123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- 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<void> {
- 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<string, any> = {}
- 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<Record<string, string>> => {
- const headers: Record<string, string> = {}
- 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<Record<string, string>> {
- const ts = Date.now()
- return await this.signHeaders(path, body, ts)
- }
- private async sign(type: string, data: Record<string, any>, 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<string, any>, expiryWindow = 30000) {
- return this.sign(type, data, expiryWindow, false)
- }
- async signOperationWithAgent(type: string, data: Record<string, any>, expiryWindow = 30000) {
- return this.sign(type, data, expiryWindow, true)
- }
- async get<T>(path: string, query?: Record<string, string | number | undefined>): Promise<T> {
- 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<T>(path: string): Promise<T> {
- 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<T>(path: string, body: any, opts?: { skipHeaderSig?: boolean }): Promise<T> {
- 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<string, any> = {}, _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<string, any> = {}): Promise<void> {
- return
- }
- }
|