diff --git a/src/services/okx/auth.ts b/src/services/okx/auth.ts new file mode 100644 index 00000000..9e3337e0 --- /dev/null +++ b/src/services/okx/auth.ts @@ -0,0 +1,138 @@ +/** + * 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; + /** + * WaaS-only (DEX + Wallet API). Optional so future CEX-private + * callers can omit it — for those flows pass requireProjectId=false + * to getOkxCredentials. + */ + 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 OKX credentials, throwing OkxCredentialsMissingError listing every + * key that's not set. Unlocks the credential store first if needed. + * + * @param requireProjectId When true (default), project_id is required and + * included in the returned credentials. WaaS + * endpoints (DEX + Wallet) need this. Pass false + * for CEX-private endpoints which don't use the + * OK-ACCESS-PROJECT header. + */ +export async function getOkxCredentials( + requireProjectId = true +): Promise { + 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 (requireProjectId && !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 ?? undefined, + }; +} + +/** + * Build the OKX auth headers for a signed request. OK-ACCESS-PROJECT is + * only included when the credentials have a projectId — WaaS endpoints + * require it, CEX-private endpoints reject it (or simply ignore it). + */ +export function buildOkxAuthHeaders( + creds: OkxCredentials, + timestamp: string, + method: string, + requestPath: string, + body = "" +): Record { + const headers: Record = { + "OK-ACCESS-KEY": creds.apiKey, + "OK-ACCESS-SIGN": signOkxRequest(creds.secret, timestamp, method, requestPath, body), + "OK-ACCESS-PASSPHRASE": creds.passphrase, + "OK-ACCESS-TIMESTAMP": timestamp, + }; + if (creds.projectId) { + headers["OK-ACCESS-PROJECT"] = creds.projectId; + } + return headers; +} diff --git a/src/services/okx/client.ts b/src/services/okx/client.ts new file mode 100644 index 00000000..f5599ad3 --- /dev/null +++ b/src/services/okx/client.ts @@ -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 { + 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(response: Response): Promise { + let body: OkxEnvelope; + try { + body = (await response.json()) as OkxEnvelope; + } 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( + path: string, + params?: Record, + opts: OkxRequestOptions = {} +): Promise { + const baseUrl = opts.baseUrl ?? getOkxBaseUrl(); + const url = `${baseUrl}${path}${buildQuery(params)}`; + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + }); + return parseOkxResponse(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( + path: string, + params: Record | undefined, + creds: OkxCredentials, + opts: OkxRequestOptions = {} +): Promise { + 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(response); +} diff --git a/src/services/okx/dex.ts b/src/services/okx/dex.ts new file mode 100644 index 00000000..118c75dc --- /dev/null +++ b/src/services/okx/dex.ts @@ -0,0 +1,185 @@ +/** + * 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"; + +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; +} + +/** + * Param interfaces include `[k: string]: string | undefined` so they + * conform structurally to the okxAuthGet query-params type without a + * cast. The named keys are still documented and required where marked. + */ +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; + [k: string]: string | undefined; +} + +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; + [k: string]: string | undefined; +} + +/** + * Token sub-object included in quote and swap responses. + * Shape per OKX WaaS DEX docs (https://web3.okx.com/build/docs/waas/dex-swap). + */ +export interface OkxDexTokenInfo { + decimal: string; + tokenContractAddress: string; + tokenSymbol: string; + tokenUnitPrice?: string; + [k: string]: unknown; +} + +/** Quote response — read-only, no tx returned. */ +export interface OkxDexQuoteResponse { + chainId: string; + dexRouterList: unknown[]; + estimateGasFee: string; + fromToken: OkxDexTokenInfo; + toToken: OkxDexTokenInfo; + fromTokenAmount: string; + toTokenAmount: string; + [k: string]: unknown; +} + +/** + * Swap response — extends quote with a `tx` object the caller must + * sign and broadcast separately. This service does NOT broadcast. + */ +export interface OkxDexSwapResponse extends OkxDexQuoteResponse { + tx: { + data: string; + from: string; + to: string; + value: string; + gas: string; + gasPrice: string; + minReceiveAmount: string; + [k: string]: unknown; + }; +} + +/** ERC-20 approval calldata response. */ +export interface OkxDexApproveResponse { + data: string; + dexContractAddress: string; + gasLimit: string; + gasPrice: string; + [k: string]: unknown; +} + +export async function getDexSupportedChains(): Promise { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/supported/chain", + undefined, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} + +export async function getDexAllTokens(chainId: string): Promise { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/all-tokens", + { chainId }, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} + +/** + * 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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/quote", + params, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} + +/** + * 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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/swap", + params, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} + +/** + * 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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/approve-transaction", + params, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} diff --git a/src/services/okx/index.ts b/src/services/okx/index.ts new file mode 100644 index 00000000..15755782 --- /dev/null +++ b/src/services/okx/index.ts @@ -0,0 +1,55 @@ +export * from "./types.js"; +export { OKX_CANDLE_BARS } from "./types.js"; +export { + okxGet, + okxAuthGet, + getOkxBaseUrl, + getOkxWeb3BaseUrl, +} from "./client.js"; +export type { OkxRequestOptions } from "./client.js"; +export { + getTicker, + getTickers, + getOrderBook, + getCandles, +} from "./market.js"; +export type { OkxTicker, OkxOrderBook, OkxCandle } from "./market.js"; + +export { + signOkxRequest, + okxTimestamp, + buildOkxAuthHeaders, + getOkxCredentials, + OkxCredentialsMissingError, +} from "./auth.js"; +export type { OkxCredentials } from "./auth.js"; + +export { + getDexSupportedChains, + getDexAllTokens, + getDexQuote, + getDexSwapTx, + getDexApproveTx, +} from "./dex.js"; +export type { + OkxDexChain, + OkxDexToken, + OkxDexQuoteParams, + OkxDexSwapParams, + OkxDexApproveParams, + OkxDexTokenInfo, + OkxDexQuoteResponse, + OkxDexSwapResponse, + OkxDexApproveResponse, +} from "./dex.js"; + +export { + getWalletSupportedChains, + getWalletTokenBalances, + getWalletUtxos, +} from "./wallet.js"; +export type { + OkxWalletChain, + OkxWalletTokenBalance, + OkxWalletUtxo, +} from "./wallet.js"; diff --git a/src/services/okx/market.ts b/src/services/okx/market.ts new file mode 100644 index 00000000..709839bb --- /dev/null +++ b/src/services/okx/market.ts @@ -0,0 +1,91 @@ +/** + * OKX public market data endpoints. + * + * All endpoints under /api/v5/market/* are public — no API key required. + * Verified 2026-05-02 by direct curl on www.okx.com and us.okx.com. + * + * Rate limit: 20 requests / 2 seconds per IP (per OKX v5 docs). + */ + +import { okxGet } from "./client.js"; +import type { OkxCandleBar, OkxTickersInstType } from "./types.js"; + +export interface OkxTicker { + instType: string; + instId: string; + last: string; + lastSz: string; + askPx: string; + askSz: string; + bidPx: string; + bidSz: string; + open24h: string; + high24h: string; + low24h: string; + volCcy24h: string; + vol24h: string; + sodUtc0: string; + sodUtc8: string; + ts: string; +} + +export interface OkxOrderBook { + asks: string[][]; + bids: string[][]; + ts: string; +} + +/** + * A single candlestick row from /api/v5/market/candles. + * Positional tuple: [ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm]. + * Verified live (2026-05-02): always 9 elements, all stringified numbers/timestamps. + */ +export type OkxCandle = [ + ts: string, + open: string, + high: string, + low: string, + close: string, + vol: string, + volCcy: string, + volCcyQuote: string, + confirm: string, +]; + +export async function getTicker(instId: string): Promise { + const data = await okxGet("/api/v5/market/ticker", { instId }); + return data[0]; +} + +export async function getTickers( + instType: OkxTickersInstType, + uly?: string, + instFamily?: string +): Promise { + return okxGet("/api/v5/market/tickers", { + instType, + uly, + instFamily, + }); +} + +export async function getOrderBook(instId: string, sz?: number): Promise { + const data = await okxGet("/api/v5/market/books", { instId, sz }); + return data[0]; +} + +export async function getCandles( + instId: string, + bar: OkxCandleBar = "1H", + limit = 100, + after?: string, + before?: string +): Promise { + return okxGet("/api/v5/market/candles", { + instId, + bar, + limit, + after, + before, + }); +} diff --git a/src/services/okx/types.ts b/src/services/okx/types.ts new file mode 100644 index 00000000..5a1482a2 --- /dev/null +++ b/src/services/okx/types.ts @@ -0,0 +1,52 @@ +/** + * Shared OKX REST API types. + * + * Every OKX v5 REST response follows the same envelope: + * { code: "0", msg: "", data: T[] } + * Non-zero `code` indicates an error; `data` is always an array even for + * single-instrument lookups. + */ + +export interface OkxEnvelope { + code: string; + msg: string; + data: T[]; +} + +export class OkxApiError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status?: number + ) { + super(message); + this.name = "OkxApiError"; + } +} + +export type OkxInstrumentType = "SPOT" | "MARGIN" | "SWAP" | "FUTURES" | "OPTION"; + +/** + * Subset of OkxInstrumentType accepted by `/api/v5/market/tickers`. MARGIN is + * rejected with `51000 Parameter instType error` on this endpoint specifically + * (verified live 2026-05-02), even though MARGIN is a valid instType elsewhere. + */ +export type OkxTickersInstType = "SPOT" | "SWAP" | "FUTURES" | "OPTION"; + +/** + * Candle intervals accepted by `/api/v5/market/candles` (verified live 2026-05-02). + * Lowercase `m` = minutes, uppercase `M` = months — case is significant. + * Note: 8H, 6M, and 1Y are NOT accepted on this endpoint (returns 51000). + * UTC-aligned variants are supported only for ≥6H bars. + * + * Single source of truth — the Zod enum in market.tools.ts derives from + * this const, so adding a bar updates both the type and the schema. + */ +export const OKX_CANDLE_BARS = [ + "1m", "3m", "5m", "15m", "30m", + "1H", "2H", "4H", "6H", "12H", + "1D", "2D", "3D", "1W", "1M", "3M", + "6Hutc", "12Hutc", "1Dutc", "1Wutc", "1Mutc", "3Mutc", +] as const; + +export type OkxCandleBar = (typeof OKX_CANDLE_BARS)[number]; diff --git a/src/services/okx/wallet.ts b/src/services/okx/wallet.ts new file mode 100644 index 00000000..0e8a23a3 --- /dev/null +++ b/src/services/okx/wallet.ts @@ -0,0 +1,101 @@ +/** + * OKX Wallet API (WaaS) — read-only address-level queries. + * + * Base: https://web3.okx.com/api/v5/wallet/* + * Auth: same OK-ACCESS-* scheme as DEX aggregator (signed + project id) + * + * Phase 2 covers only the WaaS endpoints whose paths were verified live + * (return 50111 with dummy auth, not 404). BRC-20 / Runes / Inscriptions + * are NOT served by WaaS — they live on OKLink Explorer (separate API, + * separate auth) and are intentionally NOT included here. A future phase + * can add OKLink integration if needed. + * + * chainIndex values are decimal/string identifiers. Do NOT hardcode them + * client-side — call getWalletSupportedChains() to enumerate the + * authoritative list returned by OKX (correct for the caller's account). + */ + +import { okxAuthGet, getOkxWeb3BaseUrl } from "./client.js"; +import { getOkxCredentials } from "./auth.js"; + +export interface OkxWalletChain { + name: string; + logoUrl?: string; + shortName?: string; + chainIndex: string; + [k: string]: unknown; +} + +export interface OkxWalletTokenBalance { + chainIndex: string; + tokenAddress: string; + symbol?: string; + balance: string; + /** USD-denominated price snapshot at query time */ + tokenPrice?: string; + isRiskToken?: boolean; + [k: string]: unknown; +} + +export interface OkxWalletUtxo { + txHash: string; + vOut: string; + height?: string; + blockTime?: string; + amount: string; + [k: string]: unknown; +} + +/** + * Enumerate chains supported by the Wallet API for the calling account. + * Returns the authoritative chainIndex values to use in subsequent calls. + */ +export async function getWalletSupportedChains(): Promise { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/wallet/chain/supported-chains", + undefined, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} + +/** + * Get fungible token balances for an address on one or more chains. + * + * @param address EVM (0x…) or chain-native address (BTC bc1… etc.) + * @param chains Comma-separated chainIndex list, e.g. "1" or "1,8453" + * Use getWalletSupportedChains() to discover valid values. + */ +export async function getWalletTokenBalances( + address: string, + chains: string +): Promise { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/wallet/asset/all-token-balances-by-address", + { address, chains }, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} + +/** + * Get UTXOs for an address on a Bitcoin-family chain. + * + * @param chainIndex Chain identifier (e.g. Bitcoin L1; verify via + * getWalletSupportedChains()) + * @param address Bitcoin address + */ +export async function getWalletUtxos( + chainIndex: string, + address: string +): Promise { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/wallet/utxo/utxos", + { chainIndex, address }, + creds, + { baseUrl: getOkxWeb3BaseUrl() } + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 56942148..8f99f063 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -45,6 +45,7 @@ import { registerBountyScannerTools } from "./bounty-scanner.tools.js"; import { registerRunesTools } from "./runes.tools.js"; import { registerInboxTools } from "./inbox.tools.js"; import { registerArxivResearchTools } from "./arxiv-research.tools.js"; +import { registerOkxTools } from "./okx/index.js"; import { getSkillForTool } from "./skill-mappings.js"; /** @@ -212,5 +213,8 @@ export function registerAllTools(server: McpServer): void { // arXiv Research (public arXiv Atom API — paper search and digest compilation) registerArxivResearchTools(server); + // OKX exchange (public market data — ticker, tickers, orderbook, candles) + registerOkxTools(server); + restoreRegisterTool(); } diff --git a/src/tools/okx/dex.tools.ts b/src/tools/okx/dex.tools.ts new file mode 100644 index 00000000..7c6aaac0 --- /dev/null +++ b/src/tools/okx/dex.tools.ts @@ -0,0 +1,174 @@ +/** + * OKX DEX Aggregator MCP tools (single-chain swap routing). + * + * Auth: every tool here calls getOkxCredentials() inside the service layer. + * If any of the four credentials (api_key, secret, passphrase, project_id) + * is missing, the tool returns an OkxCredentialsMissingError describing + * which keys to set via the existing credentials_set tool. Users do not + * need to provision keys until the first invocation. + * + * IMPORTANT: okx_dex_swap_tx returns pre-built transaction data only — + * it does NOT broadcast. The model must sign and broadcast the returned + * tx through a wallet (EVM tools or our internal bitcoin-builder). + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createJsonResponse, createErrorResponse } from "../../utils/index.js"; +import { + getDexAllTokens, + getDexApproveTx, + getDexQuote, + getDexSupportedChains, + getDexSwapTx, +} from "../../services/okx/index.js"; + +const CRED_NOTE = + "Requires OKX API credentials (set via credentials_set with service='okx' " + + "for keys api_key, secret, passphrase, project_id). Get keys at " + + "https://web3.okx.com/onchainos/dev-docs/home/developer-portal."; + +export function registerOkxDexTools(server: McpServer): void { + server.registerTool( + "okx_dex_supported_chains", + { + description: + "List the chains supported by the OKX DEX aggregator (chainId, chainName, " + + "DEX router approve address). " + CRED_NOTE, + inputSchema: {}, + }, + async () => { + try { + const data = await getDexSupportedChains(); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_dex_tokens", + { + description: + "List all tokens tradeable on the OKX DEX aggregator for a given chainId. " + + "Returns each token's contract address, decimals, symbol, name, and logo. " + + CRED_NOTE, + inputSchema: { + chainId: z + .string() + .describe("Chain id (e.g. '1' for Ethereum, '8453' for Base). " + + "Call okx_dex_supported_chains first to enumerate."), + }, + }, + async ({ chainId }) => { + try { + const data = await getDexAllTokens(chainId); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_dex_quote", + { + description: + "Get an estimated swap quote across the OKX DEX aggregator's routed liquidity. " + + "Read-only — returns expected output amount, router path, and gas estimate. " + + "Does NOT produce a transaction; use okx_dex_swap_tx for that. " + CRED_NOTE, + inputSchema: { + chainId: z.string().describe("Chain id"), + fromTokenAddress: z + .string() + .describe("Source token contract address (use the chain's native-token sentinel for ETH/BNB/etc.)"), + toTokenAddress: z.string().describe("Destination token contract address"), + amount: z + .string() + .describe( + "Amount in the smallest unit of fromToken (wei for ETH, satoshi for BTC)" + ), + slippage: z + .string() + .optional() + .describe("Decimal slippage tolerance, e.g. '0.05' for 5%"), + }, + }, + async (params) => { + try { + const data = await getDexQuote(params); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_dex_swap_tx", + { + description: + "Get a pre-built swap transaction from the OKX DEX aggregator. " + + "Returns data[0].tx = { data, from, to, value, gas, gasPrice, minReceiveAmount } " + + "which the caller MUST sign and broadcast through their own wallet. " + + "This tool does NOT broadcast or claim the swap succeeded. " + + "For ERC-20 sources, call okx_dex_approve_tx first if allowance is insufficient. " + + CRED_NOTE, + inputSchema: { + chainId: z.string().describe("Chain id"), + fromTokenAddress: z.string().describe("Source token contract address"), + toTokenAddress: z.string().describe("Destination token contract address"), + amount: z.string().describe("Amount in smallest unit of fromToken"), + slippage: z + .string() + .describe("Decimal slippage tolerance, e.g. '0.05' for 5% (required for swap)"), + userWalletAddress: z + .string() + .describe("Address that will execute the swap (recipient = caller by default)"), + referrerAddress: z + .string() + .optional() + .describe("Optional referrer fee address"), + }, + }, + async (params) => { + try { + const data = await getDexSwapTx(params); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_dex_approve_tx", + { + description: + "Get the calldata required to approve an ERC-20 token for the OKX DEX router. " + + "Only needed for ERC-20 sources (not native ETH/BNB or BTC). " + + "Caller signs and broadcasts the returned tx separately. " + CRED_NOTE, + inputSchema: { + chainId: z.string().describe("Chain id"), + tokenContractAddress: z + .string() + .describe("ERC-20 token to approve"), + approveAmount: z + .string() + .describe( + "Approval amount in smallest unit. Use the max uint256 value " + + "(2^256 - 1) for unlimited approval, or the exact swap amount for tighter scope." + ), + }, + }, + async (params) => { + try { + const data = await getDexApproveTx(params); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); +} diff --git a/src/tools/okx/index.ts b/src/tools/okx/index.ts new file mode 100644 index 00000000..064f2c8d --- /dev/null +++ b/src/tools/okx/index.ts @@ -0,0 +1,20 @@ +/** + * OKX MCP tool registration. + * + * Phase 1: public market data (no API key). + * Phase 2: DEX aggregator + Wallet API with lazy credential prompts — + * no upfront setup needed; the first private-tool invocation throws a + * clear OkxCredentialsMissingError telling the user which credentials_set + * calls to make. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerOkxMarketTools } from "./market.tools.js"; +import { registerOkxDexTools } from "./dex.tools.js"; +import { registerOkxWalletTools } from "./wallet.tools.js"; + +export function registerOkxTools(server: McpServer): void { + registerOkxMarketTools(server); + registerOkxDexTools(server); + registerOkxWalletTools(server); +} diff --git a/src/tools/okx/market.tools.ts b/src/tools/okx/market.tools.ts new file mode 100644 index 00000000..97c4ce7d --- /dev/null +++ b/src/tools/okx/market.tools.ts @@ -0,0 +1,160 @@ +/** + * OKX public market data MCP tools. + * + * No API key required — these endpoints are publicly accessible on + * www.okx.com (and us.okx.com for US-restricted users). Switch host with + * the OKX_BASE_URL env var. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createJsonResponse, createErrorResponse } from "../../utils/index.js"; +import { + getCandles, + getOrderBook, + getTicker, + getTickers, + OKX_CANDLE_BARS, +} from "../../services/okx/index.js"; + +// /api/v5/market/tickers does NOT accept MARGIN (verified live: returns 51000). +const TICKERS_INSTRUMENT_TYPES = ["SPOT", "SWAP", "FUTURES", "OPTION"] as const; + +// Candle bars are sourced from the OKX_CANDLE_BARS const in services/okx/types.ts +// — single source of truth; the OkxCandleBar type derives from the same array. + +export function registerOkxMarketTools(server: McpServer): void { + server.registerTool( + "okx_market_ticker", + { + description: + "Get the latest ticker for a single OKX instrument (last price, 24h volume, " + + "best bid/ask). Public endpoint — no API key required. Set OKX_BASE_URL=https://us.okx.com " + + "for US-restricted accounts.", + inputSchema: { + instId: z + .string() + .describe("Instrument id, e.g. 'BTC-USDT' (spot), 'BTC-USDT-SWAP' (perp)"), + }, + }, + async ({ instId }) => { + try { + const ticker = await getTicker(instId); + if (!ticker) { + return createErrorResponse(new Error(`No ticker found for ${instId}`)); + } + return createJsonResponse(ticker); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_market_tickers", + { + description: + "List tickers for all instruments of a given type (SPOT/SWAP/FUTURES/OPTION). " + + "MARGIN is not supported on this endpoint (OKX returns 51000). " + + "Public endpoint — no API key required.", + inputSchema: { + instType: z + .enum(TICKERS_INSTRUMENT_TYPES) + .describe("Instrument type to list (MARGIN not supported on this endpoint)"), + uly: z + .string() + .optional() + .describe("Underlying, only for FUTURES/SWAP/OPTION (e.g. 'BTC-USD')"), + instFamily: z + .string() + .optional() + .describe("Instrument family, only for FUTURES/SWAP/OPTION"), + }, + }, + async ({ instType, uly, instFamily }) => { + try { + const data = await getTickers(instType, uly, instFamily); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_market_orderbook", + { + description: + "Get the order book snapshot for an OKX instrument. Public endpoint — no API key required.", + inputSchema: { + instId: z.string().describe("Instrument id, e.g. 'BTC-USDT'"), + sz: z + .number() + .int() + .min(1) + .max(400) + .optional() + .describe("Depth per side (1-400, default 1)"), + }, + }, + async ({ instId, sz }) => { + try { + const book = await getOrderBook(instId, sz); + if (!book) { + return createErrorResponse(new Error(`No orderbook found for ${instId}`)); + } + return createJsonResponse(book); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_market_candles", + { + description: + "Get candlestick (OHLCV) data for an OKX instrument. Each candle is " + + "[ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm]. Public endpoint — " + + "no API key required.", + inputSchema: { + instId: z.string().describe("Instrument id, e.g. 'BTC-USDT'"), + bar: z + .enum(OKX_CANDLE_BARS) + .optional() + .default("1H") + .describe("Candle interval (default '1H')"), + limit: z + .number() + .int() + .min(1) + .max(300) + .optional() + .default(100) + .describe( + "Number of candles (1-300, default 100). Verified live: " + + "1-300 returns the requested count exactly with no truncation; " + + "values above 300 silently cap to 300 server-side, so the Zod " + + "max(300) here mirrors the actual server cap rather than the " + + "older 100-bar limit some docs pages still cite." + ), + after: z + .string() + .optional() + .describe("Pagination: return records earlier than this timestamp (ms)"), + before: z + .string() + .optional() + .describe("Pagination: return records newer than this timestamp (ms)"), + }, + }, + async ({ instId, bar, limit, after, before }) => { + try { + const data = await getCandles(instId, bar, limit, after, before); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); +} diff --git a/src/tools/okx/wallet.tools.ts b/src/tools/okx/wallet.tools.ts new file mode 100644 index 00000000..c8f43d58 --- /dev/null +++ b/src/tools/okx/wallet.tools.ts @@ -0,0 +1,100 @@ +/** + * OKX Wallet API MCP tools — read-only address-level queries. + * + * Auth: same lazy credential pattern as DEX tools. First call to any + * okx_wallet_* tool surfaces an OkxCredentialsMissingError describing + * which credentials_set calls to make. + * + * Note: BRC-20, Runes, and Inscriptions are NOT exposed here — those + * data sources live on OKLink Explorer (separate API + key) and are + * out of scope for the OKX WaaS integration. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createJsonResponse, createErrorResponse } from "../../utils/index.js"; +import { + getWalletSupportedChains, + getWalletTokenBalances, + getWalletUtxos, +} from "../../services/okx/index.js"; + +const CRED_NOTE = + "Requires OKX API credentials (set via credentials_set with service='okx' " + + "for keys api_key, secret, passphrase, project_id). Get keys at " + + "https://web3.okx.com/onchainos/dev-docs/home/developer-portal."; + +export function registerOkxWalletTools(server: McpServer): void { + server.registerTool( + "okx_wallet_supported_chains", + { + description: + "Enumerate chains supported by the OKX Wallet API for the calling account, " + + "returning the authoritative chainIndex values to use in subsequent token-balance " + + "and UTXO queries. " + CRED_NOTE, + inputSchema: {}, + }, + async () => { + try { + const data = await getWalletSupportedChains(); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_wallet_token_balances", + { + description: + "Get fungible token balances for an address across one or more chains. " + + "Returns each token's balance, contract address, symbol, and a USD price snapshot. " + + "Use okx_wallet_supported_chains first to discover valid chainIndex values. " + + CRED_NOTE, + inputSchema: { + address: z + .string() + .describe("Wallet address (EVM 0x… or chain-native, e.g. BTC bc1…)"), + chains: z + .string() + .describe( + "Comma-separated chainIndex list (e.g. '1' or '1,8453' for Ethereum + Base)" + ), + }, + }, + async ({ address, chains }) => { + try { + const data = await getWalletTokenBalances(address, chains); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); + + server.registerTool( + "okx_wallet_utxos", + { + description: + "Get UTXOs for an address on a Bitcoin-family chain. Each entry includes " + + "txHash, vOut, amount, and confirmation height. " + CRED_NOTE, + inputSchema: { + chainIndex: z + .string() + .describe( + "Bitcoin-family chainIndex (call okx_wallet_supported_chains to discover)" + ), + address: z.string().describe("Bitcoin address"), + }, + }, + async ({ chainIndex, address }) => { + try { + const data = await getWalletUtxos(chainIndex, address); + return createJsonResponse(data); + } catch (error) { + return createErrorResponse(error); + } + } + ); +} diff --git a/tests/services/okx-auth.test.ts b/tests/services/okx-auth.test.ts new file mode 100644 index 00000000..1f4bf76d --- /dev/null +++ b/tests/services/okx-auth.test.ts @@ -0,0 +1,345 @@ +/** + * Tests for OKX HMAC signing, credential loading, and the signed + * okxAuthGet client path. + * + * The HMAC vector is computed with openssl independently: + * + * echo -n "2026-05-02T00:00:00.000ZGET/api/v5/dex/aggregator/quote?chainId=1" \ + * | openssl dgst -sha256 -hmac "test-secret-abc123" -binary | base64 + * -> JrrjK509uBLPOfB8BUNcUE0pSF5Y/1GuUKRucQR1HU8= + * + * Matching this exactly proves we use the canonical OKX pre-sign + * format (timestamp + UPPER(method) + requestPath + body) and base64 + * HMAC-SHA256 — not a misordered or wrongly-encoded variant. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const ORIGINAL_FETCH = globalThis.fetch; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + vi.unstubAllEnvs(); +}); + +// --- HMAC signing --------------------------------------------------------- + +describe("signOkxRequest", () => { + it("matches an openssl-computed HMAC-SHA256 base64 vector", async () => { + const { signOkxRequest } = await import("../../src/services/okx/auth.js"); + const sig = signOkxRequest( + "test-secret-abc123", + "2026-05-02T00:00:00.000Z", + "GET", + "/api/v5/dex/aggregator/quote?chainId=1", + "" + ); + expect(sig).toBe("JrrjK509uBLPOfB8BUNcUE0pSF5Y/1GuUKRucQR1HU8="); + }); + + it("uppercases the method when building the pre-sign string", async () => { + const { signOkxRequest } = await import("../../src/services/okx/auth.js"); + const upper = signOkxRequest("s", "2026-05-02T00:00:00.000Z", "GET", "/p", ""); + const lower = signOkxRequest("s", "2026-05-02T00:00:00.000Z", "get", "/p", ""); + expect(upper).toBe(lower); + }); + + it("includes the body in the pre-sign for POST requests", async () => { + const { signOkxRequest } = await import("../../src/services/okx/auth.js"); + const empty = signOkxRequest("s", "t", "POST", "/p", ""); + const withBody = signOkxRequest("s", "t", "POST", "/p", "{\"x\":1}"); + expect(empty).not.toBe(withBody); + }); +}); + +// --- timestamp ------------------------------------------------------------ + +describe("okxTimestamp", () => { + it("returns ISO 8601 UTC with millisecond precision", async () => { + const { okxTimestamp } = await import("../../src/services/okx/auth.js"); + const fixed = new Date("2026-05-02T12:34:56.789Z"); + expect(okxTimestamp(fixed)).toBe("2026-05-02T12:34:56.789Z"); + }); +}); + +// --- buildOkxAuthHeaders -------------------------------------------------- + +describe("buildOkxAuthHeaders", () => { + it("returns all five OK-ACCESS-* headers", async () => { + const { buildOkxAuthHeaders } = await import("../../src/services/okx/auth.js"); + const headers = buildOkxAuthHeaders( + { apiKey: "k", secret: "s", passphrase: "p", projectId: "proj" }, + "2026-05-02T00:00:00.000Z", + "GET", + "/api/v5/dex/aggregator/supported/chain", + "" + ); + expect(headers["OK-ACCESS-KEY"]).toBe("k"); + expect(headers["OK-ACCESS-PASSPHRASE"]).toBe("p"); + expect(headers["OK-ACCESS-PROJECT"]).toBe("proj"); + expect(headers["OK-ACCESS-TIMESTAMP"]).toBe("2026-05-02T00:00:00.000Z"); + expect(headers["OK-ACCESS-SIGN"]).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); +}); + +// --- getOkxCredentials (lazy loader) -------------------------------------- + +describe("getOkxCredentials", () => { + it("throws OkxCredentialsMissingError listing every unset key", async () => { + vi.doMock("../../src/services/credentials.js", () => ({ + default: { + unlock: vi.fn().mockResolvedValue(undefined), + isUnlocked: vi.fn().mockReturnValue(true), + get: vi.fn().mockReturnValue(null), + }, + })); + + const { getOkxCredentials } = await import("../../src/services/okx/auth.js"); + await expect(getOkxCredentials()).rejects.toMatchObject({ + name: "OkxCredentialsMissingError", + missing: ["api_key", "secret", "passphrase", "project_id"], + }); + }); + + it("throws listing only the missing keys when some are set", async () => { + vi.doMock("../../src/services/credentials.js", () => ({ + default: { + unlock: vi.fn().mockResolvedValue(undefined), + isUnlocked: vi.fn().mockReturnValue(true), + get: vi.fn((service: string, key: string) => { + if (service === "okx" && key === "api_key") return "K"; + if (service === "okx" && key === "secret") return "S"; + return null; + }), + }, + })); + + const { getOkxCredentials } = await import("../../src/services/okx/auth.js"); + await expect(getOkxCredentials()).rejects.toMatchObject({ + missing: ["passphrase", "project_id"], + }); + }); + + it("returns all four credentials when every key is set", async () => { + vi.doMock("../../src/services/credentials.js", () => ({ + default: { + unlock: vi.fn().mockResolvedValue(undefined), + isUnlocked: vi.fn().mockReturnValue(true), + get: vi.fn((_service: string, key: string) => { + if (key === "api_key") return "K"; + if (key === "secret") return "S"; + if (key === "passphrase") return "P"; + if (key === "project_id") return "PROJ"; + return null; + }), + }, + })); + + const { getOkxCredentials } = await import("../../src/services/okx/auth.js"); + const creds = await getOkxCredentials(); + expect(creds).toEqual({ + apiKey: "K", + secret: "S", + passphrase: "P", + projectId: "PROJ", + }); + }); + + it("calls unlock() once when the store starts locked", async () => { + const unlock = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../src/services/credentials.js", () => ({ + default: { + unlock, + isUnlocked: vi.fn().mockReturnValue(false), + get: vi.fn(() => "x"), + }, + })); + + const { getOkxCredentials } = await import("../../src/services/okx/auth.js"); + await getOkxCredentials(); + expect(unlock).toHaveBeenCalledTimes(1); + }); + + it("does not require project_id when requireProjectId=false", async () => { + vi.doMock("../../src/services/credentials.js", () => ({ + default: { + unlock: vi.fn().mockResolvedValue(undefined), + isUnlocked: vi.fn().mockReturnValue(true), + get: vi.fn((_service: string, key: string) => { + if (key === "api_key") return "K"; + if (key === "secret") return "S"; + if (key === "passphrase") return "P"; + return null; + }), + }, + })); + + const { getOkxCredentials } = await import("../../src/services/okx/auth.js"); + const creds = await getOkxCredentials(false); + expect(creds).toEqual({ + apiKey: "K", + secret: "S", + passphrase: "P", + projectId: undefined, + }); + }); + + it("still returns project_id when set, even with requireProjectId=false", async () => { + vi.doMock("../../src/services/credentials.js", () => ({ + default: { + unlock: vi.fn().mockResolvedValue(undefined), + isUnlocked: vi.fn().mockReturnValue(true), + get: vi.fn((_service: string, key: string) => { + if (key === "api_key") return "K"; + if (key === "secret") return "S"; + if (key === "passphrase") return "P"; + if (key === "project_id") return "PROJ"; + return null; + }), + }, + })); + + const { getOkxCredentials } = await import("../../src/services/okx/auth.js"); + const creds = await getOkxCredentials(false); + expect(creds.projectId).toBe("PROJ"); + }); +}); + +describe("buildOkxAuthHeaders projectId handling", () => { + it("includes OK-ACCESS-PROJECT when projectId is set", async () => { + const { buildOkxAuthHeaders } = await import("../../src/services/okx/auth.js"); + const headers = buildOkxAuthHeaders( + { apiKey: "k", secret: "s", passphrase: "p", projectId: "proj" }, + "2026-05-02T00:00:00.000Z", + "GET", + "/api/v5/dex/aggregator/supported/chain", + "" + ); + expect(headers["OK-ACCESS-PROJECT"]).toBe("proj"); + }); + + it("omits OK-ACCESS-PROJECT when projectId is undefined", async () => { + const { buildOkxAuthHeaders } = await import("../../src/services/okx/auth.js"); + const headers = buildOkxAuthHeaders( + { apiKey: "k", secret: "s", passphrase: "p" }, + "2026-05-02T00:00:00.000Z", + "GET", + "/api/v5/account/balance?ccy=BTC", + "" + ); + expect("OK-ACCESS-PROJECT" in headers).toBe(false); + // The other four headers must still be present + expect(headers["OK-ACCESS-KEY"]).toBe("k"); + expect(headers["OK-ACCESS-SIGN"]).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(headers["OK-ACCESS-PASSPHRASE"]).toBe("p"); + expect(headers["OK-ACCESS-TIMESTAMP"]).toBe("2026-05-02T00:00:00.000Z"); + }); +}); + +// --- okxAuthGet (signed client path) -------------------------------------- + +describe("okxAuthGet", () => { + it("attaches all OK-ACCESS-* headers to the request", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [{ ok: true }] })); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { okxAuthGet, getOkxWeb3BaseUrl } = await import( + "../../src/services/okx/client.js" + ); + await okxAuthGet( + "/api/v5/dex/aggregator/supported/chain", + undefined, + { apiKey: "K", secret: "S", passphrase: "P", projectId: "PROJ" }, + { baseUrl: getOkxWeb3BaseUrl() } + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://web3.okx.com/api/v5/dex/aggregator/supported/chain" + ); + const headers = opts.headers as Record; + expect(headers["OK-ACCESS-KEY"]).toBe("K"); + expect(headers["OK-ACCESS-PASSPHRASE"]).toBe("P"); + expect(headers["OK-ACCESS-PROJECT"]).toBe("PROJ"); + expect(headers["OK-ACCESS-TIMESTAMP"]).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + expect(headers["OK-ACCESS-SIGN"]).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + it("includes the query string in the signed requestPath", async () => { + let captured: { sign: string; ts: string; query: string } | null = null; + const mockFetch = vi.fn(async (url: string, opts: RequestInit) => { + const headers = opts.headers as Record; + const u = new URL(url); + captured = { sign: headers["OK-ACCESS-SIGN"], ts: headers["OK-ACCESS-TIMESTAMP"], query: u.search }; + return jsonResponse({ code: "0", msg: "", data: [] }); + }); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { okxAuthGet, getOkxWeb3BaseUrl } = await import( + "../../src/services/okx/client.js" + ); + const { signOkxRequest } = await import("../../src/services/okx/auth.js"); + + await okxAuthGet( + "/api/v5/dex/aggregator/quote", + { chainId: "1", amount: "1000" }, + { apiKey: "K", secret: "S", passphrase: "P", projectId: "PROJ" }, + { baseUrl: getOkxWeb3BaseUrl() } + ); + + expect(captured).not.toBeNull(); + const { sign, ts, query } = captured!; + const expected = signOkxRequest( + "S", + ts, + "GET", + `/api/v5/dex/aggregator/quote${query}`, + "" + ); + expect(sign).toBe(expected); + expect(query).toContain("chainId=1"); + expect(query).toContain("amount=1000"); + }); + + it("propagates OKX error codes from signed responses", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + jsonResponse( + { code: "50111", msg: "Invalid OK-ACCESS-KEY", data: [] }, + 401 + ) + ) as unknown as typeof fetch; + + const { okxAuthGet, getOkxWeb3BaseUrl } = await import( + "../../src/services/okx/client.js" + ); + await expect( + okxAuthGet( + "/api/v5/dex/aggregator/supported/chain", + undefined, + { apiKey: "x", secret: "x", passphrase: "x", projectId: "x" }, + { baseUrl: getOkxWeb3BaseUrl() } + ) + ).rejects.toMatchObject({ + name: "OkxApiError", + code: "50111", + status: 401, + }); + }); +}); diff --git a/tests/services/okx-client.test.ts b/tests/services/okx-client.test.ts new file mode 100644 index 00000000..ae39e725 --- /dev/null +++ b/tests/services/okx-client.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for the OKX REST client. + * + * Verifies envelope unwrapping, error code propagation, base URL switching, + * and that public market endpoints are called without auth headers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const ORIGINAL_FETCH = globalThis.fetch; +const ORIGINAL_BASE_URL_ENV = process.env.OKX_BASE_URL; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +beforeEach(() => { + vi.resetModules(); + delete process.env.OKX_BASE_URL; +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + if (ORIGINAL_BASE_URL_ENV === undefined) delete process.env.OKX_BASE_URL; + else process.env.OKX_BASE_URL = ORIGINAL_BASE_URL_ENV; +}); + +describe("getOkxBaseUrl", () => { + it("defaults to https://www.okx.com when env var is unset", async () => { + const { getOkxBaseUrl } = await import("../../src/services/okx/client.js"); + expect(getOkxBaseUrl()).toBe("https://www.okx.com"); + }); + + it("respects OKX_BASE_URL env var (US-restricted users)", async () => { + process.env.OKX_BASE_URL = "https://us.okx.com"; + const { getOkxBaseUrl } = await import("../../src/services/okx/client.js"); + expect(getOkxBaseUrl()).toBe("https://us.okx.com"); + }); + + it("strips a trailing slash from the env var", async () => { + process.env.OKX_BASE_URL = "https://www.okx.com/"; + const { getOkxBaseUrl } = await import("../../src/services/okx/client.js"); + expect(getOkxBaseUrl()).toBe("https://www.okx.com"); + }); +}); + +describe("okxGet", () => { + it("unwraps the data array on success", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [{ instId: "BTC-USDT", last: "100" }] })); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + const data = await okxGet<{ instId: string; last: string }>( + "/api/v5/market/ticker", + { instId: "BTC-USDT" } + ); + + expect(data).toEqual([{ instId: "BTC-USDT", last: "100" }]); + expect(mockFetch).toHaveBeenCalledWith( + "https://www.okx.com/api/v5/market/ticker?instId=BTC-USDT", + expect.objectContaining({ method: "GET" }) + ); + }); + + it("does not send auth headers for public endpoints", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [] })); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + await okxGet("/api/v5/market/tickers", { instType: "SPOT" }); + + const options = mockFetch.mock.calls[0][1] as RequestInit; + const headers = options.headers as Record; + expect(headers["OK-ACCESS-KEY"]).toBeUndefined(); + expect(headers["OK-ACCESS-SIGN"]).toBeUndefined(); + expect(headers["OK-ACCESS-PASSPHRASE"]).toBeUndefined(); + }); + + it("throws OkxApiError when code is non-zero", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "51001", msg: "Instrument ID does not exist", data: [] })) as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + await expect(okxGet("/api/v5/market/ticker", { instId: "BAD" })).rejects.toMatchObject({ + name: "OkxApiError", + code: "51001", + message: "Instrument ID does not exist", + }); + }); + + it("throws OkxApiError on non-2xx HTTP status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "50103", msg: "OK-ACCESS-KEY can not be empty", data: [] }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + ) as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + await expect(okxGet("/api/v5/dex/aggregator/quote", { chainId: 1 })).rejects.toMatchObject({ + name: "OkxApiError", + code: "50103", + status: 401, + }); + }); + + it("throws OkxApiError when response is not JSON", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("500 error", { + status: 500, + headers: { "content-type": "text/html" }, + }) + ) as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + await expect(okxGet("/api/v5/market/ticker", { instId: "BTC-USDT" })).rejects.toMatchObject({ + name: "OkxApiError", + status: 500, + }); + }); + + it("omits undefined and empty params from the query string", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [] })); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + await okxGet("/api/v5/market/tickers", { + instType: "SPOT", + uly: undefined, + instFamily: "", + }); + + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toBe("https://www.okx.com/api/v5/market/tickers?instType=SPOT"); + }); + + it("uses the configured base URL", async () => { + process.env.OKX_BASE_URL = "https://us.okx.com"; + const mockFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [] })); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { okxGet } = await import("../../src/services/okx/client.js"); + await okxGet("/api/v5/market/ticker", { instId: "BTC-USDT" }); + + const url = mockFetch.mock.calls[0][0] as string; + expect(url.startsWith("https://us.okx.com/")).toBe(true); + }); +}); + +describe("market helpers", () => { + it("getTicker returns the first element from the data array", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [{ instId: "BTC-USDT", last: "100" }] })) as unknown as typeof fetch; + + const { getTicker } = await import("../../src/services/okx/market.js"); + const ticker = await getTicker("BTC-USDT"); + expect(ticker?.instId).toBe("BTC-USDT"); + expect(ticker?.last).toBe("100"); + }); + + it("getTicker returns undefined for empty data", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [] })) as unknown as typeof fetch; + + const { getTicker } = await import("../../src/services/okx/market.js"); + expect(await getTicker("UNKNOWN")).toBeUndefined(); + }); + + it("getCandles passes the bar parameter to the query string", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ code: "0", msg: "", data: [["1700000000000", "100", "110", "90", "105", "1000"]] })); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const { getCandles } = await import("../../src/services/okx/market.js"); + await getCandles("BTC-USDT", "4H", 50); + + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain("bar=4H"); + expect(url).toContain("limit=50"); + expect(url).toContain("instId=BTC-USDT"); + }); +});