Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
14a618b
feat(okx): add REST envelope types
biwasxyz May 2, 2026
af5149e
feat(okx): add REST client with envelope unwrap and US base URL switch
biwasxyz May 2, 2026
a33b81b
feat(okx): add market data helpers for ticker, tickers, books, candles
biwasxyz May 2, 2026
1edf257
feat(okx): add service barrel exports
biwasxyz May 2, 2026
de3a469
feat(okx): add okx_market_* MCP tools for ticker, tickers, books, can…
biwasxyz May 2, 2026
f8ab514
feat(okx): aggregate OKX tool groups behind registerOkxTools
biwasxyz May 2, 2026
10b4097
feat(okx): wire OKX tools into the MCP server registry
biwasxyz May 2, 2026
958c3b6
test(okx): cover client envelope, base URL switch, and market helpers
biwasxyz May 2, 2026
887883d
feat(okx): add HMAC signing and lazy credential loader
biwasxyz May 2, 2026
327fc86
feat(okx): add okxAuthGet for signed private requests + WaaS base URL
biwasxyz May 2, 2026
a9b8403
feat(okx): add DEX aggregator service for single-chain swap routing
biwasxyz May 2, 2026
d890e1d
feat(okx): add Wallet API service for chains, balances, UTXOs
biwasxyz May 2, 2026
8f61032
feat(okx): expose Phase 2 auth/dex/wallet helpers from service barrel
biwasxyz May 2, 2026
d06409b
feat(okx): add okx_dex_* MCP tools for aggregator routing
biwasxyz May 2, 2026
db9eb3a
feat(okx): add okx_wallet_* MCP tools for chains, balances, UTXOs
biwasxyz May 2, 2026
f2f761b
feat(okx): wire Phase 2 DEX + Wallet tools into the OKX aggregator
biwasxyz May 2, 2026
3dc9bf4
test(okx): cover HMAC signing, credential loader, and signed client
biwasxyz May 2, 2026
b236261
fix(okx): align tickers description with the actual instType enum
biwasxyz May 4, 2026
b586838
refactor(okx): make OkxCandle a 9-element tuple for positional safety
biwasxyz May 4, 2026
eb3b846
refactor(okx): single source of truth for candle bar enum
biwasxyz May 4, 2026
338a10f
fix(okx): read OKX_WEB3_BASE_URL at call time, not at module load
biwasxyz May 4, 2026
f9646b6
feat(okx): type DEX response shapes + drop double-casts on params
biwasxyz May 4, 2026
3be4b9c
feat(okx): make project_id optional via requireProjectId flag
biwasxyz May 4, 2026
a5df897
docs(okx): clarify candles limit=300 against the live server cap
biwasxyz May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/services/okx/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* OKX private API auth: HMAC signing and lazy credential loading.
*
* Signing algorithm (per OKX v5 docs, Authentication > Signature):
* pre-sign string = timestamp + METHOD + requestPath + body
* signature = base64(HMAC-SHA256(pre-sign, secret))
*
* Where:
* - timestamp ISO 8601 UTC with milliseconds (e.g. "2026-05-02T00:00:00.000Z")
* The same string goes in OK-ACCESS-TIMESTAMP and the pre-sign.
* - METHOD UPPERCASE (GET / POST)
* - requestPath absolute path including the query string for GET
* (e.g. "/api/v5/dex/aggregator/quote?chainId=1&amount=...")
* - body empty string for GET; stringified JSON for POST
*
* Credentials live in the existing aibtc credential store (encrypted at
* ~/.aibtc/credentials.enc, password from ARC_CREDS_PASSWORD env var) under
* service="okx" with keys: api_key, secret, passphrase, project_id.
*
* project_id is required for WaaS endpoints (web3.okx.com — DEX + Wallet),
* sent as the OK-ACCESS-PROJECT header. It is not used by CEX private endpoints.
*/

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

export interface OkxCredentials {
apiKey: string;
secret: string;
passphrase: string;
/**
* WaaS-only (DEX + Wallet API). Optional so future CEX-private
* callers can omit it — for those flows pass requireProjectId=false
* to getOkxCredentials.
*/
projectId?: string;
}

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

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

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

/**
* Load OKX credentials, throwing OkxCredentialsMissingError listing every
* key that's not set. Unlocks the credential store first if needed.
*
* @param requireProjectId When true (default), project_id is required and
* included in the returned credentials. WaaS
* endpoints (DEX + Wallet) need this. Pass false
* for CEX-private endpoints which don't use the
* OK-ACCESS-PROJECT header.
*/
export async function getOkxCredentials(
requireProjectId = true
): Promise<OkxCredentials> {
if (!credentials.isUnlocked()) {
await credentials.unlock();
}
const apiKey = credentials.get("okx", "api_key");
const secret = credentials.get("okx", "secret");
const passphrase = credentials.get("okx", "passphrase");
const projectId = credentials.get("okx", "project_id");

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

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

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

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

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

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

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

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

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

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

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

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

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

return body.data;
}

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

/**
* Signed GET request for OKX private endpoints (CEX trading, DEX aggregator,
* Wallet API). Builds the OK-ACCESS-* headers using the supplied credentials
* and the canonical pre-sign string (timestamp + method + path-with-query + body).
*
* For WaaS endpoints (DEX/Wallet), pass `opts.baseUrl: getOkxWeb3BaseUrl()`.
*/
export async function okxAuthGet<T>(
path: string,
params: Record<string, string | number | undefined> | undefined,
creds: OkxCredentials,
opts: OkxRequestOptions = {}
): Promise<T[]> {
const baseUrl = opts.baseUrl ?? getOkxBaseUrl();
const query = buildQuery(params);
const requestPath = `${path}${query}`;
const url = `${baseUrl}${requestPath}`;
const timestamp = okxTimestamp();
const headers = {
Accept: "application/json",
...buildOkxAuthHeaders(creds, timestamp, "GET", requestPath, ""),
};
const response = await fetch(url, { method: "GET", headers });
return parseOkxResponse<T>(response);
}
Loading
Loading