Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
14a618b
feat(okx): add REST envelope types
biwasxyz May 2, 2026
af5149e
feat(okx): add REST client with envelope unwrap and US base URL switch
biwasxyz May 2, 2026
a33b81b
feat(okx): add market data helpers for ticker, tickers, books, candles
biwasxyz May 2, 2026
1edf257
feat(okx): add service barrel exports
biwasxyz May 2, 2026
de3a469
feat(okx): add okx_market_* MCP tools for ticker, tickers, books, can…
biwasxyz May 2, 2026
f8ab514
feat(okx): aggregate OKX tool groups behind registerOkxTools
biwasxyz May 2, 2026
10b4097
feat(okx): wire OKX tools into the MCP server registry
biwasxyz May 2, 2026
958c3b6
test(okx): cover client envelope, base URL switch, and market helpers
biwasxyz May 2, 2026
887883d
feat(okx): add HMAC signing and lazy credential loader
biwasxyz May 2, 2026
327fc86
feat(okx): add okxAuthGet for signed private requests + WaaS base URL
biwasxyz May 2, 2026
a9b8403
feat(okx): add DEX aggregator service for single-chain swap routing
biwasxyz May 2, 2026
d890e1d
feat(okx): add Wallet API service for chains, balances, UTXOs
biwasxyz May 2, 2026
8f61032
feat(okx): expose Phase 2 auth/dex/wallet helpers from service barrel
biwasxyz May 2, 2026
d06409b
feat(okx): add okx_dex_* MCP tools for aggregator routing
biwasxyz May 2, 2026
db9eb3a
feat(okx): add okx_wallet_* MCP tools for chains, balances, UTXOs
biwasxyz May 2, 2026
f2f761b
feat(okx): wire Phase 2 DEX + Wallet tools into the OKX aggregator
biwasxyz May 2, 2026
3dc9bf4
test(okx): cover HMAC signing, credential loader, and signed client
biwasxyz May 2, 2026
b236261
fix(okx): align tickers description with the actual instType enum
biwasxyz May 4, 2026
b586838
refactor(okx): make OkxCandle a 9-element tuple for positional safety
biwasxyz May 4, 2026
eb3b846
refactor(okx): single source of truth for candle bar enum
biwasxyz May 4, 2026
338a10f
fix(okx): read OKX_WEB3_BASE_URL at call time, not at module load
biwasxyz May 4, 2026
f9646b6
feat(okx): type DEX response shapes + drop double-casts on params
biwasxyz May 4, 2026
3be4b9c
feat(okx): make project_id optional via requireProjectId flag
biwasxyz May 4, 2026
a5df897
docs(okx): clarify candles limit=300 against the live server cap
biwasxyz May 4, 2026
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
120 changes: 120 additions & 0 deletions src/services/okx/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* OKX private API auth: HMAC signing and lazy credential loading.
*
* Signing algorithm (per OKX v5 docs, Authentication > Signature):
* pre-sign string = timestamp + METHOD + requestPath + body
* signature = base64(HMAC-SHA256(pre-sign, secret))
*
* Where:
* - timestamp ISO 8601 UTC with milliseconds (e.g. "2026-05-02T00:00:00.000Z")
* The same string goes in OK-ACCESS-TIMESTAMP and the pre-sign.
* - METHOD UPPERCASE (GET / POST)
* - requestPath absolute path including the query string for GET
* (e.g. "/api/v5/dex/aggregator/quote?chainId=1&amount=...")
* - body empty string for GET; stringified JSON for POST
*
* Credentials live in the existing aibtc credential store (encrypted at
* ~/.aibtc/credentials.enc, password from ARC_CREDS_PASSWORD env var) under
* service="okx" with keys: api_key, secret, passphrase, project_id.
*
* project_id is required for WaaS endpoints (web3.okx.com — DEX + Wallet),
* sent as the OK-ACCESS-PROJECT header. It is not used by CEX private endpoints.
*/

import crypto from "crypto";
import credentials from "../credentials.js";

export interface OkxCredentials {
apiKey: string;
secret: string;
passphrase: string;
projectId: string;
}

export class OkxCredentialsMissingError extends Error {
constructor(public readonly missing: readonly string[]) {
super(
`OKX credentials missing: ${missing.join(", ")}. ` +
"Get them at https://web3.okx.com/onchainos/dev-docs/home/developer-portal then run " +
"credentials_set for each: " +
missing.map((k) => `service='okx' key='${k}'`).join(", ")
);
this.name = "OkxCredentialsMissingError";
}
}

/**
* Build the OKX HMAC-SHA256 signature, base64-encoded.
*
* @param secret OKX API secret (raw string — NOT base64)
* @param timestamp ISO 8601 UTC ms string, must match OK-ACCESS-TIMESTAMP header
* @param method HTTP method (will be uppercased)
* @param requestPath Absolute path; for GET include the query string verbatim
* @param body Empty string for GET; stringified JSON for POST
*/
export function signOkxRequest(
secret: string,
timestamp: string,
method: string,
requestPath: string,
body = ""
): string {
const prehash = `${timestamp}${method.toUpperCase()}${requestPath}${body}`;
return crypto.createHmac("sha256", secret).update(prehash).digest("base64");
}

/**
* Generate the timestamp string OKX expects (ISO 8601 UTC with ms precision).
* Exposed so tests can inject a fixed time.
*/
export function okxTimestamp(now: Date = new Date()): string {
return now.toISOString();
}

/**
* Load all four OKX credentials, throwing OkxCredentialsMissingError listing
* every key that's not set. Unlocks the credential store first if needed.
*/
export async function getOkxCredentials(): Promise<OkxCredentials> {
if (!credentials.isUnlocked()) {
await credentials.unlock();
}
const apiKey = credentials.get("okx", "api_key");
const secret = credentials.get("okx", "secret");
const passphrase = credentials.get("okx", "passphrase");
const projectId = credentials.get("okx", "project_id");

const missing: string[] = [];
if (!apiKey) missing.push("api_key");
if (!secret) missing.push("secret");
if (!passphrase) missing.push("passphrase");
if (!projectId) missing.push("project_id");

if (missing.length > 0) throw new OkxCredentialsMissingError(missing);

return {
apiKey: apiKey as string,
secret: secret as string,
passphrase: passphrase as string,
projectId: projectId as string,
};
}

/**
* Build the four OKX auth headers for a signed request.
*/
export function buildOkxAuthHeaders(
creds: OkxCredentials,
timestamp: string,
method: string,
requestPath: string,
body = ""
): Record<string, string> {
return {
"OK-ACCESS-KEY": creds.apiKey,
"OK-ACCESS-SIGN": signOkxRequest(creds.secret, timestamp, method, requestPath, body),
"OK-ACCESS-PASSPHRASE": creds.passphrase,
"OK-ACCESS-TIMESTAMP": timestamp,
"OK-ACCESS-PROJECT": creds.projectId,
};
}
120 changes: 120 additions & 0 deletions src/services/okx/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Low-level OKX REST client.
*
* Wraps fetch with the OKX v5 response envelope: throws OkxApiError on
* non-zero `code` or non-2xx HTTP status, returns the unwrapped `data` array
* on success.
*
* Base URL is configurable via OKX_BASE_URL env var so US users can route
* through https://us.okx.com (US-restricted), defaulting to https://www.okx.com.
*
* Phase 1 only uses public market endpoints (no auth). HMAC signing for
* DEX/Wallet/CEX private endpoints will be added in Phase 2.
*/

import type { OkxEnvelope } from "./types.js";
import { OkxApiError } from "./types.js";
import { buildOkxAuthHeaders, okxTimestamp, type OkxCredentials } from "./auth.js";

const DEFAULT_BASE_URL = "https://www.okx.com";
const DEFAULT_WEB3_BASE_URL = "https://web3.okx.com";

export function getOkxBaseUrl(): string {
const env = process.env.OKX_BASE_URL?.trim();
return env && env.length > 0 ? env.replace(/\/$/, "") : DEFAULT_BASE_URL;
}

/**
* Base URL for WaaS endpoints (DEX aggregator + Wallet API). Configurable
* via OKX_WEB3_BASE_URL; defaults to https://web3.okx.com.
*/
export function getOkxWeb3BaseUrl(): string {
const env = process.env.OKX_WEB3_BASE_URL?.trim();
return env && env.length > 0 ? env.replace(/\/$/, "") : DEFAULT_WEB3_BASE_URL;
}

function buildQuery(params?: Record<string, string | number | undefined>): string {
if (!params) return "";
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== ""
);
if (entries.length === 0) return "";
const search = new URLSearchParams();
for (const [k, v] of entries) search.append(k, String(v));
return `?${search.toString()}`;
}

export interface OkxRequestOptions {
/**
* Override the base URL. Defaults to www.okx.com (CEX). Pass
* getOkxWeb3BaseUrl() for DEX/Wallet endpoints.
*/
baseUrl?: string;
}

async function parseOkxResponse<T>(response: Response): Promise<T[]> {
let body: OkxEnvelope<T>;
try {
body = (await response.json()) as OkxEnvelope<T>;
} catch {
throw new OkxApiError(
String(response.status),
`OKX returned non-JSON response (HTTP ${response.status})`,
response.status
);
}

if (!response.ok) {
throw new OkxApiError(
body?.code ?? String(response.status),
body?.msg || `OKX HTTP ${response.status}`,
response.status
);
}

if (body.code !== "0") {
throw new OkxApiError(body.code, body.msg || `OKX error ${body.code}`, response.status);
}

return body.data;
}

export async function okxGet<T>(
path: string,
params?: Record<string, string | number | undefined>,
opts: OkxRequestOptions = {}
): Promise<T[]> {
const baseUrl = opts.baseUrl ?? getOkxBaseUrl();
const url = `${baseUrl}${path}${buildQuery(params)}`;
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
});
return parseOkxResponse<T>(response);
}

/**
* Signed GET request for OKX private endpoints (CEX trading, DEX aggregator,
* Wallet API). Builds the OK-ACCESS-* headers using the supplied credentials
* and the canonical pre-sign string (timestamp + method + path-with-query + body).
*
* For WaaS endpoints (DEX/Wallet), pass `opts.baseUrl: getOkxWeb3BaseUrl()`.
*/
export async function okxAuthGet<T>(
path: string,
params: Record<string, string | number | undefined> | undefined,
creds: OkxCredentials,
opts: OkxRequestOptions = {}
): Promise<T[]> {
const baseUrl = opts.baseUrl ?? getOkxBaseUrl();
const query = buildQuery(params);
const requestPath = `${path}${query}`;
const url = `${baseUrl}${requestPath}`;
const timestamp = okxTimestamp();
const headers = {
Accept: "application/json",
...buildOkxAuthHeaders(creds, timestamp, "GET", requestPath, ""),
};
const response = await fetch(url, { method: "GET", headers });
return parseOkxResponse<T>(response);
}
124 changes: 124 additions & 0 deletions src/services/okx/dex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* OKX DEX Aggregator (WaaS) — single-chain swap endpoints.
*
* Base: https://web3.okx.com/api/v5/dex/aggregator/*
* Auth: OK-ACCESS-{KEY,SIGN,PASSPHRASE,TIMESTAMP,PROJECT} (signed)
*
* IMPORTANT: /swap does NOT broadcast. The response contains a `tx` object
* the caller must sign and broadcast through the user's own wallet
* (web3.js, ethers, or our internal bitcoin-builder for BTC routes once
* those are added). Do not claim a swap executed after calling /swap.
*
* Phase 2 covers single-chain swaps only. Cross-chain bridge endpoints
* exist on /api/v5/dex/cross-chain/* but their exact param shapes were
* not verifiable without a live key — deferred until verified.
*/

import { okxAuthGet, getOkxWeb3BaseUrl } from "./client.js";
import { getOkxCredentials } from "./auth.js";

const WAAS = { baseUrl: getOkxWeb3BaseUrl() };

export interface OkxDexChain {
chainId: string;
chainName: string;
dexTokenApproveAddress?: string;
[k: string]: unknown;
}

export interface OkxDexToken {
decimals: string;
tokenContractAddress: string;
tokenLogoUrl?: string;
tokenName: string;
tokenSymbol: string;
[k: string]: unknown;
}

export interface OkxDexQuoteParams {
chainId: string;
fromTokenAddress: string;
toTokenAddress: string;
/** Amount in the smallest unit of fromToken (wei for ETH, satoshi for BTC) */
amount: string;
/** Optional decimal slippage, e.g. "0.05" for 5% */
slippage?: string;
}

export interface OkxDexSwapParams extends OkxDexQuoteParams {
/** EVM address that will execute the swap. Required. */
userWalletAddress: string;
slippage: string;
/** Optional referrer fee address (per OKX docs) */
referrerAddress?: string;
}

export interface OkxDexApproveParams {
chainId: string;
tokenContractAddress: string;
/** Approval amount in the smallest unit (typically max uint256) */
approveAmount: string;
}

export async function getDexSupportedChains(): Promise<OkxDexChain[]> {
const creds = await getOkxCredentials();
return okxAuthGet<OkxDexChain>(
"/api/v5/dex/aggregator/supported/chain",
undefined,
creds,
WAAS
);
}

export async function getDexAllTokens(chainId: string): Promise<OkxDexToken[]> {
const creds = await getOkxCredentials();
return okxAuthGet<OkxDexToken>(
"/api/v5/dex/aggregator/all-tokens",
{ chainId },
creds,
WAAS
);
}

/**
* Get an estimated swap output. Read-only — does not produce a tx.
* Response includes router path, gas estimate, and toTokenAmount.
*/
export async function getDexQuote(params: OkxDexQuoteParams): Promise<unknown[]> {
const creds = await getOkxCredentials();
return okxAuthGet<unknown>(
"/api/v5/dex/aggregator/quote",
params as unknown as Record<string, string>,
creds,
WAAS
);
}

/**
* Get a pre-built swap transaction. The response's `data[0].tx` object
* contains `{ data, from, to, value, gas, gasPrice, minReceiveAmount }`
* which the caller must sign + broadcast separately.
*/
export async function getDexSwapTx(params: OkxDexSwapParams): Promise<unknown[]> {
const creds = await getOkxCredentials();
return okxAuthGet<unknown>(
"/api/v5/dex/aggregator/swap",
params as unknown as Record<string, string>,
creds,
WAAS
);
}

/**
* Get the calldata required to approve an ERC-20 token for the OKX DEX
* router. Only needed for ERC-20 swaps (not native ETH or BTC).
*/
export async function getDexApproveTx(params: OkxDexApproveParams): Promise<unknown[]> {
const creds = await getOkxCredentials();
return okxAuthGet<unknown>(
"/api/v5/dex/aggregator/approve-transaction",
params as unknown as Record<string, string>,
creds,
WAAS
);
}
Loading
Loading