import WebSocket from 'ws' import nacl from 'tweetnacl' import bs58 from 'bs58' import { httpClient } from '../../utils/httpClient.js' export class PacificaClient { constructor(cfg) { this.signHeaders = async (path, body, ts) => { const headers = {} 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 } 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', 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', openOrders: '/api/v1/orders', orderBatch: '/api/v1/orders/batch', positionTpSl: '/api/v1/positions/tpsl', } this.defaultAccount = cfg.account this.agentWallet = cfg.agentWallet } async sleep(ms) { return new Promise(r => setTimeout(r, ms)) } shouldRetry(status) { if (status === 429) return true if (status >= 500 && status <= 599) return true return false } parseSecretKey() { if (this.cachedKeys) return this.cachedKeys const pk = this.cfg.privateKey if (!pk) return {} try { let sk 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 {} } parseAgentSecretKey() { if (this.cachedAgentKeys) return this.cachedAgentKeys const pk = this.cfg.agentPrivateKey if (!pk) return {} try { let sk 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) { const finalAccount = account ?? this.defaultAccount if (!finalAccount) throw new Error('PacificaClient: account is required') return finalAccount } requireAgentWallet(wallet) { const final = wallet ?? this.agentWallet if (!final) throw new Error('PacificaClient: agent wallet is required') return final } sortJson(value) { if (Array.isArray(value)) return value.map(item => this.sortJson(item)) if (value && typeof value === 'object') { const sorted = {} Object.keys(value) .sort() .forEach(key => { const val = value[key] if (val !== undefined && val !== null) sorted[key] = this.sortJson(val) }) return sorted } return value } canonicalJson(input) { if (input == null) return '' const sorted = this.sortJson(input) return JSON.stringify(sorted, (key, value) => value, 0).replace(/\s+/g, '') } async buildSignedHeaders(path, body) { const ts = Date.now() return await this.signHeaders(path, body, ts) } async sign(type, data, expiryWindow, useAgent) { 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, data, expiryWindow = 30000) { return this.sign(type, data, expiryWindow, false) } async signOperationWithAgent(type, data, expiryWindow = 30000) { return this.sign(type, data, expiryWindow, true) } async get(path, query) { const queryString = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined)).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, 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 } catch (e) { 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) { 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 } catch (e) { 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, body, opts) { 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 }, 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 } catch (e) { 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 = {}, _auth = true) { // 官方主网: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, _params = {}) { return } }