PacificaClient.js 11 KB

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