import WebSocket from 'ws' import nacl from 'tweetnacl' import bs58 from 'bs58' import { httpClient } from '../../utils/httpClient' export interface PacificaEndpoints { time: string // GET symbols: string // GET depth: string // GET (expects ?symbol=&limit=) balances: string // GET positions: string // GET accountInfo: string // GET leverage: string // POST subaccountCreate: string // POST agentBind: string // POST orderCreateMarket: string // POST orderCreateLimit: string // POST orderCreateStop: string // POST orderCancel: string // POST orderCancelAll: string // POST orderGet: string // GET (expects ?symbol=&orderId=) openOrders: string // GET (expects ?symbol=&account=) orderBatch: string // POST positionTpSl: string // POST } export interface PacificaCfg { baseUrl: string wsUrl: string apiKey?: string privateKey?: string timeoutMs?: number account?: string agentWallet?: string agentPrivateKey?: string } export class PacificaClient { public endpoints: PacificaEndpoints public cfg: PacificaCfg private cachedKeys?: { secretKey: Uint8Array; publicKey: Uint8Array } private cachedAgentKeys?: { secretKey: Uint8Array; publicKey: Uint8Array } private defaultAccount?: string private agentWallet?: string constructor(cfg: PacificaCfg) { this.cfg = cfg if (!this.cfg.baseUrl || !this.cfg.wsUrl) { throw new Error('PacificaClient: baseUrl and wsUrl are required') } // 固定为官方文档结构,不再从环境或配置读取 this.endpoints = { // Per docs: base is /api/v1; info and orderbook exist time: '/api/v1/info', symbols: '/api/v1/info', depth: '/api/v1/book', prices: '/api/v1/prices', // The rest may differ; keep placeholders under /api/v1 for now balances: '/api/v1/account/balance', positions: '/api/v1/account/positions', accountInfo: '/api/v1/account/info', // POST per docs leverage: '/api/v1/account/update-leverage', subaccountCreate: '/api/v1/subaccounts/create', agentBind: '/api/v1/agent/bind', orderCreateMarket: '/api/v1/orders/create_market', orderCreateLimit: '/api/v1/orders/create', orderCreateStop: '/api/v1/orders/stop/create', orderCancel: '/api/v1/orders/cancel', orderCancelAll: '/api/v1/orders/cancel_all', orderGet: '/api/v1/orders/history', // may be GET by id or requires POST; keep for now openOrders: '/api/v1/orders', orderBatch: '/api/v1/orders/batch', positionTpSl: '/api/v1/positions/tpsl', } this.defaultAccount = cfg.account this.agentWallet = cfg.agentWallet } private async sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)) } private shouldRetry(status: number): boolean { if (status === 429) return true if (status >= 500 && status <= 599) return true return false } private parseSecretKey(): { secretKey?: Uint8Array; publicKey?: Uint8Array } { if (this.cachedKeys) return this.cachedKeys const pk = this.cfg.privateKey if (!pk) return {} try { let sk: Uint8Array | undefined if (pk.startsWith('[')) { sk = Uint8Array.from(JSON.parse(pk)) } else { sk = bs58.decode(pk) } if (sk && (sk.length === 64 || sk.length === 32)) { const kp = sk.length === 64 ? nacl.sign.keyPair.fromSecretKey(sk) : nacl.sign.keyPair.fromSeed(sk) this.cachedKeys = { secretKey: kp.secretKey, publicKey: kp.publicKey } return this.cachedKeys } } catch {} return {} } private parseAgentSecretKey(): { secretKey?: Uint8Array; publicKey?: Uint8Array } { if (this.cachedAgentKeys) return this.cachedAgentKeys const pk = this.cfg.agentPrivateKey if (!pk) return {} try { let sk: Uint8Array | undefined if (pk.startsWith('[')) { sk = Uint8Array.from(JSON.parse(pk)) } else { sk = bs58.decode(pk) } if (sk && (sk.length === 64 || sk.length === 32)) { const kp = sk.length === 64 ? nacl.sign.keyPair.fromSecretKey(sk) : nacl.sign.keyPair.fromSeed(sk) this.cachedAgentKeys = { secretKey: kp.secretKey, publicKey: kp.publicKey } return this.cachedAgentKeys } } catch {} return {} } requireAccount(account?: string): string { const finalAccount = account ?? this.defaultAccount if (!finalAccount) throw new Error('PacificaClient: account is required') return finalAccount } requireAgentWallet(wallet?: string): string { const final = wallet ?? this.agentWallet if (!final) throw new Error('PacificaClient: agent wallet is required') return final } private sortJson(value: any): any { if (Array.isArray(value)) return value.map(item => this.sortJson(item)) if (value && typeof value === 'object') { const sorted: Record = {} Object.keys(value) .sort() .forEach(key => { const val = value[key] if (val !== undefined && val !== null) sorted[key] = this.sortJson(val) }) return sorted } return value } private canonicalJson(input: any): string { if (input == null) return '' const sorted = this.sortJson(input) return JSON.stringify(sorted, (key, value) => value, 0).replace(/\s+/g, '') } private signHeaders = async (path: string, body: any, ts: number): Promise> => { const headers: Record = {} if (this.cfg.apiKey) headers['X-API-KEY'] = this.cfg.apiKey const { secretKey, publicKey } = this.parseSecretKey() if (!secretKey || !publicKey) return headers const payload = `${ts}|${path}|${this.canonicalJson(body)}` const msg = new TextEncoder().encode(payload) const sig = nacl.sign.detached(msg, secretKey) headers['X-PUBKEY'] = bs58.encode(publicKey) headers['X-TS'] = String(ts) headers['X-SIGNATURE'] = bs58.encode(sig) return headers } async buildSignedHeaders(path: string, body?: any): Promise> { const ts = Date.now() return await this.signHeaders(path, body, ts) } private async sign(type: string, data: Record, expiryWindow: number, useAgent: boolean) { const keys = useAgent ? this.parseAgentSecretKey() : this.parseSecretKey() if (!keys.secretKey) throw new Error('PacificaClient: private key missing for signing') const timestamp = Date.now() const message = this.sortJson({ timestamp, expiry_window: expiryWindow, type, data }) const json = JSON.stringify(message, (k, v) => v, 0).replace(/\s+/g, '') const sig = nacl.sign.detached(new TextEncoder().encode(json), keys.secretKey) return { timestamp, expiryWindow, signature: bs58.encode(sig) } } async signOperation(type: string, data: Record, expiryWindow = 30000) { return this.sign(type, data, expiryWindow, false) } async signOperationWithAgent(type: string, data: Record, expiryWindow = 30000) { return this.sign(type, data, expiryWindow, true) } async get(path: string, query?: Record): Promise { const queryString = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined) as any).toString()}` : '' const url = `${this.cfg.baseUrl}${path}${queryString}` const pathForSig = path.split('?')[0] const maxRetries = 4 const baseDelay = 250 for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const ts = Date.now() const headers = await this.signHeaders(pathForSig, undefined, ts) const res = await httpClient.get(url, { headers: headers as any, exchange: 'pacifica', timeout: this.cfg.timeoutMs || 30000, retries: 0, // 我们自己处理重试 }) if (!res.ok) { if (this.shouldRetry(res.status) && attempt < maxRetries) { // 对429错误使用更长的等待时间 const isRateLimit = res.status === 429 const delay = isRateLimit ? 5000 + attempt * 2000 // 429错误: 5s, 7s, 9s, 11s : baseDelay * Math.pow(2, attempt) // 其他错误: 指数退避 const jitter = Math.floor(Math.random() * 1000) console.log(`HTTP ${res.status}错误,${delay + jitter}ms后重试 (${attempt + 1}/${maxRetries + 1})`) await this.sleep(delay + jitter) continue } const errorMsg = res.data ? (typeof res.data === 'string' ? res.data : JSON.stringify(res.data)) : '' throw new Error(`HTTP ${res.status} ${path}${errorMsg ? ` - ${errorMsg.slice(0, 500)}` : ''}`) } return res.data as T } catch (e: any) { const message = String(e?.message || '') if (message.startsWith('HTTP')) { throw e } if (attempt < maxRetries) { const jitter = Math.floor(Math.random() * 100) await this.sleep(baseDelay * Math.pow(2, attempt) + jitter) continue } throw e } } // Unreachable, to satisfy TS return type throw new Error('unreachable') } async getPublic(path: string): Promise { const url = `${this.cfg.baseUrl}${path}` const maxRetries = 4 const baseDelay = 250 for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const res = await httpClient.get(url, { exchange: 'pacifica', timeout: this.cfg.timeoutMs || 30000, retries: 0, // 我们自己处理重试 }) if (!res.ok) { if (this.shouldRetry(res.status) && attempt < maxRetries) { // 对429错误使用更长的等待时间 const isRateLimit = res.status === 429 const delay = isRateLimit ? 5000 + attempt * 2000 // 429错误: 5s, 7s, 9s, 11s : baseDelay * Math.pow(2, attempt) // 其他错误: 指数退避 const jitter = Math.floor(Math.random() * 1000) console.log(`HTTP ${res.status}错误,${delay + jitter}ms后重试 (${attempt + 1}/${maxRetries + 1})`) await this.sleep(delay + jitter) continue } const errorMsg = res.data ? (typeof res.data === 'string' ? res.data : JSON.stringify(res.data)) : '' throw new Error(`HTTP ${res.status} ${path}${errorMsg ? ` - ${errorMsg.slice(0, 500)}` : ''}`) } return res.data as T } catch (e: any) { const message = String(e?.message || '') if (message.startsWith('HTTP')) { throw e } if (attempt < maxRetries) { const jitter = Math.floor(Math.random() * 100) await this.sleep(baseDelay * Math.pow(2, attempt) + jitter) continue } throw e } } throw new Error('unreachable') } async post(path: string, body: any, opts?: { skipHeaderSig?: boolean }): Promise { const url = `${this.cfg.baseUrl}${path}` const maxRetries = 4 const baseDelay = 250 for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const ts = Date.now() const headers = opts?.skipHeaderSig ? {} : await this.signHeaders(path, body, ts) const res = await httpClient.post(url, body, { headers: { 'Content-Type': 'application/json', ...headers } as any, exchange: 'pacifica', timeout: this.cfg.timeoutMs || 30000, retries: 0, // 我们自己处理重试 }) if (!res.ok) { if (this.shouldRetry(res.status) && attempt < maxRetries) { // 对429错误使用更长的等待时间 const isRateLimit = res.status === 429 const delay = isRateLimit ? 5000 + attempt * 2000 // 429错误: 5s, 7s, 9s, 11s : baseDelay * Math.pow(2, attempt) // 其他错误: 指数退避 const jitter = Math.floor(Math.random() * 1000) console.log(`HTTP ${res.status}错误,${delay + jitter}ms后重试 (${attempt + 1}/${maxRetries + 1})`) await this.sleep(delay + jitter) continue } const errorMsg = res.data ? (typeof res.data === 'string' ? res.data : JSON.stringify(res.data)) : '' throw new Error(`HTTP ${res.status} ${path}${errorMsg ? ` - ${errorMsg.slice(0, 500)}` : ''}`) } return res.data as T } catch (e: any) { const message = String(e?.message || '') if (message.startsWith('HTTP')) { throw e } if (attempt < maxRetries) { const jitter = Math.floor(Math.random() * 100) await this.sleep(baseDelay * Math.pow(2, attempt) + jitter) continue } throw e } } throw new Error('unreachable') } wsConnect(_params: Record = {}, _auth = true): WebSocket { // 官方主网:wss://ws.pacifica.fi/ws 测试网:wss://test-ws.pacifica.fi/ws // 若 cfg.wsUrl 已包含 /ws 则直接使用;否则补上 /ws const raw = this.cfg.wsUrl.replace(/\/$/, '') const url = /\/ws(\?|$)/.test(raw) ? raw : `${raw}/ws` return new WebSocket(url) } async wsLogin(_ws: WebSocket, _params: Record = {}): Promise { return } }