PacificaClient.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import WebSocket from 'ws'
  2. import nacl from 'tweetnacl'
  3. import bs58 from 'bs58'
  4. import { httpClient } from '../../utils/httpClient'
  5. export interface PacificaEndpoints {
  6. time: string // GET
  7. symbols: string // GET
  8. depth: string // GET (expects ?symbol=&limit=)
  9. balances: string // GET
  10. positions: string // GET
  11. accountInfo: string // GET
  12. leverage: string // POST
  13. subaccountCreate: string // POST
  14. agentBind: string // POST
  15. orderCreateMarket: string // POST
  16. orderCreateLimit: string // POST
  17. orderCreateStop: string // POST
  18. orderCancel: string // POST
  19. orderCancelAll: string // POST
  20. orderGet: string // GET (expects ?symbol=&orderId=)
  21. openOrders: string // GET (expects ?symbol=&account=)
  22. orderBatch: string // POST
  23. positionTpSl: string // POST
  24. }
  25. export interface PacificaCfg {
  26. baseUrl: string
  27. wsUrl: string
  28. apiKey?: string
  29. privateKey?: string
  30. timeoutMs?: number
  31. account?: string
  32. agentWallet?: string
  33. agentPrivateKey?: string
  34. }
  35. export class PacificaClient {
  36. public endpoints: PacificaEndpoints
  37. public cfg: PacificaCfg
  38. private cachedKeys?: { secretKey: Uint8Array; publicKey: Uint8Array }
  39. private cachedAgentKeys?: { secretKey: Uint8Array; publicKey: Uint8Array }
  40. private defaultAccount?: string
  41. private agentWallet?: string
  42. constructor(cfg: PacificaCfg) {
  43. this.cfg = cfg
  44. if (!this.cfg.baseUrl || !this.cfg.wsUrl) {
  45. throw new Error('PacificaClient: baseUrl and wsUrl are required')
  46. }
  47. // 固定为官方文档结构,不再从环境或配置读取
  48. this.endpoints = {
  49. // Per docs: base is /api/v1; info and orderbook exist
  50. time: '/api/v1/info',
  51. symbols: '/api/v1/info',
  52. depth: '/api/v1/book',
  53. prices: '/api/v1/prices',
  54. // The rest may differ; keep placeholders under /api/v1 for now
  55. balances: '/api/v1/account/balance',
  56. positions: '/api/v1/account/positions',
  57. accountInfo: '/api/v1/account/info', // POST per docs
  58. leverage: '/api/v1/account/update-leverage',
  59. subaccountCreate: '/api/v1/subaccounts/create',
  60. agentBind: '/api/v1/agent/bind',
  61. orderCreateMarket: '/api/v1/orders/create_market',
  62. orderCreateLimit: '/api/v1/orders/create',
  63. orderCreateStop: '/api/v1/orders/stop/create',
  64. orderCancel: '/api/v1/orders/cancel',
  65. orderCancelAll: '/api/v1/orders/cancel_all',
  66. orderGet: '/api/v1/orders/history', // may be GET by id or requires POST; keep for now
  67. openOrders: '/api/v1/orders',
  68. orderBatch: '/api/v1/orders/batch',
  69. positionTpSl: '/api/v1/positions/tpsl',
  70. }
  71. this.defaultAccount = cfg.account
  72. this.agentWallet = cfg.agentWallet
  73. }
  74. private async sleep(ms: number): Promise<void> {
  75. return new Promise(r => setTimeout(r, ms))
  76. }
  77. private shouldRetry(status: number): boolean {
  78. if (status === 429) return true
  79. if (status >= 500 && status <= 599) return true
  80. return false
  81. }
  82. private parseSecretKey(): { secretKey?: Uint8Array; publicKey?: Uint8Array } {
  83. if (this.cachedKeys) return this.cachedKeys
  84. const pk = this.cfg.privateKey
  85. if (!pk) return {}
  86. try {
  87. let sk: Uint8Array | undefined
  88. if (pk.startsWith('[')) {
  89. sk = Uint8Array.from(JSON.parse(pk))
  90. } else {
  91. sk = bs58.decode(pk)
  92. }
  93. if (sk && (sk.length === 64 || sk.length === 32)) {
  94. const kp = sk.length === 64 ? nacl.sign.keyPair.fromSecretKey(sk) : nacl.sign.keyPair.fromSeed(sk)
  95. this.cachedKeys = { secretKey: kp.secretKey, publicKey: kp.publicKey }
  96. return this.cachedKeys
  97. }
  98. } catch {}
  99. return {}
  100. }
  101. private parseAgentSecretKey(): { secretKey?: Uint8Array; publicKey?: Uint8Array } {
  102. if (this.cachedAgentKeys) return this.cachedAgentKeys
  103. const pk = this.cfg.agentPrivateKey
  104. if (!pk) return {}
  105. try {
  106. let sk: Uint8Array | undefined
  107. if (pk.startsWith('[')) {
  108. sk = Uint8Array.from(JSON.parse(pk))
  109. } else {
  110. sk = bs58.decode(pk)
  111. }
  112. if (sk && (sk.length === 64 || sk.length === 32)) {
  113. const kp = sk.length === 64 ? nacl.sign.keyPair.fromSecretKey(sk) : nacl.sign.keyPair.fromSeed(sk)
  114. this.cachedAgentKeys = { secretKey: kp.secretKey, publicKey: kp.publicKey }
  115. return this.cachedAgentKeys
  116. }
  117. } catch {}
  118. return {}
  119. }
  120. requireAccount(account?: string): string {
  121. const finalAccount = account ?? this.defaultAccount
  122. if (!finalAccount) throw new Error('PacificaClient: account is required')
  123. return finalAccount
  124. }
  125. requireAgentWallet(wallet?: string): string {
  126. const final = wallet ?? this.agentWallet
  127. if (!final) throw new Error('PacificaClient: agent wallet is required')
  128. return final
  129. }
  130. private sortJson(value: any): any {
  131. if (Array.isArray(value)) return value.map(item => this.sortJson(item))
  132. if (value && typeof value === 'object') {
  133. const sorted: Record<string, any> = {}
  134. Object.keys(value)
  135. .sort()
  136. .forEach(key => {
  137. const val = value[key]
  138. if (val !== undefined && val !== null) sorted[key] = this.sortJson(val)
  139. })
  140. return sorted
  141. }
  142. return value
  143. }
  144. private canonicalJson(input: any): string {
  145. if (input == null) return ''
  146. const sorted = this.sortJson(input)
  147. return JSON.stringify(sorted, (key, value) => value, 0).replace(/\s+/g, '')
  148. }
  149. private signHeaders = async (path: string, body: any, ts: number): Promise<Record<string, string>> => {
  150. const headers: Record<string, string> = {}
  151. if (this.cfg.apiKey) headers['X-API-KEY'] = this.cfg.apiKey
  152. const { secretKey, publicKey } = this.parseSecretKey()
  153. if (!secretKey || !publicKey) return headers
  154. const payload = `${ts}|${path}|${this.canonicalJson(body)}`
  155. const msg = new TextEncoder().encode(payload)
  156. const sig = nacl.sign.detached(msg, secretKey)
  157. headers['X-PUBKEY'] = bs58.encode(publicKey)
  158. headers['X-TS'] = String(ts)
  159. headers['X-SIGNATURE'] = bs58.encode(sig)
  160. return headers
  161. }
  162. async buildSignedHeaders(path: string, body?: any): Promise<Record<string, string>> {
  163. const ts = Date.now()
  164. return await this.signHeaders(path, body, ts)
  165. }
  166. private async sign(type: string, data: Record<string, any>, expiryWindow: number, useAgent: boolean) {
  167. const keys = useAgent ? this.parseAgentSecretKey() : this.parseSecretKey()
  168. if (!keys.secretKey) throw new Error('PacificaClient: private key missing for signing')
  169. const timestamp = Date.now()
  170. const message = this.sortJson({ timestamp, expiry_window: expiryWindow, type, data })
  171. const json = JSON.stringify(message, (k, v) => v, 0).replace(/\s+/g, '')
  172. const sig = nacl.sign.detached(new TextEncoder().encode(json), keys.secretKey)
  173. return { timestamp, expiryWindow, signature: bs58.encode(sig) }
  174. }
  175. async signOperation(type: string, data: Record<string, any>, expiryWindow = 30000) {
  176. return this.sign(type, data, expiryWindow, false)
  177. }
  178. async signOperationWithAgent(type: string, data: Record<string, any>, expiryWindow = 30000) {
  179. return this.sign(type, data, expiryWindow, true)
  180. }
  181. async get<T>(path: string, query?: Record<string, string | number | undefined>): Promise<T> {
  182. const queryString = query
  183. ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined) as any).toString()}`
  184. : ''
  185. const url = `${this.cfg.baseUrl}${path}${queryString}`
  186. const pathForSig = path.split('?')[0]
  187. const maxRetries = 4
  188. const baseDelay = 250
  189. for (let attempt = 0; attempt <= maxRetries; attempt++) {
  190. try {
  191. const ts = Date.now()
  192. const headers = await this.signHeaders(pathForSig, undefined, ts)
  193. const res = await httpClient.get(url, {
  194. headers: headers as any,
  195. exchange: 'pacifica',
  196. timeout: this.cfg.timeoutMs || 30000,
  197. retries: 0, // 我们自己处理重试
  198. })
  199. if (!res.ok) {
  200. if (this.shouldRetry(res.status) && attempt < maxRetries) {
  201. // 对429错误使用更长的等待时间
  202. const isRateLimit = res.status === 429
  203. const delay = isRateLimit
  204. ? 5000 + attempt * 2000 // 429错误: 5s, 7s, 9s, 11s
  205. : baseDelay * Math.pow(2, attempt) // 其他错误: 指数退避
  206. const jitter = Math.floor(Math.random() * 1000)
  207. console.log(`HTTP ${res.status}错误,${delay + jitter}ms后重试 (${attempt + 1}/${maxRetries + 1})`)
  208. await this.sleep(delay + jitter)
  209. continue
  210. }
  211. const errorMsg = res.data ? (typeof res.data === 'string' ? res.data : JSON.stringify(res.data)) : ''
  212. throw new Error(`HTTP ${res.status} ${path}${errorMsg ? ` - ${errorMsg.slice(0, 500)}` : ''}`)
  213. }
  214. return res.data as T
  215. } catch (e: any) {
  216. const message = String(e?.message || '')
  217. if (message.startsWith('HTTP')) {
  218. throw e
  219. }
  220. if (attempt < maxRetries) {
  221. const jitter = Math.floor(Math.random() * 100)
  222. await this.sleep(baseDelay * Math.pow(2, attempt) + jitter)
  223. continue
  224. }
  225. throw e
  226. }
  227. }
  228. // Unreachable, to satisfy TS return type
  229. throw new Error('unreachable')
  230. }
  231. async getPublic<T>(path: string): Promise<T> {
  232. const url = `${this.cfg.baseUrl}${path}`
  233. const maxRetries = 4
  234. const baseDelay = 250
  235. for (let attempt = 0; attempt <= maxRetries; attempt++) {
  236. try {
  237. const res = await httpClient.get(url, {
  238. exchange: 'pacifica',
  239. timeout: this.cfg.timeoutMs || 30000,
  240. retries: 0, // 我们自己处理重试
  241. })
  242. if (!res.ok) {
  243. if (this.shouldRetry(res.status) && attempt < maxRetries) {
  244. // 对429错误使用更长的等待时间
  245. const isRateLimit = res.status === 429
  246. const delay = isRateLimit
  247. ? 5000 + attempt * 2000 // 429错误: 5s, 7s, 9s, 11s
  248. : baseDelay * Math.pow(2, attempt) // 其他错误: 指数退避
  249. const jitter = Math.floor(Math.random() * 1000)
  250. console.log(`HTTP ${res.status}错误,${delay + jitter}ms后重试 (${attempt + 1}/${maxRetries + 1})`)
  251. await this.sleep(delay + jitter)
  252. continue
  253. }
  254. const errorMsg = res.data ? (typeof res.data === 'string' ? res.data : JSON.stringify(res.data)) : ''
  255. throw new Error(`HTTP ${res.status} ${path}${errorMsg ? ` - ${errorMsg.slice(0, 500)}` : ''}`)
  256. }
  257. return res.data as T
  258. } catch (e: any) {
  259. const message = String(e?.message || '')
  260. if (message.startsWith('HTTP')) {
  261. throw e
  262. }
  263. if (attempt < maxRetries) {
  264. const jitter = Math.floor(Math.random() * 100)
  265. await this.sleep(baseDelay * Math.pow(2, attempt) + jitter)
  266. continue
  267. }
  268. throw e
  269. }
  270. }
  271. throw new Error('unreachable')
  272. }
  273. async post<T>(path: string, body: any, opts?: { skipHeaderSig?: boolean }): Promise<T> {
  274. const url = `${this.cfg.baseUrl}${path}`
  275. const maxRetries = 4
  276. const baseDelay = 250
  277. for (let attempt = 0; attempt <= maxRetries; attempt++) {
  278. try {
  279. const ts = Date.now()
  280. const headers = opts?.skipHeaderSig ? {} : await this.signHeaders(path, body, ts)
  281. const res = await httpClient.post(url, body, {
  282. headers: { 'Content-Type': 'application/json', ...headers } as any,
  283. exchange: 'pacifica',
  284. timeout: this.cfg.timeoutMs || 30000,
  285. retries: 0, // 我们自己处理重试
  286. })
  287. if (!res.ok) {
  288. if (this.shouldRetry(res.status) && attempt < maxRetries) {
  289. // 对429错误使用更长的等待时间
  290. const isRateLimit = res.status === 429
  291. const delay = isRateLimit
  292. ? 5000 + attempt * 2000 // 429错误: 5s, 7s, 9s, 11s
  293. : baseDelay * Math.pow(2, attempt) // 其他错误: 指数退避
  294. const jitter = Math.floor(Math.random() * 1000)
  295. console.log(`HTTP ${res.status}错误,${delay + jitter}ms后重试 (${attempt + 1}/${maxRetries + 1})`)
  296. await this.sleep(delay + jitter)
  297. continue
  298. }
  299. const errorMsg = res.data ? (typeof res.data === 'string' ? res.data : JSON.stringify(res.data)) : ''
  300. throw new Error(`HTTP ${res.status} ${path}${errorMsg ? ` - ${errorMsg.slice(0, 500)}` : ''}`)
  301. }
  302. return res.data as T
  303. } catch (e: any) {
  304. const message = String(e?.message || '')
  305. if (message.startsWith('HTTP')) {
  306. throw e
  307. }
  308. if (attempt < maxRetries) {
  309. const jitter = Math.floor(Math.random() * 100)
  310. await this.sleep(baseDelay * Math.pow(2, attempt) + jitter)
  311. continue
  312. }
  313. throw e
  314. }
  315. }
  316. throw new Error('unreachable')
  317. }
  318. wsConnect(_params: Record<string, any> = {}, _auth = true): WebSocket {
  319. // 官方主网:wss://ws.pacifica.fi/ws 测试网:wss://test-ws.pacifica.fi/ws
  320. // 若 cfg.wsUrl 已包含 /ws 则直接使用;否则补上 /ws
  321. const raw = this.cfg.wsUrl.replace(/\/$/, '')
  322. const url = /\/ws(\?|$)/.test(raw) ? raw : `${raw}/ws`
  323. return new WebSocket(url)
  324. }
  325. async wsLogin(_ws: WebSocket, _params: Record<string, any> = {}): Promise<void> {
  326. return
  327. }
  328. }