import { AbiCoder, Wallet, keccak256, getBytes } from 'ethers' import { createHmac } from 'crypto' export interface AsterConfig { rpcUrl: string chainId: number routerAddress: string // Aster 合约/路由地址(根据文档填写) readerAddress?: string // 读取器合约地址(可选) httpBase?: string // REST API Base,例如 https://fapi.asterdex.com defaultUser?: string // 下单 user 地址(owner) defaultSigner?: string // 下单 signer 地址(与私钥对应) apiKey?: string // (可选)REST HMAC API Key,用于读取账户设置 apiSecret?: string // (可选)REST HMAC API Secret } export interface AsterQuoteParams { symbol: string // 交易对(与 Aster 平台定义保持一致) side: 'long' | 'short' quantity: number // 张数或标的数量(依据 Aster 定义) slippage: number // 最大滑点比例 } export interface AsterQuoteResult { expectedPrice: number feeUsd?: number fundingRate?: number } export interface AsterOrderParams extends AsterQuoteParams { deadlineSec: number positionSide?: 'BOTH' | 'LONG' | 'SHORT' } export interface AsterOrderResult { success: boolean txHash?: string error?: string } export class AsterAdapter { private provider: any // 懒加载链上需求 private signer?: Wallet private cfg: AsterConfig private dualSideCache?: boolean private listenKeyState?: { key: string; updatedAt: number } constructor(cfg: AsterConfig, signer?: Wallet) { this.cfg = cfg // 仅在需要链上时使用 provider // @ts-ignore this.provider = undefined this.signer = signer } async getFundingRate(symbol: string): Promise { // 使用 REST: GET /fapi/v3/fundingRate?symbol=BTCUSDT&limit=1 取最近一条 const base = this.cfg.httpBase || process.env.ASTER_HTTP_BASE || '' if (!base) return null const url = `${base.replace(/\/$/, '')}/fapi/v3/fundingRate?symbol=${encodeURIComponent(symbol)}&limit=1` try { const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any) if (!('ok' in res) || !(res as any).ok) return null const data = await (res as any).json() const item = Array.isArray(data) ? data[0] : data const rate = Number(item?.fundingRate ?? item?.funding_rate ?? item?.r) return Number.isFinite(rate) ? rate : null } catch { return null } } async quote(params: AsterQuoteParams): Promise { // 简化:用标记价格/最新价作为预估成交价,并返回当前资金费率 const base = this.cfg.httpBase || process.env.ASTER_HTTP_BASE || '' if (!base) return { expectedPrice: 0 } const url = `${base.replace(/\/$/, '')}/fapi/v3/ticker/price?symbol=${encodeURIComponent(params.symbol)}` try { const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any) const data = await (res as any).json() const px = Number(data?.price ?? data?.p ?? data?.lastPrice) const funding = await this.getFundingRate(params.symbol) return { expectedPrice: Number.isFinite(px) ? px : 0, fundingRate: funding ?? undefined } } catch { return { expectedPrice: 0 } } } async openPerp(params: AsterOrderParams): Promise { // 使用 REST 市价下单(BUY,LONG 或 BOTH 由调用方选择) const side = params.side === 'long' ? 'BUY' : 'SELL' try { const posSide = params.positionSide ?? (await this.choosePositionSide(side as 'BUY' | 'SELL')) const attempt = async (positionSide: 'BOTH' | 'LONG' | 'SHORT') => { const sig = await this.generateOrderSignature({ symbol: params.symbol, positionSide, type: 'MARKET', side: side as any, quantity: params.quantity, timeInForce: undefined, }) const url = `${this.requireHttpBase()}/fapi/v3/order` const body = new URLSearchParams(sig.formFields).toString() const res = await (globalThis.fetch as any)(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' } as any, body, } as any) const text = await res.text() return { ok: (res as any).ok, text } } const r1 = await attempt(posSide) if (r1.ok) return { success: true, txHash: r1.text } if (r1.text.includes('position side') || r1.text.includes('positionSide')) { // 切换模式重试:BOTH <-> LONG/SHORT const fallback: 'BOTH' | 'LONG' | 'SHORT' = posSide === 'BOTH' ? (side === 'BUY' ? 'LONG' : 'SHORT') : 'BOTH' const r2 = await attempt(fallback) if (r2.ok) return { success: true, txHash: r2.text } return { success: false, error: r2.text.slice(0, 200) } } return { success: false, error: r1.text.slice(0, 200) } } catch (e: any) { return { success: false, error: e?.message || String(e) } } } async closePerp(params: AsterOrderParams): Promise { const side = params.side === 'long' ? 'SELL' : 'BUY' try { const posSide = params.positionSide ?? (await this.choosePositionSide(side as 'BUY' | 'SELL')) const attempt = async (positionSide: 'BOTH' | 'LONG' | 'SHORT') => { const sig = await this.generateOrderSignature({ symbol: params.symbol, positionSide, type: 'MARKET', side: side as any, quantity: params.quantity, timeInForce: undefined, }) const url = `${this.requireHttpBase()}/fapi/v3/order` const body = new URLSearchParams(sig.formFields).toString() const res = await (globalThis.fetch as any)(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' } as any, body, } as any) const text = await res.text() return { ok: (res as any).ok, text } } const r1 = await attempt(posSide) if (r1.ok) return { success: true, txHash: r1.text } if (r1.text.includes('position side') || r1.text.includes('positionSide')) { const fallback: 'BOTH' | 'LONG' | 'SHORT' = posSide === 'BOTH' ? (side === 'BUY' ? 'LONG' : 'SHORT') : 'BOTH' const r2 = await attempt(fallback) if (r2.ok) return { success: true, txHash: r2.text } return { success: false, error: r2.text.slice(0, 200) } } return { success: false, error: r1.text.slice(0, 200) } } catch (e: any) { return { success: false, error: e?.message || String(e) } } } // === REST: 查单 / 撤单(使用与下单相同的签名流程) === /** 生成撤单签名 */ async generateCancelSignature( req: { symbol: string side: 'BUY' | 'SELL' | 'buy' | 'sell' type: 'LIMIT' | 'MARKET' | 'STOP' | 'STOP_MARKET' | 'TAKE_PROFIT' | 'TAKE_PROFIT_MARKET' orderId?: number clientOrderId?: string recvWindow?: number timestamp?: number }, creds?: { user?: string; signer?: string; privateKey?: string; nonceMicros?: bigint }, ): Promise<{ formFields: Record }> { const business: Record = { ...req, recvWindow: req.recvWindow ?? 50000, timestamp: req.timestamp ?? Date.now(), } const bizStrDict = this.normalizeBusinessParams(business) const jsonStr = this.stableJSONString(bizStrDict) const user = creds?.user || this.cfg.defaultUser || process.env.ASTER_ORDER_USER || '' const signer = creds?.signer || this.cfg.defaultSigner || process.env.ASTER_ORDER_SIGNER || '' const privateKey = creds?.privateKey || process.env.PRIVATE_KEY || '' if (!user || !signer || !privateKey) throw new Error('缺少用户、签名者或私钥') const nonce = creds?.nonceMicros ?? BigInt(Math.trunc(Date.now() * 1000)) const signature = await this.signOrderJSONString(jsonStr, user, signer, nonce, privateKey) const formFields: Record = { ...bizStrDict, nonce: String(nonce), user, signer, signature, } return { formFields } } /** GET /fapi/v3/order 查单 */ async getOrderRest(params: { symbol: string side: 'BUY' | 'SELL' type: string orderId?: number clientOrderId?: string }): Promise { const base = this.requireHttpBase() const { formFields } = await this.generateCancelSignature(params) const qs = new URLSearchParams(formFields).toString() const url = `${base}/fapi/v3/order?${qs}` const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any) const txt = await res.text() if (!('ok' in res) || !(res as any).ok) throw new Error(`HTTP ${(res as any).status} GET /fapi/v3/order - ${txt.slice(0, 200)}`) try { return JSON.parse(txt) } catch { return txt } } /** DELETE /fapi/v3/order 撤单 */ async cancelOrderRest(params: { symbol: string side: 'BUY' | 'SELL' type: string orderId?: number clientOrderId?: string }): Promise { const base = this.requireHttpBase() const { formFields } = await this.generateCancelSignature(params) const qs = new URLSearchParams(formFields).toString() const url = `${base}/fapi/v3/order?${qs}` const res = await (globalThis.fetch as any)(url, { method: 'DELETE' } as any) const txt = await res.text() if (!('ok' in res) || !(res as any).ok) throw new Error(`HTTP ${(res as any).status} DELETE /fapi/v3/order - ${txt.slice(0, 200)}`) try { return JSON.parse(txt) } catch { return txt } } // ===== 账户/杠杆相关 ===== async getLeverageBracket(symbol?: string): Promise { const base = this.requireHttpBase() const url = `${base}/fapi/v3/leverageBracket${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}` const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any) if (!('ok' in res) || !(res as any).ok) throw new Error(`HTTP ${(res as any).status} /fapi/v3/leverageBracket`) return await (res as any).json() } async updateLeverage(symbol: string, leverage: number): Promise { // 需要 HMAC 鉴权 const body = await this.hmacGet('/fapi/v3/leverage', { symbol, leverage }) return body } async updateMarginMode(symbol: string, marginType: 'ISOLATED' | 'CROSSED'): Promise { // 需要 HMAC 鉴权 const body = await this.hmacGet('/fapi/v3/marginType', { symbol, marginType }) return body } // === REST 下单封装(按照文档 /fapi/v3/order 签名流程) === /** * 业务参数 -> 纯字符串字典(递归字符串化 list/dict),移除空值 */ private normalizeBusinessParams(input: Record): Record { const cleaned: Record = {} for (const [k, v] of Object.entries(input)) { if (v === undefined || v === null) continue cleaned[k] = v } const toStringValue = (val: any): string => { if (Array.isArray(val)) { const arr = val.map(item => typeof item === 'object' && item !== null ? JSON.stringify(this.deepStringify(item)) : String(item), ) return JSON.stringify(arr) } if (typeof val === 'object') { return JSON.stringify(this.deepStringify(val)) } return String(val) } const out: Record = {} for (const [k, v] of Object.entries(cleaned)) { out[k] = toStringValue(v) } return out } private deepStringify(obj: Record): Record { const out: Record = {} for (const [k, v] of Object.entries(obj)) { if (v === undefined || v === null) continue if (Array.isArray(v)) { out[k] = JSON.stringify( v.map(item => (typeof item === 'object' && item !== null ? this.deepStringify(item) : String(item))), ) } else if (typeof v === 'object') { out[k] = JSON.stringify(this.deepStringify(v as any)) } else { out[k] = String(v) } } return out } /** * 生成按 ASCII 排序的 JSON 字符串 */ private stableJSONString(obj: Record): string { const entries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)) const sorted = Object.fromEntries(entries) return JSON.stringify(sorted) } /** * 计算签名(ABI 编码 ['string','address','address','uint256'] 后 keccak,再 personal_sign) */ private async signOrderJSONString( jsonStr: string, user: string, signerAddr: string, nonce: bigint, privateKey: string, ): Promise { const coder = AbiCoder.defaultAbiCoder() const encoded = coder.encode(['string', 'address', 'address', 'uint256'], [jsonStr, user, signerAddr, nonce]) const hash = keccak256(encoded) const wallet = new Wallet(privateKey) const sig = await wallet.signMessage(getBytes(hash)) return sig } /** * 仅生成签名(不发起 HTTP 请求)。 * 返回 jsonStr(按 ASCII 排序的业务 JSON 字符串)、nonce(微秒)、signature、user、signer 供外部自行拼装表单提交。 */ async generateOrderSignature( order: { symbol: string positionSide: 'BOTH' | 'LONG' | 'SHORT' type: 'LIMIT' | 'MARKET' | 'STOP' | 'STOP_MARKET' | 'TAKE_PROFIT' | 'TAKE_PROFIT_MARKET' side: 'BUY' | 'SELL' timeInForce?: 'GTC' | 'IOC' | 'FOK' | 'GTX' | 'GTD' quantity: string | number price?: string | number recvWindow?: number timestamp?: number }, creds?: { user?: string; signer?: string; privateKey?: string; nonceMicros?: bigint }, ): Promise<{ jsonStr: string nonce: bigint signature: string user: string signer: string formFields: Record // 直接用于 x-www-form-urlencoded 的字段 }> { const business: Record = { ...order, recvWindow: order.recvWindow ?? 50000, timestamp: order.timestamp ?? Date.now(), } const bizStrDict = this.normalizeBusinessParams(business) const jsonStr = this.stableJSONString(bizStrDict) const user = creds?.user || this.cfg.defaultUser || process.env.ASTER_ORDER_USER || '' const signer = creds?.signer || this.cfg.defaultSigner || process.env.ASTER_ORDER_SIGNER || '' const privateKey = creds?.privateKey || process.env.PRIVATE_KEY || '' if (!user || !signer || !privateKey) throw new Error('缺少用户、签名者或私钥') const nonce = creds?.nonceMicros ?? BigInt(Math.trunc(Date.now() * 1000)) const signature = await this.signOrderJSONString(jsonStr, user, signer, nonce, privateKey) const formFields: Record = { ...bizStrDict, nonce: String(nonce), user, signer, signature, } return { jsonStr, nonce, signature, user, signer, formFields } } // ===== REST/HMAC 工具与账户模式 ===== private requireHttpBase(): string { const base = this.cfg.httpBase || process.env.ASTER_HTTP_BASE || '' if (!base) throw new Error('AsterAdapter: 需要httpBase') return base.replace(/\/$/, '') } private async hmacRequest( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, extra: Record = {}, ): Promise { // 对于 listenKey 等端点,使用 Aster 的 ECDSA 签名算法 const business: Record = { ...extra, recvWindow: extra.recvWindow ?? 50000, timestamp: extra.timestamp ?? Date.now(), } const bizStrDict = this.normalizeBusinessParams(business) const jsonStr = this.stableJSONString(bizStrDict) const user = this.cfg.defaultUser || process.env.ASTER_ORDER_USER || '' const signer = this.cfg.defaultSigner || process.env.ASTER_API_KEY || '' const privateKey = process.env.ASTER_API_SECRET || '' if (!user || !signer || !privateKey) throw new Error('HMAC请求需要用户、签名者、私钥') const nonce = BigInt(Math.trunc(Date.now() * 1000)) const signature = await this.signOrderJSONString(jsonStr, user, signer, nonce, privateKey) const formFields: Record = { ...bizStrDict, nonce: String(nonce), user, signer, signature, } // Aster API 不需要 X-MBX-APIKEY header,所有认证通过签名参数 const headers: Record = {} let url = `${this.requireHttpBase()}${path}` const options: any = { method, headers } if (method === 'GET' || method === 'DELETE') { const qs = new URLSearchParams(formFields).toString() url += `?${qs}` } else { headers['content-type'] = 'application/x-www-form-urlencoded' options.body = new URLSearchParams(formFields).toString() } const res = await (globalThis.fetch as any)(url, options) const text = await res.text() if (!('ok' in res) || !(res as any).ok) throw new Error(`HTTP ${(res as any).status} ${path} - ${text.slice(0, 200)}`) try { return JSON.parse(text) } catch { return text } } private async hmacGet(path: string, extra: Record = {}): Promise { return await this.hmacRequest('GET', path, extra) } // === 公共账户查询(HMAC) === async getBalances(): Promise { return await this.hmacGet('/fapi/v3/balance') } async getPositionRisk(symbol?: string): Promise { const params: Record = {} if (symbol) params.symbol = symbol return await this.hmacGet('/fapi/v3/positionRisk', params) } async getOpenOrders(symbol?: string): Promise { const params: Record = {} if (symbol) params.symbol = symbol return await this.hmacGet('/fapi/v3/openOrders', params) } // ===== User Stream (listenKey) ===== /** 创建 listenKey 用于账户类 WS。返回 listenKey 字符串。*/ async createListenKey(): Promise { // USER_STREAM 类型需要签名,尽管文档说参数为 None const body = await this.hmacRequest('POST', '/fapi/v3/listenKey', {}) const lk = String(body?.listenKey || '') if (!lk) throw new Error('empty_listenKey') this.listenKeyState = { key: lk, updatedAt: Date.now() } return lk } /** 延长 listenKey 有效期(官方为 60 分钟有效期,这里建议每 30 分钟续期)。*/ async keepAliveListenKey(): Promise { if (!this.listenKeyState) throw new Error('listenKey_not_initialized') // USER_STREAM 类型需要签名 await this.hmacRequest('PUT', '/fapi/v3/listenKey', {}) this.listenKeyState.updatedAt = Date.now() } /** 关闭 listenKey(可选)。*/ async closeListenKey(): Promise { // USER_STREAM 类型需要签名 await this.hmacRequest('DELETE', '/fapi/v3/listenKey', {}) this.listenKeyState = undefined } async ensureListenKey(opts?: { forceNew?: boolean refreshThresholdMs?: number }): Promise<{ listenKey: string; refreshed: boolean; source: 'cached' | 'refreshed' | 'created' }> { const now = Date.now() const threshold = opts?.refreshThresholdMs ?? 45 * 60 * 1000 if (opts?.forceNew) { const key = await this.createListenKey() return { listenKey: key, refreshed: true, source: 'created' } } if (this.listenKeyState) { const age = now - this.listenKeyState.updatedAt if (age < threshold) { return { listenKey: this.listenKeyState.key, refreshed: false, source: 'cached' } } try { await this.keepAliveListenKey() return { listenKey: this.listenKeyState.key, refreshed: true, source: 'refreshed' } } catch { // fallthrough to recreate } } else { const preset = process.env.ASTER_LISTEN_KEY || (this.cfg as any).listenKey if (preset) { this.listenKeyState = { key: preset, updatedAt: 0 } try { await this.keepAliveListenKey() return { listenKey: preset, refreshed: true, source: 'refreshed' } } catch { this.listenKeyState = undefined } } } const newKey = await this.createListenKey() return { listenKey: newKey, refreshed: true, source: 'created' } } getListenKeyState() { return this.listenKeyState ? { ...this.listenKeyState } : undefined } /** * 读取账户是否为双向持仓模式(true=双向,false=单向)。需要 cfg.apiKey/apiSecret。 * 结果带有简单缓存,可通过 force=true 强制刷新。 */ async getDualSidePosition(force = false): Promise { if (!force && typeof this.dualSideCache === 'boolean') return this.dualSideCache if (!this.cfg.apiKey || !this.cfg.apiSecret) return undefined try { const data = await this.hmacGet('/fapi/v3/positionSide/dual') const val = data && typeof data.dualSidePosition === 'boolean' ? data.dualSidePosition : undefined this.dualSideCache = val return val } catch { return undefined } } /** * 根据账户设置返回下单用 positionSide。 * - 双向:BUY -> LONG,SELL -> SHORT * - 单向或未知:BOTH */ async choosePositionSide(side: 'BUY' | 'SELL'): Promise<'BOTH' | 'LONG' | 'SHORT'> { const dual = await this.getDualSidePosition() if (dual === true) return side === 'BUY' ? 'LONG' : 'SHORT' return 'BOTH' } }