| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575 |
- 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<number | null> {
- // 使用 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<AsterQuoteResult> {
- // 简化:用标记价格/最新价作为预估成交价,并返回当前资金费率
- 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<AsterOrderResult> {
- // 使用 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<AsterOrderResult> {
- 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<string, string> }> {
- const business: Record<string, any> = {
- ...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<string, string> = {
- ...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<any> {
- 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<any> {
- 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<any> {
- 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<any> {
- // 需要 HMAC 鉴权
- const body = await this.hmacGet('/fapi/v3/leverage', { symbol, leverage })
- return body
- }
- async updateMarginMode(symbol: string, marginType: 'ISOLATED' | 'CROSSED'): Promise<any> {
- // 需要 HMAC 鉴权
- const body = await this.hmacGet('/fapi/v3/marginType', { symbol, marginType })
- return body
- }
- // === REST 下单封装(按照文档 /fapi/v3/order 签名流程) ===
- /**
- * 业务参数 -> 纯字符串字典(递归字符串化 list/dict),移除空值
- */
- private normalizeBusinessParams(input: Record<string, any>): Record<string, string> {
- const cleaned: Record<string, any> = {}
- 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<string, string> = {}
- for (const [k, v] of Object.entries(cleaned)) {
- out[k] = toStringValue(v)
- }
- return out
- }
- private deepStringify(obj: Record<string, any>): Record<string, string> {
- const out: Record<string, string> = {}
- 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, string>): 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<string> {
- 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<string, string> // 直接用于 x-www-form-urlencoded 的字段
- }> {
- const business: Record<string, any> = {
- ...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<string, string> = {
- ...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<string, string | number> = {},
- ): Promise<any> {
- // 对于 listenKey 等端点,使用 Aster 的 ECDSA 签名算法
- const business: Record<string, any> = {
- ...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<string, string> = {
- ...bizStrDict,
- nonce: String(nonce),
- user,
- signer,
- signature,
- }
- // Aster API 不需要 X-MBX-APIKEY header,所有认证通过签名参数
- const headers: Record<string, string> = {}
- 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<string, string | number> = {}): Promise<any> {
- return await this.hmacRequest('GET', path, extra)
- }
- // === 公共账户查询(HMAC) ===
- async getBalances(): Promise<any[]> {
- return await this.hmacGet('/fapi/v3/balance')
- }
- async getPositionRisk(symbol?: string): Promise<any[]> {
- const params: Record<string, string | number> = {}
- if (symbol) params.symbol = symbol
- return await this.hmacGet('/fapi/v3/positionRisk', params)
- }
- async getOpenOrders(symbol?: string): Promise<any[]> {
- const params: Record<string, string | number> = {}
- if (symbol) params.symbol = symbol
- return await this.hmacGet('/fapi/v3/openOrders', params)
- }
- // ===== User Stream (listenKey) =====
- /** 创建 listenKey 用于账户类 WS。返回 listenKey 字符串。*/
- async createListenKey(): Promise<string> {
- // 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<void> {
- 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<void> {
- // 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<boolean | undefined> {
- 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'
- }
- }
|