From 14a618b25ae8d81fb7340833592c5f6a02b5871a Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:42:30 +0545 Subject: [PATCH 01/24] feat(okx): add REST envelope types OKX v5 wraps every response in `{ code, msg, data: T[] }`. Adds the shared envelope shape, an OkxApiError class for non-zero codes, and the narrow instType/bar unions used by downstream helpers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/types.ts | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/services/okx/types.ts diff --git a/src/services/okx/types.ts b/src/services/okx/types.ts new file mode 100644 index 00000000..d1507cf5 --- /dev/null +++ b/src/services/okx/types.ts @@ -0,0 +1,46 @@ +/** + * 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. + */ +export type OkxCandleBar = + | "1m" | "3m" | "5m" | "15m" | "30m" + | "1H" | "2H" | "4H" | "6H" | "12H" + | "1D" | "2D" | "3D" | "1W" | "1M" | "3M" + | "6Hutc" | "12Hutc" | "1Dutc" | "1Wutc" | "1Mutc" | "3Mutc"; From af5149ef5748b801ce055998e6af45b6063020a3 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:42:37 +0545 Subject: [PATCH 02/24] feat(okx): add REST client with envelope unwrap and US base URL switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit okxGet wraps fetch, unwraps the OKX envelope, and throws OkxApiError on non-zero codes or non-2xx HTTP. Base URL is configurable via OKX_BASE_URL so US-restricted users can route through us.okx.com, defaulting to www.okx.com. Phase 1 only uses public market endpoints with no auth — HMAC signing for DEX/Wallet/CEX private endpoints lands in Phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/client.ts | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/services/okx/client.ts diff --git a/src/services/okx/client.ts b/src/services/okx/client.ts new file mode 100644 index 00000000..bd9888da --- /dev/null +++ b/src/services/okx/client.ts @@ -0,0 +1,70 @@ +/** + * 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"; + +const DEFAULT_BASE_URL = "https://www.okx.com"; + +export function getOkxBaseUrl(): string { + const env = process.env.OKX_BASE_URL?.trim(); + return env && env.length > 0 ? env.replace(/\/$/, "") : DEFAULT_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 async function okxGet( + path: string, + params?: Record +): Promise { + const url = `${getOkxBaseUrl()}${path}${buildQuery(params)}`; + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + }); + + 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; +} From a33b81be6199355d2e281d3689945549ad046a05 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:42:46 +0545 Subject: [PATCH 03/24] feat(okx): add market data helpers for ticker, tickers, books, candles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the four public /api/v5/market/* endpoints. Verified live against www.okx.com on 2026-05-02: - /tickers does NOT accept MARGIN — narrowed type to OkxTickersInstType - ticker response includes sodUtc0 and sodUtc8 (start-of-day prices) - /books sz parameter accepts 1-400 (not the legacy 50/200 cap) - order book levels are [price, size, liquidatedOrders, numOrders] Rate limit per OKX v5 docs: 20 requests / 2 seconds per IP. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/market.ts | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/services/okx/market.ts diff --git a/src/services/okx/market.ts b/src/services/okx/market.ts new file mode 100644 index 00000000..e2290c2d --- /dev/null +++ b/src/services/okx/market.ts @@ -0,0 +1,76 @@ +/** + * 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; +} + +export type OkxCandle = 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, + }); +} From 1edf257044995bdba1acbed512d344cda504451b Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:42:49 +0545 Subject: [PATCH 04/24] feat(okx): add service barrel exports Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/services/okx/index.ts diff --git a/src/services/okx/index.ts b/src/services/okx/index.ts new file mode 100644 index 00000000..e57b1b61 --- /dev/null +++ b/src/services/okx/index.ts @@ -0,0 +1,9 @@ +export * from "./types.js"; +export { okxGet, getOkxBaseUrl } from "./client.js"; +export { + getTicker, + getTickers, + getOrderBook, + getCandles, +} from "./market.js"; +export type { OkxTicker, OkxOrderBook, OkxCandle } from "./market.js"; From de3a4696e960a3badab0bb3cfe8c3ef57cd21bc8 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:42:56 +0545 Subject: [PATCH 05/24] feat(okx): add okx_market_* MCP tools for ticker, tickers, books, candles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four public market data tools — no API key required. Each tool maps 1:1 onto a verified OKX endpoint, with bar/instType enums narrowed to the values the live API actually accepts: - okx_market_ticker — single instrument snapshot - okx_market_tickers — all SPOT/SWAP/FUTURES/OPTION instruments - okx_market_orderbook — depth up to 400 per side - okx_market_candles — 22 verified bar values incl. UTC variants Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/market.tools.ts | 158 ++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/tools/okx/market.tools.ts diff --git a/src/tools/okx/market.tools.ts b/src/tools/okx/market.tools.ts new file mode 100644 index 00000000..dc9e3d95 --- /dev/null +++ b/src/tools/okx/market.tools.ts @@ -0,0 +1,158 @@ +/** + * 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, +} 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; + +// Verified live (2026-05-02): 8H, 6M, 1Y are NOT accepted on /market/candles. +// Lowercase m = minutes, uppercase M = months. UTC variants only for ≥6H bars. +const 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 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/MARGIN/SWAP/FUTURES/OPTION). " + + "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(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)"), + 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); + } + } + ); +} From f8ab514ac6abad44015ddf3edc0451c71cc58e42 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:43:01 +0545 Subject: [PATCH 06/24] feat(okx): aggregate OKX tool groups behind registerOkxTools Single entry point so future Phase 2 tools (DEX aggregator, Wallet API) can be added alongside market data without touching the top-level registry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/tools/okx/index.ts diff --git a/src/tools/okx/index.ts b/src/tools/okx/index.ts new file mode 100644 index 00000000..cf97990e --- /dev/null +++ b/src/tools/okx/index.ts @@ -0,0 +1,14 @@ +/** + * OKX MCP tool registration. + * + * Phase 1: public market data (no API key). + * Phase 2 will add DEX Aggregator + Wallet API tools that prompt for + * credentials lazily when invoked. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerOkxMarketTools } from "./market.tools.js"; + +export function registerOkxTools(server: McpServer): void { + registerOkxMarketTools(server); +} From 10b40978491b33bc9665078fda503fc6fb11b74b Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:43:04 +0545 Subject: [PATCH 07/24] feat(okx): wire OKX tools into the MCP server registry Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/index.ts | 4 ++++ 1 file changed, 4 insertions(+) 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(); } From 958c3b6ebf9403dd16c7e9371a1be88159fed26f Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:43:12 +0545 Subject: [PATCH 08/24] test(okx): cover client envelope, base URL switch, and market helpers 13 vitest cases covering: - OKX_BASE_URL default, override, trailing-slash strip - envelope unwrap on success - no auth headers attached to public requests - OkxApiError on non-zero code, on non-2xx HTTP, on non-JSON body - query string omits undefined/empty params - getTicker returns first element / undefined - getCandles passes bar/limit through Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/services/okx-client.test.ts | 198 ++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 tests/services/okx-client.test.ts 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"); + }); +}); From 887883db7f99976c166376287a308ab7ff0109f6 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:58:45 +0545 Subject: [PATCH 09/24] feat(okx): add HMAC signing and lazy credential loader Implements the OKX v5 signing algorithm exactly as specified in the Authentication > Signature section of the docs: pre-sign = timestamp + UPPER(METHOD) + requestPath + body signature = base64(HMAC-SHA256(pre-sign, secret)) Where requestPath is the absolute path including the query string for GET requests, body is "" for GET, and timestamp is ISO 8601 UTC with millisecond precision (matching the OK-ACCESS-TIMESTAMP header). Credentials live in the existing aibtc encrypted credential store under service="okx" with keys api_key, secret, passphrase, project_id. project_id is required because all Phase 2 endpoints are WaaS (web3.okx.com) and need OK-ACCESS-PROJECT. getOkxCredentials throws OkxCredentialsMissingError listing every unset key, with instructions on which credentials_set calls to make. This lets each Phase 2 tool fail-fast with a clear, actionable error the first time a user invokes a private endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/auth.ts | 120 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/services/okx/auth.ts diff --git a/src/services/okx/auth.ts b/src/services/okx/auth.ts new file mode 100644 index 00000000..5e618de1 --- /dev/null +++ b/src/services/okx/auth.ts @@ -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 { + 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 { + 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, + }; +} From 327fc862dbb6f8ae54515dbedae3add02c08161c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 20:59:18 +0545 Subject: [PATCH 10/24] feat(okx): add okxAuthGet for signed private requests + WaaS base URL Extends the REST client with okxAuthGet, which wraps a GET request in the four OK-ACCESS-* headers (KEY, SIGN, PASSPHRASE, TIMESTAMP) plus OK-ACCESS-PROJECT for WaaS endpoints. The signature is built over the exact pre-sign string OKX expects: timestamp + method + path-with-query + body, where body is "" for GET. A new getOkxWeb3BaseUrl() returns the WaaS host (web3.okx.com) used for DEX aggregator and Wallet API endpoints, configurable via OKX_WEB3_BASE_URL. Service helpers in Phase 2 will pass this base URL explicitly via OkxRequestOptions.baseUrl, leaving the public market data path on www.okx.com unchanged. Response parsing is factored into a shared parseOkxResponse helper so public and signed requests share identical envelope/error handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/client.ts | 68 +++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/src/services/okx/client.ts b/src/services/okx/client.ts index bd9888da..f5599ad3 100644 --- a/src/services/okx/client.ts +++ b/src/services/okx/client.ts @@ -14,14 +14,25 @@ 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( @@ -33,16 +44,15 @@ function buildQuery(params?: Record): strin return `?${search.toString()}`; } -export async function okxGet( - path: string, - params?: Record -): Promise { - const url = `${getOkxBaseUrl()}${path}${buildQuery(params)}`; - const response = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - }); +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; @@ -68,3 +78,43 @@ export async function okxGet( 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); +} From a9b840381d0d2453f37ac5fd5a017b3f8a245597 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:00:15 +0545 Subject: [PATCH 11/24] feat(okx): add DEX aggregator service for single-chain swap routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps five web3.okx.com/api/v5/dex/aggregator/* endpoints behind typed service helpers, each calling getOkxCredentials() so a missing key fails fast with OkxCredentialsMissingError before any network call: - getDexSupportedChains list chains the aggregator routes on - getDexAllTokens list tradeable tokens on a chainId - getDexQuote estimated output (no tx returned) - getDexSwapTx pre-built swap transaction - getDexApproveTx ERC-20 approval calldata Critical contract: getDexSwapTx does NOT broadcast. The response's data[0].tx object contains { data, from, to, value, gas, gasPrice, minReceiveAmount } that the caller must sign and broadcast through their own wallet. The Phase 2 MCP tool will surface this clearly so the model never claims a swap "succeeded" after only fetching tx data. Cross-chain bridge endpoints (/api/v5/dex/cross-chain/*) intentionally deferred — paths exist (verified live, return 50111 not 404) but exact param shapes for /quote, /build-tx, /status weren't verifiable without a live key, so shipping them risks shipping wrong assumptions. Param shapes are typed against the well-documented OKX dev portal + github.com/okx/dex-api-library samples; response payloads are typed loosely (unknown) since they were not curl-verifiable. Tools render the JSON verbatim via createJsonResponse so users see real fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/dex.ts | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/services/okx/dex.ts diff --git a/src/services/okx/dex.ts b/src/services/okx/dex.ts new file mode 100644 index 00000000..45655897 --- /dev/null +++ b/src/services/okx/dex.ts @@ -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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/supported/chain", + undefined, + creds, + WAAS + ); +} + +export async function getDexAllTokens(chainId: string): Promise { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/quote", + params as unknown as Record, + 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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/swap", + params as unknown as Record, + 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 { + const creds = await getOkxCredentials(); + return okxAuthGet( + "/api/v5/dex/aggregator/approve-transaction", + params as unknown as Record, + creds, + WAAS + ); +} From d890e1dcf3884ef4ab2661930512f8aa18385182 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:00:53 +0545 Subject: [PATCH 12/24] feat(okx): add Wallet API service for chains, balances, UTXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps three verified web3.okx.com/api/v5/wallet/* endpoints: - getWalletSupportedChains /wallet/chain/supported-chains - getWalletTokenBalances /wallet/asset/all-token-balances-by-address - getWalletUtxos /wallet/utxo/utxos (BTC-family chains) These three were the only Wallet API paths whose existence I could verify live (return 50111 with dummy auth, not 404). Other paths exist in the OKX docs (UTXO detail, transaction history, order history) but their parameter shapes weren't curl-verifiable, so they're deferred. Deliberately excluded: BRC-20, Runes, and Inscriptions endpoints. These are NOT served by WaaS Wallet API — they live on OKLink Explorer (separate API at oklink.com, separate auth/keys). Probing live confirmed all guesses under /api/v5/wallet/explorer/btc/* return 404. A future phase can add OKLink integration if BTC-ecosystem indexer queries are desired; this phase keeps the credential surface to a single OKX key. chainIndex values are intentionally not hardcoded — service consumers must call getWalletSupportedChains() first to discover the authoritative identifiers (these can vary by account/region). The MCP tool layer will expose this discovery as a separate okx_wallet_supported_chains tool so the model can chain calls correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/wallet.ts | 103 +++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/services/okx/wallet.ts diff --git a/src/services/okx/wallet.ts b/src/services/okx/wallet.ts new file mode 100644 index 00000000..71e50655 --- /dev/null +++ b/src/services/okx/wallet.ts @@ -0,0 +1,103 @@ +/** + * 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"; + +const WAAS = { baseUrl: getOkxWeb3BaseUrl() }; + +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, + WAAS + ); +} + +/** + * 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, + WAAS + ); +} + +/** + * 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, + WAAS + ); +} From 8f610321c1f0979477f9fd985c4123e91d7da0c4 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:01:11 +0545 Subject: [PATCH 13/24] feat(okx): expose Phase 2 auth/dex/wallet helpers from service barrel Adds re-exports for the new auth, dex, and wallet modules so consumers import from a single ../services/okx/index.js entry. Also surfaces OkxRequestOptions and OkxCredentials so MCP tools can declare typed helpers without reaching into implementation files. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/index.ts | 43 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/services/okx/index.ts b/src/services/okx/index.ts index e57b1b61..db5e0881 100644 --- a/src/services/okx/index.ts +++ b/src/services/okx/index.ts @@ -1,5 +1,11 @@ export * from "./types.js"; -export { okxGet, getOkxBaseUrl } from "./client.js"; +export { + okxGet, + okxAuthGet, + getOkxBaseUrl, + getOkxWeb3BaseUrl, +} from "./client.js"; +export type { OkxRequestOptions } from "./client.js"; export { getTicker, getTickers, @@ -7,3 +13,38 @@ export { 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, +} from "./dex.js"; + +export { + getWalletSupportedChains, + getWalletTokenBalances, + getWalletUtxos, +} from "./wallet.js"; +export type { + OkxWalletChain, + OkxWalletTokenBalance, + OkxWalletUtxo, +} from "./wallet.js"; From d06409bf40f51fda2d71a2337d6b105cff750231 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:02:04 +0545 Subject: [PATCH 14/24] feat(okx): add okx_dex_* MCP tools for aggregator routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tools mapping 1:1 to the DEX aggregator service helpers: - okx_dex_supported_chains discover routable chains - okx_dex_tokens list tokens for a chainId - okx_dex_quote read-only output estimate - okx_dex_swap_tx pre-built swap transaction (no broadcast) - okx_dex_approve_tx ERC-20 approval calldata Each tool's description ends with a single CRED_NOTE constant pointing the user at the OKX developer portal and the exact credentials_set calls needed (service='okx' for keys api_key/secret/passphrase/ project_id). The first invocation throws OkxCredentialsMissingError listing the unset keys — there is no upfront setup required to install the MCP server, only to invoke a private tool. The okx_dex_swap_tx description deliberately spells out twice that the tool returns transaction data the caller must sign and broadcast themselves. The model has historically conflated "got a tx object" with "swap succeeded"; framing the tool name as _swap_tx (not _swap) plus the explicit description guards against that. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/dex.tools.ts | 174 +++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/tools/okx/dex.tools.ts 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); + } + } + ); +} From db9eb3aa07d482f1d5c2c04f3153c3883bc59154 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:02:35 +0545 Subject: [PATCH 15/24] feat(okx): add okx_wallet_* MCP tools for chains, balances, UTXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three read-only address-level tools mapping to the verified Wallet API endpoints: - okx_wallet_supported_chains discover valid chainIndex values - okx_wallet_token_balances fungible token balances per address - okx_wallet_utxos UTXOs for BTC-family chains The supported_chains tool exists primarily so the model can chain calls correctly — chainIndex values can vary by account/region and shouldn't be hardcoded by callers. Tool descriptions point at it as the discovery step before calling balances or UTXOs. BRC-20, Runes, and Inscriptions are intentionally absent — those data sources require OKLink Explorer (separate API + key) and are deferred to a future phase. The Wallet API path probes confirmed those endpoints return 404 under /api/v5/wallet/, so shipping them here would be wrong. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/wallet.tools.ts | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/tools/okx/wallet.tools.ts 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); + } + } + ); +} From f2f761b7b947fc0d1499ebb4554e8c9488849fb9 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:02:52 +0545 Subject: [PATCH 16/24] feat(okx): wire Phase 2 DEX + Wallet tools into the OKX aggregator registerOkxTools now registers all three tool groups so the top-level src/tools/index.ts wiring stays unchanged: a single registerOkxTools call still picks up market + dex + wallet tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tools/okx/index.ts b/src/tools/okx/index.ts index cf97990e..064f2c8d 100644 --- a/src/tools/okx/index.ts +++ b/src/tools/okx/index.ts @@ -2,13 +2,19 @@ * OKX MCP tool registration. * * Phase 1: public market data (no API key). - * Phase 2 will add DEX Aggregator + Wallet API tools that prompt for - * credentials lazily when invoked. + * 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); } From 3dc9bf42eb31be546dd8ceb1d78e9a8beef42028 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Sat, 2 May 2026 21:04:19 +0545 Subject: [PATCH 17/24] test(okx): cover HMAC signing, credential loader, and signed client 12 vitest cases covering Phase 2 auth + private-client paths: - signOkxRequest matches an openssl-computed HMAC-SHA256 base64 vector (timestamp + UPPER(method) + path + body, base64-encoded). The vector was generated 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 exactly proves the pre-sign format and HMAC encoding are canonical, not a misordered or wrongly-encoded variant. - Method case-insensitivity: GET and get produce the same signature - Body inclusion: empty vs JSON body produce different signatures - Timestamp format: ISO 8601 UTC with millisecond precision - buildOkxAuthHeaders returns all five OK-ACCESS-* headers with valid base64 signature - getOkxCredentials throws OkxCredentialsMissingError listing every unset key (full set, partial set), unlocks the store on demand, and returns the typed credential object when complete - okxAuthGet attaches all OK-ACCESS-* headers to outgoing requests, signs the requestPath with query string included, and propagates OkxApiError for non-zero codes (e.g. 50111 Invalid OK-ACCESS-KEY) The credential-loader tests use vi.doMock to stub the credentials module per-test rather than touching the real ~/.aibtc/credentials.enc store. The query-string signing test recomputes the expected signature in-test via signOkxRequest with the captured timestamp, proving the client's pre-sign string matches what auth.ts produces directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/services/okx-auth.test.ts | 270 ++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 tests/services/okx-auth.test.ts diff --git a/tests/services/okx-auth.test.ts b/tests/services/okx-auth.test.ts new file mode 100644 index 00000000..6026bef7 --- /dev/null +++ b/tests/services/okx-auth.test.ts @@ -0,0 +1,270 @@ +/** + * 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); + }); +}); + +// --- 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, + }); + }); +}); From b236261d0d713b28f32078a868f4b06f4f0db9f8 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:47:12 +0545 Subject: [PATCH 18/24] fix(okx): align tickers description with the actual instType enum The okx_market_tickers description listed SPOT/MARGIN/SWAP/FUTURES/OPTION but the Zod enum (TICKERS_INSTRUMENT_TYPES) excludes MARGIN because the endpoint rejects it with 51000 (verified live). A user reading the description would try MARGIN and hit a Zod validation error before the request even leaves the client, with no hint why. Now the description matches the enum and explains WHY MARGIN is absent. Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/market.tools.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/okx/market.tools.ts b/src/tools/okx/market.tools.ts index dc9e3d95..77e01a52 100644 --- a/src/tools/okx/market.tools.ts +++ b/src/tools/okx/market.tools.ts @@ -59,7 +59,8 @@ export function registerOkxMarketTools(server: McpServer): void { "okx_market_tickers", { description: - "List tickers for all instruments of a given type (SPOT/MARGIN/SWAP/FUTURES/OPTION). " + + "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 From b586838d6b3e3146c660c1f954a7c3d1d2987f01 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:47:29 +0545 Subject: [PATCH 19/24] refactor(okx): make OkxCandle a 9-element tuple for positional safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OKX returns each candle row as a fixed array [ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm] (verified live 2026-05-02). The previous string[] type allowed any length, so downstream callers indexing into row[5] for "vol" had no compile-time safety against off-by-one bugs. The named-tuple syntax also acts as documentation in IDE tooltips — hovering over a candle element shows the field name, not just "string". Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/market.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/services/okx/market.ts b/src/services/okx/market.ts index e2290c2d..709839bb 100644 --- a/src/services/okx/market.ts +++ b/src/services/okx/market.ts @@ -35,7 +35,22 @@ export interface OkxOrderBook { ts: string; } -export type OkxCandle = 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 }); From eb3b846fd63e98554b2fd4e5b3eb9eb78e2b6148 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:48:20 +0545 Subject: [PATCH 20/24] refactor(okx): single source of truth for candle bar enum CANDLE_BARS in market.tools.ts and OkxCandleBar in types.ts had the same 22 values in two places. Adding a bar (e.g. when OKX adds 8H support) required two edits, with no compile-time link guaranteeing they stayed aligned. Now there is one OKX_CANDLE_BARS const in types.ts. The OkxCandleBar type derives from it via (typeof OKX_CANDLE_BARS)[number], and the Zod enum in the tools file imports the same const. Adding a bar is a single-line change with the type and schema staying in lockstep. The * re-export of types.ts already covered OKX_CANDLE_BARS, but I added an explicit named re-export in the barrel as well so it's discoverable at the public API surface. Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/index.ts | 1 + src/services/okx/types.ts | 16 +++++++++++----- src/tools/okx/market.tools.ts | 13 ++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/services/okx/index.ts b/src/services/okx/index.ts index db5e0881..39e7ab59 100644 --- a/src/services/okx/index.ts +++ b/src/services/okx/index.ts @@ -1,4 +1,5 @@ export * from "./types.js"; +export { OKX_CANDLE_BARS } from "./types.js"; export { okxGet, okxAuthGet, diff --git a/src/services/okx/types.ts b/src/services/okx/types.ts index d1507cf5..5a1482a2 100644 --- a/src/services/okx/types.ts +++ b/src/services/okx/types.ts @@ -38,9 +38,15 @@ export type OkxTickersInstType = "SPOT" | "SWAP" | "FUTURES" | "OPTION"; * 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 type OkxCandleBar = - | "1m" | "3m" | "5m" | "15m" | "30m" - | "1H" | "2H" | "4H" | "6H" | "12H" - | "1D" | "2D" | "3D" | "1W" | "1M" | "3M" - | "6Hutc" | "12Hutc" | "1Dutc" | "1Wutc" | "1Mutc" | "3Mutc"; +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/tools/okx/market.tools.ts b/src/tools/okx/market.tools.ts index 77e01a52..90a2192b 100644 --- a/src/tools/okx/market.tools.ts +++ b/src/tools/okx/market.tools.ts @@ -14,19 +14,14 @@ import { 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; -// Verified live (2026-05-02): 8H, 6M, 1Y are NOT accepted on /market/candles. -// Lowercase m = minutes, uppercase M = months. UTC variants only for ≥6H bars. -const 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; +// 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( @@ -125,7 +120,7 @@ export function registerOkxMarketTools(server: McpServer): void { inputSchema: { instId: z.string().describe("Instrument id, e.g. 'BTC-USDT'"), bar: z - .enum(CANDLE_BARS) + .enum(OKX_CANDLE_BARS) .optional() .default("1H") .describe("Candle interval (default '1H')"), From 338a10fe75ebed2db5b4e6a47c427ffe424ac3cd Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:48:51 +0545 Subject: [PATCH 21/24] fix(okx): read OKX_WEB3_BASE_URL at call time, not at module load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WAAS = { baseUrl: getOkxWeb3BaseUrl() } module-level const captured the env var once at first import, so any test or runtime path that mutated OKX_WEB3_BASE_URL after the module was imported would silently get the original value. This matches a real bug pattern: tests using vi.stubEnv after the dex.ts/wallet.ts module is already in the cache would expect the new base URL but hit the cached one. Now each service helper inlines `{ baseUrl: getOkxWeb3BaseUrl() }` at the call site, so the env is read once per request — same pattern client.ts already used for the public market endpoints. The override behavior is now consistent across the entire OKX service surface. Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/dex.ts | 12 +++++------- src/services/okx/wallet.ts | 8 +++----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/services/okx/dex.ts b/src/services/okx/dex.ts index 45655897..c7310136 100644 --- a/src/services/okx/dex.ts +++ b/src/services/okx/dex.ts @@ -17,8 +17,6 @@ import { okxAuthGet, getOkxWeb3BaseUrl } from "./client.js"; import { getOkxCredentials } from "./auth.js"; -const WAAS = { baseUrl: getOkxWeb3BaseUrl() }; - export interface OkxDexChain { chainId: string; chainName: string; @@ -66,7 +64,7 @@ export async function getDexSupportedChains(): Promise { "/api/v5/dex/aggregator/supported/chain", undefined, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } @@ -76,7 +74,7 @@ export async function getDexAllTokens(chainId: string): Promise { "/api/v5/dex/aggregator/all-tokens", { chainId }, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } @@ -90,7 +88,7 @@ export async function getDexQuote(params: OkxDexQuoteParams): Promise "/api/v5/dex/aggregator/quote", params as unknown as Record, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } @@ -105,7 +103,7 @@ export async function getDexSwapTx(params: OkxDexSwapParams): Promise "/api/v5/dex/aggregator/swap", params as unknown as Record, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } @@ -119,6 +117,6 @@ export async function getDexApproveTx(params: OkxDexApproveParams): Promise, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } diff --git a/src/services/okx/wallet.ts b/src/services/okx/wallet.ts index 71e50655..0e8a23a3 100644 --- a/src/services/okx/wallet.ts +++ b/src/services/okx/wallet.ts @@ -18,8 +18,6 @@ import { okxAuthGet, getOkxWeb3BaseUrl } from "./client.js"; import { getOkxCredentials } from "./auth.js"; -const WAAS = { baseUrl: getOkxWeb3BaseUrl() }; - export interface OkxWalletChain { name: string; logoUrl?: string; @@ -58,7 +56,7 @@ export async function getWalletSupportedChains(): Promise { "/api/v5/wallet/chain/supported-chains", undefined, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } @@ -78,7 +76,7 @@ export async function getWalletTokenBalances( "/api/v5/wallet/asset/all-token-balances-by-address", { address, chains }, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } @@ -98,6 +96,6 @@ export async function getWalletUtxos( "/api/v5/wallet/utxo/utxos", { chainIndex, address }, creds, - WAAS + { baseUrl: getOkxWeb3BaseUrl() } ); } From f9646b6934106ed8e0a3ce09f3c977ad1e5c2444 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:49:56 +0545 Subject: [PATCH 22/24] feat(okx): type DEX response shapes + drop double-casts on params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related cleanups in one commit because they couldn't be made independently — the cast removal depended on relaxing the param interfaces to conform to okxAuthGet's query-params type. Typed responses (P2.2): - OkxDexQuoteResponse chainId, dexRouterList, fromToken, toToken, fromTokenAmount, toTokenAmount, estimateGasFee - OkxDexSwapResponse extends Quote with the `tx` object the caller must sign and broadcast - OkxDexApproveResponse ERC-20 approval calldata - OkxDexTokenInfo shared sub-shape for from/toToken Each interface includes `[k: string]: unknown` so undocumented OKX fields pass through verbatim without breaking compilation. Shapes are sourced from the WAAS DEX docs at web3.okx.com/build/docs/waas/dex-swap; they were not curl-verified end-to-end (would require a live key). Cast cleanup (P2.3): - OkxDexQuoteParams / OkxDexApproveParams gain `[k: string]: string | undefined` - OkxDexSwapParams inherits from OkxDexQuoteParams so it picks up the same index signature - The three `params as unknown as Record` casts in getDexQuote / getDexSwapTx / getDexApproveTx are removed — params now flow into okxAuthGet directly The double-cast was also semantically wrong: it claimed all values were `string`, but slippage / referrerAddress are `string | undefined`. The new index signature reflects the actual shape, so callers (and the type checker) see the truth. Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/dex.ts | 81 ++++++++++++++++++++++++++++++++++----- src/services/okx/index.ts | 4 ++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/services/okx/dex.ts b/src/services/okx/dex.ts index c7310136..118c75dc 100644 --- a/src/services/okx/dex.ts +++ b/src/services/okx/dex.ts @@ -33,6 +33,11 @@ export interface OkxDexToken { [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; @@ -41,6 +46,7 @@ export interface OkxDexQuoteParams { amount: string; /** Optional decimal slippage, e.g. "0.05" for 5% */ slippage?: string; + [k: string]: string | undefined; } export interface OkxDexSwapParams extends OkxDexQuoteParams { @@ -56,6 +62,57 @@ export interface OkxDexApproveParams { 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 { @@ -82,11 +139,13 @@ export async function getDexAllTokens(chainId: string): Promise { * 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 { +export async function getDexQuote( + params: OkxDexQuoteParams +): Promise { const creds = await getOkxCredentials(); - return okxAuthGet( + return okxAuthGet( "/api/v5/dex/aggregator/quote", - params as unknown as Record, + params, creds, { baseUrl: getOkxWeb3BaseUrl() } ); @@ -97,11 +156,13 @@ export async function getDexQuote(params: OkxDexQuoteParams): Promise * contains `{ data, from, to, value, gas, gasPrice, minReceiveAmount }` * which the caller must sign + broadcast separately. */ -export async function getDexSwapTx(params: OkxDexSwapParams): Promise { +export async function getDexSwapTx( + params: OkxDexSwapParams +): Promise { const creds = await getOkxCredentials(); - return okxAuthGet( + return okxAuthGet( "/api/v5/dex/aggregator/swap", - params as unknown as Record, + params, creds, { baseUrl: getOkxWeb3BaseUrl() } ); @@ -111,11 +172,13 @@ export async function getDexSwapTx(params: OkxDexSwapParams): Promise * 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 { +export async function getDexApproveTx( + params: OkxDexApproveParams +): Promise { const creds = await getOkxCredentials(); - return okxAuthGet( + return okxAuthGet( "/api/v5/dex/aggregator/approve-transaction", - params as unknown as Record, + params, creds, { baseUrl: getOkxWeb3BaseUrl() } ); diff --git a/src/services/okx/index.ts b/src/services/okx/index.ts index 39e7ab59..15755782 100644 --- a/src/services/okx/index.ts +++ b/src/services/okx/index.ts @@ -37,6 +37,10 @@ export type { OkxDexQuoteParams, OkxDexSwapParams, OkxDexApproveParams, + OkxDexTokenInfo, + OkxDexQuoteResponse, + OkxDexSwapResponse, + OkxDexApproveResponse, } from "./dex.js"; export { From 3be4b9c8bdb5b41e558b5877cfb7396641bb3dd1 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:51:17 +0545 Subject: [PATCH 23/24] feat(okx): make project_id optional via requireProjectId flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OK-ACCESS-PROJECT is only required for WaaS endpoints (DEX + Wallet). Future CEX-private endpoints (account, trading) don't use that header and don't need a project id. The previous implementation forced every signed-OKX caller to configure project_id, even those that don't need it. Now: getOkxCredentials() // requireProjectId=true (default) getOkxCredentials(false) // CEX-private callers can omit project_id OkxCredentials.projectId is now optional. buildOkxAuthHeaders() conditionally includes OK-ACCESS-PROJECT only when projectId is set, so a CEX-private signed request will have exactly four OK-ACCESS-* headers instead of five — matching what the v5 CEX docs require. All current callers (dex.ts + wallet.ts) use the default requireProjectId=true and are unchanged. The new behavior unblocks adding CEX private tools (e.g. okx_account_balance, okx_trade_order) in a future phase without forcing public-market-only users to also provision a WaaS project id. Tests added: - getOkxCredentials(false) when project_id is unset → returns projectId: undefined, no error - getOkxCredentials(false) when project_id IS set → still returned - buildOkxAuthHeaders includes OK-ACCESS-PROJECT only when set - 4 OK-ACCESS-* headers present when projectId is omitted Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/okx/auth.ts | 36 ++++++++++++---- tests/services/okx-auth.test.ts | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/services/okx/auth.ts b/src/services/okx/auth.ts index 5e618de1..9e3337e0 100644 --- a/src/services/okx/auth.ts +++ b/src/services/okx/auth.ts @@ -28,7 +28,12 @@ export interface OkxCredentials { apiKey: string; secret: string; passphrase: string; - projectId: 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 { @@ -72,10 +77,18 @@ export function okxTimestamp(now: Date = new Date()): string { } /** - * Load all four OKX credentials, throwing OkxCredentialsMissingError listing - * every key that's not set. Unlocks the credential store first if needed. + * 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(): Promise { +export async function getOkxCredentials( + requireProjectId = true +): Promise { if (!credentials.isUnlocked()) { await credentials.unlock(); } @@ -88,7 +101,7 @@ export async function getOkxCredentials(): Promise { if (!apiKey) missing.push("api_key"); if (!secret) missing.push("secret"); if (!passphrase) missing.push("passphrase"); - if (!projectId) missing.push("project_id"); + if (requireProjectId && !projectId) missing.push("project_id"); if (missing.length > 0) throw new OkxCredentialsMissingError(missing); @@ -96,12 +109,14 @@ export async function getOkxCredentials(): Promise { apiKey: apiKey as string, secret: secret as string, passphrase: passphrase as string, - projectId: projectId as string, + projectId: projectId ?? undefined, }; } /** - * Build the four OKX auth headers for a signed request. + * 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, @@ -110,11 +125,14 @@ export function buildOkxAuthHeaders( requestPath: string, body = "" ): Record { - return { + 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, - "OK-ACCESS-PROJECT": creds.projectId, }; + if (creds.projectId) { + headers["OK-ACCESS-PROJECT"] = creds.projectId; + } + return headers; } diff --git a/tests/services/okx-auth.test.ts b/tests/services/okx-auth.test.ts index 6026bef7..1f4bf76d 100644 --- a/tests/services/okx-auth.test.ts +++ b/tests/services/okx-auth.test.ts @@ -171,6 +171,81 @@ describe("getOkxCredentials", () => { 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) -------------------------------------- From a5df897d8ca177d613243c21e75752791fa4f14a Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 4 May 2026 20:51:37 +0545 Subject: [PATCH 24/24] docs(okx): clarify candles limit=300 against the live server cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer asked whether the 300 max in our Zod schema matched OKX docs (some doc pages list 100 as the cap). Re-verified live (2026-05-02): limit=100 -> 100 rows code=0 limit=200 -> 200 rows code=0 limit=300 -> 300 rows code=0 limit=400 -> 300 rows code=0 <- silent server-side cap limit=500 -> 300 rows code=0 <- silent server-side cap So 1-300 is exact (no truncation), and our Zod max(300) prevents users from hitting the silent cap because we reject 301+ at the schema layer before the request leaves the client. Description now spells this out so future reviewers don't have to re-ask the same question — the older "100 bars" cap some docs pages still cite is outdated for /market/candles on the v5 API as of the verification date. Reported in PR review by @arc0btc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/okx/market.tools.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tools/okx/market.tools.ts b/src/tools/okx/market.tools.ts index 90a2192b..97c4ce7d 100644 --- a/src/tools/okx/market.tools.ts +++ b/src/tools/okx/market.tools.ts @@ -131,7 +131,13 @@ export function registerOkxMarketTools(server: McpServer): void { .max(300) .optional() .default(100) - .describe("Number of candles (1-300, 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()