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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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
PANORA_API_KEY=""
OKX_API_KEY=
OKX_SECRET_KEY=
OKX_API_PASSPHRASE=
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ jobs:

- name: Run and Test
env:
PANORA_API_KEY: "ci-dummy-panora-key"
OKX_API_KEY: "ci-dummy-key"
OKX_SECRET_KEY: "ci-dummy-secret"
OKX_API_PASSPHRASE: "ci-dummy-passphrase"
OKX_PROJECT_ID: "ci-dummy-project"
run: |
docker run -d --rm --name test-container -p 3000:3000 \
-e PANORA_API_KEY \
-e OKX_API_KEY \
-e OKX_SECRET_KEY \
-e OKX_API_PASSPHRASE \
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const providers: Record<string, Protocol> = {
cetus: new CetusAggregatorProvider(process.env.SUI_URL || "https://fullnode.mainnet.sui.io"),
relay: new RelayProvider(),
orca: new OrcaWhirlpoolProvider(solanaRpc),
panora: new PanoraProvider({ rpcUrl: process.env.APTOS_URL }),
panora: new PanoraProvider(),
okx: new OkxProvider(solanaRpc),
};

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"devDependencies": {
"@types/jest": "30.0.0",
"jest": "30.2.0",
"knip": "5.84.0",
"oxfmt": "0.33.0",
"knip": "5.84.1",
"oxfmt": "0.34.0",
"oxlint": "1.48.0",
"ts-jest": "29.4.5",
"typescript": "5.9.3"
Expand Down
2 changes: 0 additions & 2 deletions packages/swapper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@
"@gemwallet/types": "workspace:*",
"@mayanfinance/swap-sdk": "12.2.5",
"@mysten/sui": "1.45.2",
"@okx-dex/okx-dex-sdk": "1.0.18",
"@orca-so/whirlpools": "7.0.1",
"@orca-so/whirlpools-client": "6.2.0",
"@orca-so/whirlpools-core": "3.1.0",
"@panoraexchange/swap-sdk": "1.3.1",
"@solana-program/token-2022": "0.6.1",
"@solana/instructions": "5.5.1",
"@solana/kit": "5.5.1",
Expand Down
14 changes: 7 additions & 7 deletions packages/swapper/src/okx/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# OKX Provider (Solana)
# OKX Provider

This provider implements Solana swap support against OKX DEX aggregator APIs
using the official `@okx-dex/okx-dex-sdk`.
This provider implements swap support against OKX DEX aggregator APIs
using a thin HMAC-SHA256 authenticated HTTP client (`client.ts`).

## Scope

Expand All @@ -17,7 +17,7 @@ using the official `@okx-dex/okx-dex-sdk`.

## Auth and Security

- Authentication is handled by the SDK's built-in HMAC-SHA256 signing.
- Authentication is handled via HMAC-SHA256 request signing in `client.ts`.
- Required env vars (server-side only):
- `OKX_API_KEY`
- `OKX_SECRET_KEY`
Expand All @@ -29,14 +29,14 @@ using the official `@okx-dex/okx-dex-sdk`.
Quotes are limited to top Solana DEXes by TVL (see `constants.ts`):
Raydium, Orca, Meteora, Sanctum, PumpSwap, PancakeSwap V3, Phoenix, OpenBook V2.

The full liquidity list can be fetched via `OKXDexClient.dex.getLiquidity("501")`.
The full liquidity list can be fetched via the OKX DEX API `/api/v6/dex/aggregator/all-tokens`.

## Data Flow

1. `OkxProvider.get_quote(...)`
2. Validate Solana assets and map native SOL to `11111111111111111111111111111111`
3. Request quote via `OKXDexClient.dex.getQuote()` (filtered by `dexIds`)
4. Request swap data via `OKXDexClient.dex.getSwapData()` with `autoSlippage: true`
3. Request quote via `OkxDexClient.getQuote()` (filtered by `dexIds`)
4. Request swap data via `OkxDexClient.getSwapData()` with `autoSlippage: true`
5. Store OKX route in `quote.route_data`
6. `OkxProvider.get_quote_data(...)`
7. Build swap request with auto slippage and optional referral data
Expand Down
84 changes: 84 additions & 0 deletions packages/swapper/src/okx/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { buildHeaders, buildQueryString, sign } from "./auth";

describe("OKX auth", () => {
describe("buildQueryString", () => {
it("returns empty string for empty params", () => {
expect(buildQueryString({})).toBe("");
});

it("builds query string from params", () => {
const qs = buildQueryString({ chainIndex: "501", amount: "1000" });
expect(qs).toBe("?chainIndex=501&amount=1000");
});

it("filters out undefined and null values", () => {
const qs = buildQueryString({ chainIndex: "501", dexIds: undefined, feePercent: null });
expect(qs).toBe("?chainIndex=501");
});

it("encodes special characters", () => {
const qs = buildQueryString({ key: "a b&c" });
expect(qs).toBe("?key=a%20b%26c");
});
});

describe("sign", () => {
it("produces stable HMAC-SHA256 signature", () => {
const sig = sign("2026-01-01T00:00:00.000Z", "GET", "/api/v6/dex/aggregator/quote?chainIndex=501", "test-secret");
expect(sig).toBe(sign("2026-01-01T00:00:00.000Z", "GET", "/api/v6/dex/aggregator/quote?chainIndex=501", "test-secret"));
expect(typeof sig).toBe("string");
expect(sig.length).toBeGreaterThan(0);
});

it("changes with different timestamps", () => {
const sig1 = sign("2026-01-01T00:00:00.000Z", "GET", "/path", "secret");
const sig2 = sign("2026-01-02T00:00:00.000Z", "GET", "/path", "secret");
expect(sig1).not.toBe(sig2);
});

it("changes with different paths", () => {
const sig1 = sign("2026-01-01T00:00:00.000Z", "GET", "/path-a", "secret");
const sig2 = sign("2026-01-01T00:00:00.000Z", "GET", "/path-b", "secret");
expect(sig1).not.toBe(sig2);
});

it("changes with different secrets", () => {
const sig1 = sign("2026-01-01T00:00:00.000Z", "GET", "/path", "secret-a");
const sig2 = sign("2026-01-01T00:00:00.000Z", "GET", "/path", "secret-b");
expect(sig1).not.toBe(sig2);
});
});

describe("buildHeaders", () => {
it("includes all required OKX headers", () => {
const config = {
apiKey: "test-key",
secretKey: "test-secret",
apiPassphrase: "test-pass",
projectId: "test-project",
};
const headers = buildHeaders(config, "2026-01-01T00:00:00.000Z", "/api/v6/dex/aggregator/quote");

expect(headers["OK-ACCESS-KEY"]).toBe("test-key");
expect(headers["OK-ACCESS-PASSPHRASE"]).toBe("test-pass");
expect(headers["OK-ACCESS-PROJECT"]).toBe("test-project");
expect(headers["OK-ACCESS-TIMESTAMP"]).toBe("2026-01-01T00:00:00.000Z");
expect(headers["OK-ACCESS-SIGN"]).toBeDefined();
expect(headers["Content-Type"]).toBe("application/json");
});

it("sign matches HMAC-SHA256 of timestamp+method+path", () => {
const config = {
apiKey: "k",
secretKey: "my-secret",
apiPassphrase: "p",
projectId: "proj",
};
const timestamp = "2026-01-01T00:00:00.000Z";
const path = "/api/v6/dex/aggregator/quote?chainIndex=501";
const headers = buildHeaders(config, timestamp, path);

expect(headers["OK-ACCESS-SIGN"]).toBe(sign(timestamp, "GET", path, "my-secret"));
});
});
});
35 changes: 35 additions & 0 deletions packages/swapper/src/okx/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createHmac } from "node:crypto";

export interface OkxClientConfig {
apiKey: string;
secretKey: string;
apiPassphrase: string;
projectId: string;
}

export function buildQueryString(params: object): string {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null);
if (entries.length === 0) return "";
return "?" + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
}

export function sign(timestamp: string, method: string, path: string, secretKey: string): string {
return createHmac("sha256", secretKey).update(timestamp + method + path).digest("base64");
}

const HEADER_KEY = "OK-ACCESS-KEY";
const HEADER_SIGN = "OK-ACCESS-SIGN";
const HEADER_TIMESTAMP = "OK-ACCESS-TIMESTAMP";
const HEADER_PASSPHRASE = "OK-ACCESS-PASSPHRASE";
const HEADER_PROJECT = "OK-ACCESS-PROJECT";

export function buildHeaders(config: OkxClientConfig, timestamp: string, fullPath: string): Record<string, string> {
return {
"Content-Type": "application/json",
[HEADER_KEY]: config.apiKey,
[HEADER_SIGN]: sign(timestamp, "GET", fullPath, config.secretKey),
[HEADER_TIMESTAMP]: timestamp,
[HEADER_PASSPHRASE]: config.apiPassphrase,
[HEADER_PROJECT]: config.projectId,
};
}
44 changes: 44 additions & 0 deletions packages/swapper/src/okx/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { buildHeaders, buildQueryString } from "./auth";
import type { OkxClientConfig } from "./auth";
import type { ChainData, OkxApiResponse, QuoteData, QuoteParams, SwapParams, TransactionData } from "./models";

const BASE_URL = "https://web3.okx.com";

export class OkxDexClient {
private readonly config: OkxClientConfig;

constructor(config: OkxClientConfig) {
this.config = config;
}

async getQuote(params: QuoteParams): Promise<OkxApiResponse<QuoteData>> {
return this.get("/api/v6/dex/aggregator/quote", params);
}

async getSwapData(params: SwapParams): Promise<OkxApiResponse<{ routerResult: QuoteData; tx: TransactionData }>> {
return this.get("/api/v6/dex/aggregator/swap", params);
}

async getChainData(chainIndex: string): Promise<OkxApiResponse<ChainData>> {
return this.get("/api/v6/dex/aggregator/supported/chain", { chainIndex });
}

private async get<T>(path: string, params: object): Promise<T> {
const queryString = buildQueryString(params);
const fullPath = path + queryString;
const timestamp = new Date().toISOString();

const response = await fetch(BASE_URL + fullPath, {
method: "GET",
headers: buildHeaders(this.config, timestamp, fullPath),
});

if (!response.ok) {
const text = await response.text();
throw new Error(`OKX API ${response.status}: ${text}`);
}

const json = await response.json() as T;
return json;
}
}
8 changes: 4 additions & 4 deletions packages/swapper/src/okx/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
require("dotenv").config({ path: "../../.env" });

import { Chain, QuoteRequest } from "@gemwallet/types";
import { OKXDexClient } from "@okx-dex/okx-dex-sdk";

import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, XLAYER_USD0_ADDRESS } from "../testkit/mock";
import { OkxDexClient } from "./client";
import { CHAIN_INDEX } from "./constants";
import { OkxProvider } from "./provider";

Expand All @@ -18,8 +18,8 @@ const hasAuth = hasAuthEnv();
const runIntegration = process.env.INTEGRATION_TEST === "1" && hasAuth;
const itIntegration = runIntegration ? it : it.skip;

function createClient(): OKXDexClient {
return new OKXDexClient({
function createClient(): OkxDexClient {
return new OkxDexClient({
apiKey: process.env.OKX_API_KEY!,
secretKey: process.env.OKX_SECRET_KEY!,
apiPassphrase: process.env.OKX_API_PASSPHRASE!,
Expand Down Expand Up @@ -96,7 +96,7 @@ describe("OKX live integration", () => {
describe("Chain Data", () => {
itIntegration("fetches XLayer approve spender address", async () => {
const client = createClient();
const response = await client.dex.getChainData(CHAIN_INDEX[Chain.XLayer]);
const response = await client.getChainData(CHAIN_INDEX[Chain.XLayer]);

expect(response.code).toBe("0");
expect(response.data.length).toBeGreaterThan(0);
Expand Down
63 changes: 63 additions & 0 deletions packages/swapper/src/okx/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export interface OkxApiResponse<T> {
code: string;
msg: string;
data: T[];
}

export interface TokenInfo {
tokenContractAddress: string;
tokenSymbol: string;
decimal: string;
tokenUnitPrice: string;
}

export interface QuoteData {
fromToken: TokenInfo;
toToken: TokenInfo;
fromTokenAmount: string;
toTokenAmount: string;
estimateGasFee: string;
tx?: TransactionData;
}

export interface TransactionData {
data: string;
from: string;
to: string;
value: string;
gas: string;
minReceiveAmount: string;
slippagePercent: string;
signatureData?: string[];
}

export interface ChainData {
chainIndex: string;
chainName: string;
dexTokenApproveAddress: string | null;
}

export interface QuoteParams {
chainIndex: string;
amount: string;
fromTokenAddress: string;
toTokenAddress: string;
slippagePercent: string;
dexIds?: string;
feePercent?: string;
userWalletAddress?: string;
}

export interface SwapParams {
chainIndex: string;
amount: string;
fromTokenAddress: string;
toTokenAddress: string;
userWalletAddress: string;
slippagePercent?: string;
autoSlippage?: boolean;
maxAutoSlippagePercent?: string;
dexIds?: string;
feePercent?: string;
fromTokenReferrerWalletAddress?: string;
}
5 changes: 3 additions & 2 deletions packages/swapper/src/okx/provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Chain, Quote } from "@gemwallet/types";
import type { OKXDexClient } from "@okx-dex/okx-dex-sdk";

import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, XLAYER_USD0_ADDRESS } from "../testkit/mock";
import type { OkxDexClient } from "./client";

import { OkxProvider } from "./provider";

const SOL_MINT = "11111111111111111111111111111111";
Expand All @@ -20,7 +21,7 @@ function createProvider() {
code: "0",
data: [{ dexTokenApproveAddress: MOCK_APPROVE_ADDRESS }],
});
const client = { dex: { getQuote, getSwapData, getChainData } } as unknown as OKXDexClient;
const client = { getQuote, getSwapData, getChainData } as unknown as OkxDexClient;
const provider = new OkxProvider("https://localhost:8899", client);
return { provider, getQuote, getSwapData, getChainData };
}
Expand Down
Loading
Loading