dsh-hyperliquid 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/remote.js CHANGED
@@ -11,7 +11,7 @@ import { resolve } from 'node:path'
11
11
  import { HyperliquidClient, keys, sigFigs } from './hl.js'
12
12
  import { canonicalMarketCoin } from './market-symbol.js'
13
13
  import { HyperStore } from './store.js'
14
- import { addressFromPrivateKey, buildL1TypedData, splitSignature } from './signing.js'
14
+ import { addressFromPrivateKey, buildL1TypedData, buildSendAsset, signTypedDataLocal, splitSignature } from './signing.js'
15
15
  import { TradingGuard } from './trading-guard.js'
16
16
  import { normalizeMaLines } from './moving-average.js'
17
17
  import { ALERT_KINDS, ICON_NAMES, TEXT_KINDS, drawingGeometry, drawingStyle, isIconLabel, linePriceAt } from './chart-drawings.js'
@@ -128,12 +128,16 @@ export class HyperRemote extends TypertRemoteService {
128
128
  if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
129
129
  this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
130
130
  this.store = new HyperStore(this.cfg.dataDir, this.cfg.network)
131
+ // Signing chosen on the desk wins over plugin config (set without a restart; see updateWalletSettings).
132
+ try { Object.assign(this.cfg, normalizeWalletSettings(this.store.walletSettings() ?? {}, this.cfg)) } catch { /* a bad saved file falls back to config */ }
131
133
  this.baseRiskSettings = Object.fromEntries(['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage'].map((key) => [key, this.cfg[key]]))
132
134
  const savedRiskSettings = this.store.getRiskSettings()
133
135
  this.riskSettingsOverride = savedRiskSettings != null
134
136
  if (savedRiskSettings) Object.assign(this.cfg, normalizeRiskSettings(savedRiskSettings))
135
137
  this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
136
138
  this.paperGuard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
139
+ // Custody: the desk setting wins; before the trader picks one, config.autonomousLive implies managed.
140
+ this.cfg.custody = this.store.custody() ?? (this.cfg.autonomousLive ? 'managed' : 'assisted')
137
141
  this.marketsCache = null
138
142
  this.marketsAt = 0
139
143
  this.catalogCache = null
@@ -146,12 +150,13 @@ export class HyperRemote extends TypertRemoteService {
146
150
  this.tradeCapacityCache = new Map()
147
151
  this.tradeCapacityPending = new Map()
148
152
  this.walletAddressCache = null
153
+ this.walletEpoch = 0 // bumped on every wallet switch; work that started under an older wallet is discarded
149
154
  this.desk = { coin: 'BTC', interval: '15m', sessionId: null } // active UI session and market focus
150
155
  this.lastTouch = new Map() // snapshot key -> last request ms
151
156
  this.activeTradeOps = 0
152
157
  // Hold the network stable through every order/cancel/leverage operation,
153
158
  // including wallet approval and the exchange response.
154
- for (const method of ['placeOrder', 'cancelOrder', 'setLeverage']) {
159
+ for (const method of ['placeOrder', 'cancelOrder', 'setLeverage', 'transferCollateral']) {
155
160
  const operation = this[method].bind(this)
156
161
  this[method] = async (...args) => {
157
162
  this.activeTradeOps += 1
@@ -162,7 +167,12 @@ export class HyperRemote extends TypertRemoteService {
162
167
  this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
163
168
  this.sweepTimer.unref?.()
164
169
  this.alertSides = new Map() // drawing id → last side of the mid price (-1 below, 1 above)
165
- this.alertTimer = setInterval(() => { try { this.checkDrawingAlerts() } catch { /* next tick retries */ } }, ALERT_CHECK_MS)
170
+ this.alertListeners = new Set() // (event) => void — the host wakes the session agent through this
171
+ this.dexMidCache = new Map() // HIP-3 dex → { at, mids }: core allMids does not carry builder-deployed markets
172
+ this.alertTimer = setInterval(() => {
173
+ try { this.checkDrawingAlerts() } catch { /* next tick retries */ }
174
+ void this.refreshAlertDexMids().catch(() => {})
175
+ }, ALERT_CHECK_MS)
166
176
  this.alertTimer.unref?.()
167
177
  }
168
178
 
@@ -294,8 +304,63 @@ export class HyperRemote extends TypertRemoteService {
294
304
  return this.client.exchangeSigned(action, signature, nonce)
295
305
  }
296
306
 
307
+ /**
308
+ * The exchange asset behind a tradable perp: core perps use their `meta` index; HIP-3 (builder-deployed)
309
+ * perps use 100000 + perpDexIndex * 10000 + index in that dex's meta (Hyperliquid "Asset IDs").
310
+ * Only USDC-collateral dexes are tradable here, since balances and limits are valued in USD.
311
+ * @returns {{ coin, asset, dex, szDecimals, maxLeverage, onlyIsolated }}
312
+ */
313
+ async tradeAsset(coin) {
314
+ if (!coin.includes(':')) {
315
+ const { universe } = await this.markets()
316
+ const index = universe.findIndex((row) => row.name === coin)
317
+ if (index < 0) throw new Error(`未知合约 ${coin};现货与 Outcome 市场目前只读`)
318
+ const row = universe[index]
319
+ return { coin, asset: index, dex: '', szDecimals: row.szDecimals, maxLeverage: row.maxLeverage, onlyIsolated: Boolean(row.onlyIsolated) }
320
+ }
321
+ const dex = coin.split(':')[0]
322
+ const client = this.client
323
+ const cached = this.dexAssets?.get(dex)
324
+ let entry = cached && Date.now() - cached.at < 60_000 && cached.network === this.cfg.network ? cached : null
325
+ if (!entry) {
326
+ const [dexs, meta] = await Promise.all([this.perpDexList(), client.info('meta', { dex })])
327
+ if (client !== this.client) throw new Error('网络已切换,请重新预检交易')
328
+ const dexIndex = dexs.findIndex((row) => row?.name === dex)
329
+ if (dexIndex < 1 || !Array.isArray(meta?.universe)) throw new Error(`未知的 HIP-3 交易所 ${dex}`)
330
+ entry = { at: Date.now(), network: this.cfg.network, dexIndex, collateralToken: meta.collateralToken, universe: meta.universe }
331
+ ;(this.dexAssets ??= new Map()).set(dex, entry)
332
+ }
333
+ if (entry.collateralToken !== 0) throw new Error(`${dex} 使用非 USDC 保证金(token ${entry.collateralToken}),暂只支持查看行情`)
334
+ const index = entry.universe.findIndex((row) => row.name === coin)
335
+ if (index < 0) throw new Error(`${dex} 上没有合约 ${coin}`)
336
+ const row = entry.universe[index]
337
+ if (row.isDelisted) throw new Error(`${coin} 已下架`)
338
+ return { coin, asset: 100000 + entry.dexIndex * 10000 + index, dex, szDecimals: row.szDecimals, maxLeverage: row.maxLeverage,
339
+ onlyIsolated: Boolean(row.onlyIsolated) || row.marginMode === 'strictIsolated' || row.marginMode === 'noCross' }
340
+ }
341
+
342
+ /** `perpDexs`: index 0 is the core dex (null), HIP-3 dexes follow in asset-id order. */
343
+ async perpDexList() {
344
+ if (this.perpDexCache && Date.now() - this.perpDexCache.at < 300_000 && this.perpDexCache.network === this.cfg.network) return this.perpDexCache.rows
345
+ const rows = await this.client.info('perpDexs')
346
+ if (!Array.isArray(rows)) throw new Error('无法读取 HIP-3 交易所列表')
347
+ this.perpDexCache = { at: Date.now(), network: this.cfg.network, rows }
348
+ return rows
349
+ }
350
+
351
+ /** Mids of one HIP-3 dex (core mids do not include builder-deployed markets). */
352
+ async dexMids(dex) {
353
+ const res = await this.client.info('allMids', { dex })
354
+ return res?.mids ?? res ?? {}
355
+ }
356
+
297
357
  /** Mid price for a perp coin: WS allMids first, REST fallback. */
298
358
  async midOf(coin) {
359
+ if (coin.includes(':')) {
360
+ const n = Number((await this.dexMids(coin.split(':')[0]))[coin])
361
+ if (Number.isFinite(n) && n > 0) return n
362
+ throw new Error(`无法获得 ${coin} 中间价`)
363
+ }
299
364
  this.touch(keys.allMids())
300
365
  const snap = this.client.snapshot(keys.allMids())
301
366
  if (snap?.data && Date.now() - snap.at < 5_000) {
@@ -361,9 +426,10 @@ export class HyperRemote extends TypertRemoteService {
361
426
  for (const [index, asset] of (meta?.universe ?? []).entries()) {
362
427
  const dex = asset.name.split(':')[0] || 'HIP-3'
363
428
  dexIndex.set(asset.name, { dex, index })
429
+ // USDC-collateral HIP-3 perps trade through the same order path (asset 100000 + dex·10000 + index).
364
430
  rows.push({ name: asset.name, label: asset.name, kind: 'hip3',
365
- venue: dex, tradable: false, ctx: contexts?.[index] ?? null,
366
- category: categories.get(asset.name) ?? null, maxLeverage: asset.maxLeverage })
431
+ venue: dex, tradable: meta?.collateralToken === 0 && !asset.isDelisted, ctx: contexts?.[index] ?? null,
432
+ category: categories.get(asset.name) ?? null, maxLeverage: asset.maxLeverage, szDecimals: asset.szDecimals })
367
433
  }
368
434
  }
369
435
  }
@@ -509,7 +575,7 @@ export class HyperRemote extends TypertRemoteService {
509
575
  const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
510
576
  if (patch?.alert === true) fields.alertTriggeredAt = undefined
511
577
  // null clears a style back to its default (undo of a first restyle relies on this).
512
- for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea']) if (key in patch && patch[key] === null) fields[key] = undefined
578
+ for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea', 'levels']) if (key in patch && patch[key] === null) fields[key] = undefined
513
579
  if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
514
580
  if ('label' in patch) {
515
581
  const label = String(patch.label ?? '').trim()
@@ -530,6 +596,15 @@ export class HyperRemote extends TypertRemoteService {
530
596
  * switches off, the time is recorded) and the desk gets a `chart.alert` event.
531
597
  * @param mids - coin → mid (defaults to the live allMids snapshot).
532
598
  */
599
+ /** Keep fresh mids for every HIP-3 dex that has an armed alert (polled with the alert check). */
600
+ async refreshAlertDexMids() {
601
+ const dexes = new Set((this.store.allChartMarks?.() ?? []).filter((mark) => mark.alert && String(mark.coin).includes(':')).map((mark) => mark.coin.split(':')[0]))
602
+ const client = this.client, network = this.cfg.network
603
+ const rows = await Promise.all([...dexes].map(async (dex) => [dex, { at: Date.now(), mids: await this.dexMids(dex) }]))
604
+ if (this.client !== client || this.cfg.network !== network) throw new Error('网络已切换,丢弃旧价格')
605
+ for (const [dex, value] of rows) this.dexMidCache.set(dex, value)
606
+ }
607
+
533
608
  checkDrawingAlerts(now = Date.now(), mids = null) {
534
609
  const watched = this.store.allChartMarks?.().filter((mark) => mark.alert && ALERT_KINDS.includes(mark.kind)) ?? []
535
610
  for (const id of [...this.alertSides.keys()]) if (!watched.some((mark) => mark.id === id)) this.alertSides.delete(id)
@@ -539,7 +614,8 @@ export class HyperRemote extends TypertRemoteService {
539
614
  const key = this.touch(keys.allMids())
540
615
  const snap = this.client.snapshot(key)
541
616
  if (!snap || now - snap.at > 10_000) return []
542
- book = snap.data?.mids ?? snap.data
617
+ book = { ...(snap.data?.mids ?? snap.data) }
618
+ for (const { at, mids: dexBook } of this.dexMidCache.values()) if (now - at <= 10_000) Object.assign(book, dexBook)
543
619
  }
544
620
  const fired = []
545
621
  for (const mark of watched) {
@@ -556,6 +632,8 @@ export class HyperRemote extends TypertRemoteService {
556
632
  price: mid, line, direction: side > 0 ? 'up' : 'down', at: now }
557
633
  this.store.recordEvent('chart.alert', event)
558
634
  fired.push({ ...event, mark: saved })
635
+ // The owning session's agent hears about it too (the host decides how, with its own guard rails).
636
+ for (const listener of this.alertListeners ?? []) { try { listener({ ...event, sessionId: mark.sessionId, mark: saved }) } catch { /* a listener must not stop the scan */ } }
559
637
  }
560
638
  return fired
561
639
  }
@@ -696,6 +774,9 @@ export class HyperRemote extends TypertRemoteService {
696
774
  this.cfg.network = network
697
775
  this.client = new HyperliquidClient({ network, privateKey: this.cfg.privateKey })
698
776
  this.lastTouch.clear()
777
+ this.dexMidCache?.clear()
778
+ this.alertSides?.clear()
779
+ this.lastLiveAccount = null
699
780
  this.marketsCache = null
700
781
  this.marketsAt = 0
701
782
  this.catalogCache = null
@@ -721,6 +802,7 @@ export class HyperRemote extends TypertRemoteService {
721
802
  if (mode === 'live' && request?.acknowledgeLive !== true) throw new Error('切换实盘需要确认')
722
803
  const previous = this.cfg.tradingMode
723
804
  this.cfg.tradingMode = mode
805
+ this.lastLiveAccount = null
724
806
  this.accountModeCache = null
725
807
  this.accountModePending = null
726
808
  this.tradeCapacityCache.clear()
@@ -746,6 +828,136 @@ export class HyperRemote extends TypertRemoteService {
746
828
  return { riskLimits: { ...this.baseRiskSettings, usedTodayUsd: this.usedTodayUsd() } }
747
829
  }
748
830
 
831
+ /** Desk only: the dsh-web3 accounts the trader can sign with (no secrets, just id / label / address). */
832
+ async walletAccounts() {
833
+ const wallet = this.walletService()
834
+ if (!wallet) return { available: false, accounts: [] }
835
+ const accounts = typeof wallet.listAccounts === 'function' ? wallet.listAccounts() : []
836
+ return { available: true, accounts: accounts.filter((row) => row?.address).map((row) => ({ id: row.id, label: row.label ?? row.id, address: row.address })) }
837
+ }
838
+
839
+ /**
840
+ * Desk only (never an agent tool): change signer / web3 account / trading address at runtime. The values
841
+ * are validated, saved, and every cached account read is dropped, so the next status and account reads
842
+ * use the new wallet — no host restart. The private key stays in plugin config.
843
+ */
844
+ async updateWalletSettings(request) {
845
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,请稍后再修改钱包设置')
846
+ const next = normalizeWalletSettings(request ?? {}, this.cfg)
847
+ if (next.signer === 'web3') {
848
+ const wallet = this.walletService()
849
+ if (!wallet) throw new Error('dsh-web3 未安装或未启用:请先在 DSH 中启用 Web3 钱包插件')
850
+ const accounts = typeof wallet.listAccounts === 'function' ? wallet.listAccounts() : []
851
+ if (!accounts.some((row) => row?.id === next.web3Account || row?.label === next.web3Account)) {
852
+ throw new Error(`dsh-web3 中找不到账户「${next.web3Account}」。如果它刚在另一个进程里创建,当前宿主的钱包列表还没刷新,需要重启 DSH`)
853
+ }
854
+ }
855
+ if (next.signer === 'local' && !this.cfg.privateKey) throw new Error('本地签名需要在插件配置里填写私钥')
856
+ if (next.signer === 'web3' && !next.tradingAddress) throw new Error('使用 dsh-web3 签名时需要填写 Hyperliquid 主账户地址')
857
+ Object.assign(this.cfg, next)
858
+ this.walletEpoch += 1
859
+ this.store.saveWalletSettings(next)
860
+ this.addressCache = null; this.walletAddressCache = null
861
+ this.accountModeCache = null; this.accountModePending = null
862
+ this.tradeCapacityCache.clear(); this.tradeCapacityPending.clear()
863
+ this.lastLiveAccount = null
864
+ this.guard.plans.clear()
865
+ this.store.recordEvent('wallet.changed', { signer: next.signer, web3Account: next.web3Account, tradingAddress: next.tradingAddress })
866
+ return { signer: next.signer, web3Account: next.web3Account, tradingAddress: next.tradingAddress, address: await this.resolveAddress() }
867
+ }
868
+
869
+ /** 全托管: the agent may act without per-action approval (the dsh-web3 wallet policy still applies). */
870
+ isManaged() { return this.cfg.custody ? this.cfg.custody === 'managed' : this.cfg.autonomousLive === true }
871
+
872
+ /** Desk only (never an agent tool): the trader picks 全托管 / 半托管. */
873
+ async setCustody(request) {
874
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能切换托管模式')
875
+ const mode = request?.mode
876
+ if (mode !== 'managed' && mode !== 'assisted') throw new Error('托管模式必须为 managed(全托管)或 assisted(半托管)')
877
+ if (mode === 'managed' && this.cfg.tradingMode === 'live' && request?.acknowledge !== true) throw new Error('开启全托管实盘需要确认')
878
+ this.cfg.custody = this.store.saveCustody(mode)
879
+ this.guard.plans.clear()
880
+ this.store.recordEvent('custody.changed', { mode })
881
+ return { custody: mode }
882
+ }
883
+
884
+ /**
885
+ * Agent edit of the trading limits — 全托管 only; in 半托管 the trader changes them on the desk.
886
+ * `addCoins` / `removeCoins` adjust the allow-list; numeric limits replace the current value.
887
+ */
888
+ agentUpdateRiskSettings(request) {
889
+ if (!this.isManaged()) throw new Error('当前为半托管:交易限额与允许币对只能由你在交易台 设置 → 交易限额与签名规则 中修改;如需让 agent 自行调整,请在 设置 → 托管模式 切换为全托管')
890
+ const current = { allowedCoins: [...this.cfg.allowedCoins], maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage }
891
+ const add = (request?.addCoins ?? []).map((coin) => canonicalMarketCoin(coin))
892
+ const drop = new Set((request?.removeCoins ?? []).map((coin) => canonicalMarketCoin(coin)))
893
+ const next = { ...current, allowedCoins: [...new Set([...current.allowedCoins.filter((coin) => !drop.has(coin)), ...add])] }
894
+ for (const key of ['maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage']) if (request?.[key] != null) next[key] = request[key]
895
+ if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
896
+ const limits = normalizeRiskSettings(next)
897
+ this.store.saveRiskSettings(limits)
898
+ this.applyRiskSettings(limits, true)
899
+ this.store.recordEvent('risk.changed', { by: 'agent', before: current, after: limits })
900
+ return { before: current, after: limits }
901
+ }
902
+
903
+ /**
904
+ * Move USDC between the core perp account ('') and a HIP-3 dex (e.g. 'xyz') with a user-signed
905
+ * `sendAsset` (main wallet). 半托管 asks the trader every time; 全托管 goes straight to the wallet.
906
+ */
907
+ async transferCollateral(request, exec) {
908
+ if (this.cfg.tradingMode !== 'live') throw new Error('paper 模式没有交易所资金,无需划转')
909
+ const transferEpoch = this.walletEpoch
910
+ if (!exec?.agent) throw new Error('资金划转仅允许从 DSH agent 工具调用')
911
+ exec.signal?.throwIfAborted()
912
+ const dex = String(request?.dex ?? '').trim()
913
+ const toDex = request?.direction !== 'out'
914
+ if (!dex || dex.includes(':')) throw new Error('dex 需要 HIP-3 交易所名,如 xyz')
915
+ const dexs = await this.perpDexList()
916
+ if (!dexs.some((row) => row?.name === dex)) throw new Error(`未知的 HIP-3 交易所 ${dex}`)
917
+ const meta = await this.client.info('meta', { dex })
918
+ if (meta?.collateralToken !== 0) throw new Error(`${dex} 不使用 USDC 保证金,暂不支持划转`)
919
+ const amount = Number(request?.amount)
920
+ if (!Number.isFinite(amount) || amount <= 0) throw new Error('amount 必须为正数(USDC)')
921
+ if (Number(amount.toFixed(6)) !== amount) throw new Error('USDC 划转金额最多 6 位小数')
922
+ if (amount > this.cfg.maxPositionUsd) throw new Error(`单次划转不超过仓位上限 ${this.cfg.maxPositionUsd} USD`)
923
+ const address = await this.resolveAddress()
924
+ if (!address) throw new Error('live 划转需要钱包地址')
925
+ const signerAddress = this.cfg.signer === 'web3' ? await this.web3Address() : await addressFromPrivateKey(this.cfg.privateKey)
926
+ if (signerAddress.toLowerCase() !== address.toLowerCase()) throw new Error('保证金划转需要交易主钱包签名;当前签名钱包与 tradingAddress 不一致,API wallet 不能用于此划转')
927
+ const source = toDex ? '' : dex
928
+ const sourceState = source ? await this.client.info('clearinghouseState', { user: address, dex: source }) : await this.client.clearinghouseState(address)
929
+ const withdrawable = Number(sourceState?.withdrawable)
930
+ if (!Number.isFinite(withdrawable)) throw new Error('无法确认可划转余额')
931
+ if (amount > withdrawable + 1e-9) throw new Error(`${source || '主永续账户'} 可划转 ${withdrawable.toFixed(2)} USDC,不足 ${amount}`)
932
+ const spotMeta = await this.client.info('spotMeta')
933
+ const usdc = (spotMeta?.tokens ?? []).find((token) => token.index === 0 && token.name === 'USDC')
934
+ if (!usdc?.tokenId) throw new Error('无法读取 USDC 代币信息')
935
+ const what = `${amount} USDC:${toDex ? `主永续账户 → ${dex}` : `${dex} → 主永续账户`}`
936
+ if (!this.isManaged()) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 划转 ${what}?`, exec, `Hyperliquid ${this.cfg.network}: transfer ${amount} USDC ${toDex ? `core perps → ${dex}` : `${dex} → core perps`}?`)
937
+ if (transferEpoch !== this.walletEpoch) throw new Error('钱包已切换,划转已取消')
938
+ const nonce = this.client.nextNonce()
939
+ const { action, typed } = buildSendAsset({ destination: address, sourceDex: source, destinationDex: toDex ? dex : '', token: `USDC:${usdc.tokenId}`,
940
+ amount: String(Number(amount.toFixed(6))), nonce, isMainnet: this.cfg.network === 'mainnet' })
941
+ let signature
942
+ if (this.cfg.signer === 'web3') {
943
+ const wallet = this.walletService()
944
+ if (!wallet) throw new Error('signer=web3 但 dsh-web3 未安装')
945
+ const result = await wallet.signTypedData({ account: this.cfg.web3Account, ...typed, purpose: `Hyperliquid ${this.cfg.network} 资金划转:${what}` },
946
+ this.approvalGateway(), { toolName: 'dsh-hyperliquid', agent: exec?.agent, callId: exec?.callId, signal: exec?.signal })
947
+ if (result?.status !== 'signed' || !result.signature) throw new Error(`dsh-web3 签名未完成(status=${result?.status})`)
948
+ if (result.address?.toLowerCase() !== signerAddress.toLowerCase()) throw new Error('dsh-web3 返回的签名地址与配置账户不一致')
949
+ signature = splitSignature(result.signature)
950
+ } else {
951
+ if (!this.cfg.privateKey) throw new Error('live 划转需要钱包私钥')
952
+ signature = await signTypedDataLocal(this.cfg.privateKey, typed)
953
+ }
954
+ exec.signal?.throwIfAborted()
955
+ const res = await this.client.exchangeUserSigned(action, signature, nonce)
956
+ assertExchangeAccepted(res)
957
+ this.store.recordEvent('live.transfer', { dex, amount, toDex })
958
+ return { ok: true, dex, amount, direction: toDex ? 'in' : 'out', response: res }
959
+ }
960
+
749
961
  applyRiskSettings(limits, override) {
750
962
  Object.assign(this.cfg, limits)
751
963
  Object.assign(this.guard.limits, limits)
@@ -771,7 +983,10 @@ export class HyperRemote extends TypertRemoteService {
771
983
  network: this.cfg.network,
772
984
  tradingMode: this.cfg.tradingMode,
773
985
  signer: this.cfg.signer,
774
- autonomousLive: this.cfg.autonomousLive,
986
+ web3Account: this.cfg.web3Account,
987
+ tradingAddress: this.cfg.tradingAddress,
988
+ autonomousLive: this.isManaged(),
989
+ custody: this.cfg.custody,
775
990
  riskSettingsSource: this.riskSettingsOverride ? 'desk' : 'plugin',
776
991
  riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
777
992
  // Day budget of the ledger that applies to the current mode (paper and live are separate).
@@ -872,7 +1087,7 @@ export class HyperRemote extends TypertRemoteService {
872
1087
  async account(request) {
873
1088
  if (this.cfg.tradingMode === 'paper') {
874
1089
  const paper = this.store.paperAccount()
875
- const mids = await safeMids(this)
1090
+ const mids = await safeMids(this, Object.keys(paper.positions ?? {}))
876
1091
  const risk = paperRiskState(paper, mids)
877
1092
  return { mode: 'paper', usd: paper.usd, equity: risk.equity, uPnl: risk.uPnl,
878
1093
  marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
@@ -880,25 +1095,27 @@ export class HyperRemote extends TypertRemoteService {
880
1095
  marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
881
1096
  simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
882
1097
  }
1098
+ const client = this.client, network = this.cfg.network
1099
+ const walletEpoch = this.walletEpoch
883
1100
  const address = await this.resolveAddress()
884
- if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
1101
+ if (!address) throw new Error('live 模式需要钱包:在交易台 设置 → 交易限额与签名规则 → 签名与托管 中连接')
885
1102
  const accountMode = await this.accountMode(address)
886
1103
  const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
887
1104
  const activeCoin = canonicalMarketCoin(request?.coin ?? this.desk?.coin)
888
1105
  const accountDex = activeCoin.includes(':') ? activeCoin.split(':')[0] : ''
889
1106
  const stateKey = this.touch(keys.allDexsClearinghouseState(address))
890
- const wsState = this.client.snapshot(stateKey)
1107
+ const wsState = client.snapshot(stateKey)
891
1108
  const liveStates = wsState && Date.now() - wsState.at < 10_000 &&
892
1109
  wsState.data?.user?.toLowerCase() === address.toLowerCase() && Array.isArray(wsState.data?.clearinghouseStates)
893
1110
  ? new Map(wsState.data.clearinghouseStates.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && entry[1] && Array.isArray(entry[1].assetPositions)))
894
1111
  : null
895
1112
  const [state, open, markets, spot] = await Promise.all([
896
1113
  liveStates?.get(accountDex) ?? (accountDex
897
- ? this.client.info('clearinghouseState', { user: address, dex: accountDex })
898
- : this.client.clearinghouseState(address)),
899
- this.client.frontendOpenOrders(address),
1114
+ ? client.info('clearinghouseState', { user: address, dex: accountDex })
1115
+ : client.clearinghouseState(address)),
1116
+ client.frontendOpenOrders(address),
900
1117
  this.markets().catch(() => null),
901
- sharedBalance ? this.client.spotClearinghouseState(address) : Promise.resolve(null),
1118
+ sharedBalance ? client.spotClearinghouseState(address) : Promise.resolve(null),
902
1119
  ])
903
1120
  if (!state || !Array.isArray(state.assetPositions) || (!sharedBalance && (!state.marginSummary || !Number.isFinite(Number(state.marginSummary.accountValue))))) {
904
1121
  throw new Error('交易所永续账户响应无效;账户状态未确认')
@@ -931,7 +1148,7 @@ export class HyperRemote extends TypertRemoteService {
931
1148
  maxLeverage: row.position?.maxLeverage ?? null,
932
1149
  leverage: row.position?.leverage ?? null,
933
1150
  })))
934
- return {
1151
+ const liveAccount = {
935
1152
  mode: 'live',
936
1153
  address,
937
1154
  accountMode,
@@ -948,6 +1165,12 @@ export class HyperRemote extends TypertRemoteService {
948
1165
  positions: assetPositions,
949
1166
  openOrders: open,
950
1167
  }
1168
+ // The system prompt reads this (synchronously) to warn about unprotected positions.
1169
+ if (this.client !== client || this.cfg.network !== network || this.cfg.tradingMode !== 'live') throw new Error('账户环境已切换,请重新读取')
1170
+ // A response that started under the previous wallet must not land in the new wallet's view.
1171
+ if (walletEpoch !== this.walletEpoch) throw new Error('钱包已切换,账户数据已过期')
1172
+ this.lastLiveAccount = { at: Date.now(), network, value: liveAccount }
1173
+ return liveAccount
951
1174
  }
952
1175
 
953
1176
  /** Open orders (live mode; paper has none resting). */
@@ -1114,7 +1337,11 @@ export class HyperRemote extends TypertRemoteService {
1114
1337
  const thesis = String(request?.thesis ?? '').trim()
1115
1338
  if (thesis.length < 10 || thesis.length > 1000) throw new Error('交易决策需要 10-1000 字的依据')
1116
1339
  const order = orderOf(request)
1117
- const preview = await this.checkLiveOrder(order)
1340
+ const epoch = this.walletEpoch
1341
+ const checked = await this.checkLiveOrder(order)
1342
+ // A wallet switch while the pre-check was in flight voids it: the decision belongs to the old wallet.
1343
+ if (epoch !== this.walletEpoch) throw new Error('钱包已切换,请重新预检')
1344
+ const preview = { ...checked, walletEpoch: epoch }
1118
1345
  const decision = this.guard.create(order, sessionId, preview)
1119
1346
  this.store.recordLiveDecision(decision.decisionId, { sessionId, order, thesis, preview, expiresAt: decision.expiresAt })
1120
1347
  return decision
@@ -1129,19 +1356,37 @@ export class HyperRemote extends TypertRemoteService {
1129
1356
  const address = await this.resolveAddress()
1130
1357
  if (!address) throw new Error('live 交易需要有效的 tradingAddress 或本地钱包')
1131
1358
  if (this.cfg.signer === 'web3') await this.web3Address()
1132
- const [market, mids, state, accountMode] = await Promise.all([
1133
- this.markets(), client.allMids(), client.clearinghouseState(address), this.accountMode(address, true),
1359
+ const asset = await this.tradeAsset(order.coin)
1360
+ // A HIP-3 dex has its own clearinghouse: margin, positions and withdrawable live there.
1361
+ const [mids, state, accountMode] = await Promise.all([
1362
+ asset.dex ? this.dexMids(asset.dex) : client.allMids(),
1363
+ asset.dex ? client.info('clearinghouseState', { user: address, dex: asset.dex }) : client.clearinghouseState(address),
1364
+ this.accountMode(address, true),
1134
1365
  ])
1135
1366
  if (!Array.isArray(state?.assetPositions)) throw new Error('交易所持仓状态未确认,拒绝 live 下单')
1136
1367
  const sharedBalance = accountMode === 'unifiedAccount' || accountMode === 'portfolioMargin'
1137
- const asset = market.universe.find((u) => u.name === order.coin)
1138
- if (!asset) throw new Error(`未知合约 ${order.coin}`)
1368
+ let positionStates = [state]
1369
+ if (sharedBalance) {
1370
+ // Shared USDC backs every perp venue. Read fresh positions rather than
1371
+ // the UI's partial/cached snapshot; Standard only risks its own dex equity.
1372
+ const [dexs, metas] = await Promise.all([client.info('perpDexs'), accountMode === 'unifiedAccount' ? client.info('allPerpMetas') : Promise.resolve(null)])
1373
+ if (!Array.isArray(dexs) || dexs.some((dex) => dex !== null && (typeof dex?.name !== 'string' || !dex.name))) throw new Error('交易所持仓状态未确认:无法读取交易所列表')
1374
+ if (metas && (!Array.isArray(metas) || metas.length !== dexs.length || metas.some((meta) => !Number.isInteger(meta?.collateralToken)))) throw new Error('交易所持仓状态未确认:无法核对保证金资产')
1375
+ if (accountMode === 'unifiedAccount' && !metas) throw new Error('交易所持仓状态未确认:无法核对保证金资产')
1376
+ // Unified pools are per collateral token; USDH/USDT dexes do not spend
1377
+ // USDC. Portfolio mode remains conservative across its eligible assets.
1378
+ const pooledDexs = dexs.filter((dex, index) => dex && (!metas || metas[index].collateralToken === 0))
1379
+ const names = [...new Set(['', asset.dex ?? '', ...pooledDexs.map((dex) => dex.name)])]
1380
+ positionStates = await Promise.all(names.map((dex) => dex === (asset.dex ?? '') ? state
1381
+ : dex ? client.info('clearinghouseState', { user: address, dex }) : client.clearinghouseState(address)))
1382
+ if (positionStates.some((value) => !Array.isArray(value?.assetPositions))) throw new Error('交易所持仓状态未确认,拒绝 live 下单')
1383
+ }
1139
1384
  const mid = Number(mids?.mids?.[order.coin] ?? mids?.[order.coin])
1140
1385
  if (!Number.isFinite(mid) || mid <= 0) throw new Error('无法获取实时价格,拒绝 live 下单')
1141
1386
  validateProtection(order, mid)
1142
1387
  if (Number(wireSz(order.sz, asset.szDecimals)) !== order.sz) throw new Error(`数量不符合 ${order.coin} 的精度 ${asset.szDecimals}`)
1143
1388
  const row = (state?.assetPositions ?? []).find((p) => p.position?.coin === order.coin)?.position
1144
- const otherExposureUsd = (state?.assetPositions ?? []).reduce((sum, p) => {
1389
+ const otherExposureUsd = positionStates.flatMap((value) => value.assetPositions).reduce((sum, p) => {
1145
1390
  if (p.position?.coin === order.coin) return sum
1146
1391
  const value = Number(p.position?.positionValue)
1147
1392
  if (!Number.isFinite(value)) throw new Error('无法读取其他仓位敞口')
@@ -1161,10 +1406,13 @@ export class HyperRemote extends TypertRemoteService {
1161
1406
  const sideCapacity = Math.min(...capacity.maxTradeSzs.map(Number))
1162
1407
  if (!order.reduceOnly && order.sz > sideCapacity + 1e-10) throw new Error('订单数量超过交易所当前可交易额度')
1163
1408
  }
1409
+ if (asset.dex && !sharedBalance && !order.reduceOnly && !(accountValue > 0)) {
1410
+ throw new Error(`${asset.dex} 是独立保证金的 HIP-3 交易所,账户里没有 USDC。请在 Hyperliquid 网页把 USDC 从主永续账户划转到 ${asset.dex},或开启统一账户(Unified Account),之后 HIP-3 下单会自动调用主账户余额`)
1411
+ }
1164
1412
  const riskPrice = order.type === 'limit' ? Math.max(mid * 1.05, order.px) : mid * 1.05
1165
1413
  const checked = this.guard.validate({ ...order, price: riskPrice, positionSize, otherExposureUsd, accountValue, leverage })
1166
1414
  if (client !== this.client || network !== this.cfg.network) throw new Error('网络已切换,请重新预检交易')
1167
- return { coin: order.coin, side: order.isBuy ? 'buy' : 'sell', mid, riskPrice, accountValue, accountMode, positionSize, otherExposureUsd, leverage, ...checked, network, address }
1415
+ return { coin: order.coin, dex: asset.dex, side: order.isBuy ? 'buy' : 'sell', mid, riskPrice, accountValue, accountMode, positionSize, otherExposureUsd, leverage, ...checked, network, address }
1168
1416
  }
1169
1417
 
1170
1418
  async executeToolOrder(request, exec, sessionId) {
@@ -1201,8 +1449,7 @@ export class HyperRemote extends TypertRemoteService {
1201
1449
  const reduceOnly = Boolean(request?.reduceOnly)
1202
1450
  if (this.cfg.tradingMode === 'paper') {
1203
1451
  if (request?.takeProfitPx != null || request?.stopLossPx != null) throw new Error('paper 暂不模拟止盈止损触发单;请移除保护价格或切换 testnet live')
1204
- const { universe } = await this.markets()
1205
- if (!universe.some((row) => row.name === coin)) throw new Error(`当前仅支持核心永续合约模拟交易:${coin}`)
1452
+ await this.tradeAsset(coin)
1206
1453
  const mid = await this.midOf(coin)
1207
1454
  const pos = this.store.paper.positions[coin]?.size ?? 0
1208
1455
  if (reduceOnly && (pos === 0 || Math.sign(isBuy ? sz : -sz) === Math.sign(pos) || sz > Math.abs(pos) + 1e-10)) throw new Error('只减仓订单会增加或翻转仓位')
@@ -1217,7 +1464,7 @@ export class HyperRemote extends TypertRemoteService {
1217
1464
  const px = mid * (isBuy ? 1 + pct : 1 - pct)
1218
1465
  if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
1219
1466
  const paper = this.store.paperAccount()
1220
- const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: mid }, true)
1467
+ const risk = paperRiskState(paper, { ...await safeMids(this, Object.keys(paper.positions)), [coin]: mid }, true)
1221
1468
  const positionSize = Number(paper.positions[coin]?.size ?? 0)
1222
1469
  const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
1223
1470
  const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
@@ -1244,16 +1491,17 @@ export class HyperRemote extends TypertRemoteService {
1244
1491
  const orderSpec = orderOf(request)
1245
1492
  const preview = this.guard.consume(request?.decisionId, orderSpec, trusted.sessionId)
1246
1493
  if (preview.network !== this.cfg.network) throw new Error('交易决策的网络与当前网络不一致,请重新预检')
1494
+ if (preview.walletEpoch !== this.walletEpoch) throw new Error('交易决策来自切换前的钱包,请重新预检')
1247
1495
  const fresh = await this.checkLiveOrder(orderSpec)
1496
+ if (String(fresh.address ?? '').toLowerCase() !== String(preview.address ?? '').toLowerCase() || preview.walletEpoch !== this.walletEpoch) {
1497
+ throw new Error('交易账户与预检时不一致,请重新预检')
1498
+ }
1248
1499
  if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
1249
- if (!this.cfg.autonomousLive) {
1500
+ if (!this.isManaged()) {
1250
1501
  await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec,
1251
1502
  `Hyperliquid ${this.cfg.network}: ${isBuy ? 'buy' : 'sell'} ${sz} ${coin}, estimated notional ${fresh.notionalUsd.toFixed(2)} USD. Allow this trade?`)
1252
1503
  }
1253
- const { universe } = await this.markets()
1254
- const idx = universe.findIndex((u) => u.name === coin)
1255
- if (idx < 0) throw new Error(`未知合约 ${coin}`)
1256
- const szDecimals = universe[idx].szDecimals
1504
+ const { asset: idx, szDecimals } = await this.tradeAsset(coin)
1257
1505
  const tif = request?.type === 'limit' ? (request?.tif === 'Alo' ? 'Alo' : 'Gtc') : 'Ioc'
1258
1506
  const mid = await this.midOf(coin)
1259
1507
  let pxNum
@@ -1327,10 +1575,8 @@ export class HyperRemote extends TypertRemoteService {
1327
1575
  if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
1328
1576
  const exec = request?.[TOOL_CALL]?.exec
1329
1577
  if (!exec?.agent) throw new Error('live 撤单仅允许从 DSH agent 工具调用')
1330
- if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec, `Hyperliquid ${this.cfg.network}: cancel ${coin} order oid=${oid}?`)
1331
- const { universe } = await this.markets()
1332
- const idx = universe.findIndex((u) => u.name === coin)
1333
- if (idx < 0) throw new Error(`未知合约 ${coin}`)
1578
+ if (!this.isManaged() || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec, `Hyperliquid ${this.cfg.network}: cancel ${coin} order oid=${oid}?`)
1579
+ const { asset: idx } = await this.tradeAsset(coin)
1334
1580
  const res = await this.submitAction({ type: 'cancel', cancels: [{ a: idx, o: oid }] }, exec)
1335
1581
  assertExchangeAccepted(res)
1336
1582
  this.store.recordEvent('live.cancel', { coin, oid })
@@ -1341,17 +1587,17 @@ export class HyperRemote extends TypertRemoteService {
1341
1587
  const coin = requireCoin(request)
1342
1588
  const leverage = Number(request?.leverage)
1343
1589
  if (!Number.isInteger(leverage) || leverage < 1 || leverage > 50) throw new Error('leverage 需要是 1-50 的整数')
1344
- const { universe } = await this.markets()
1345
- const idx = universe.findIndex((u) => u.name === coin)
1346
- if (idx < 0) throw new Error(`未知合约 ${coin}`)
1347
- if (leverage > Number(universe[idx].maxLeverage ?? 50)) throw new Error(`杠杆超过 ${coin} 市场上限 ${universe[idx].maxLeverage}x`)
1348
- const isCross = request?.marginMode !== 'isolated'
1590
+ const market = await this.tradeAsset(coin)
1591
+ const idx = market.asset
1592
+ if (leverage > Number(market.maxLeverage ?? 50)) throw new Error(`杠杆超过 ${coin} 市场上限 ${market.maxLeverage}x`)
1593
+ if (market.onlyIsolated && request?.marginMode === 'cross') throw new Error(`${coin} 只支持逐仓`)
1594
+ const isCross = request?.marginMode !== 'isolated' && !market.onlyIsolated
1349
1595
  if (this.cfg.tradingMode === 'paper') {
1350
1596
  if (leverage > this.cfg.maxLeverage) throw new Error(`杠杆超过配置上限 ${this.cfg.maxLeverage}x`)
1351
1597
  if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`${coin} 不在允许交易币对内`)
1352
1598
  const paper = this.store.paperAccount()
1353
1599
  if (paper.positions[coin]) {
1354
- const risk = paperRiskState(paper, await safeMids(this), true)
1600
+ const risk = paperRiskState(paper, await safeMids(this, Object.keys(paper.positions)), true)
1355
1601
  const pos = paper.positions[coin]
1356
1602
  const marked = risk.positions.find((row) => row.coin === coin)
1357
1603
  const currentMargin = Math.abs(pos.size) * marked.markPx / marked.leverage
@@ -1365,7 +1611,7 @@ export class HyperRemote extends TypertRemoteService {
1365
1611
  if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
1366
1612
  const exec = request?.[TOOL_CALL]?.exec
1367
1613
  if (!exec?.agent) throw new Error('live 杠杆变更仅允许从 DSH agent 工具调用')
1368
- if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec, `Hyperliquid ${this.cfg.network}: set ${coin} leverage to ${leverage}x?`)
1614
+ if (!this.isManaged()) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec, `Hyperliquid ${this.cfg.network}: set ${coin} leverage to ${leverage}x?`)
1369
1615
  const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
1370
1616
  assertExchangeAccepted(res)
1371
1617
  this.store.recordEvent('live.leverage', { coin, leverage, isCross })
@@ -1489,6 +1735,17 @@ function expandHome(p) {
1489
1735
  return resolve(p)
1490
1736
  }
1491
1737
 
1738
+ /** Validate desk wallet settings; missing fields keep the current value. */
1739
+ export function normalizeWalletSettings(input, current = {}) {
1740
+ const signer = input.signer ?? current.signer ?? 'local'
1741
+ if (signer !== 'local' && signer !== 'web3') throw new Error('签名方式必须为 local 或 web3')
1742
+ const web3Account = String(input.web3Account ?? current.web3Account ?? 'main').trim()
1743
+ if (!web3Account || web3Account.length > 80) throw new Error('dsh-web3 账户名无效')
1744
+ const tradingAddress = String(input.tradingAddress ?? current.tradingAddress ?? '').trim()
1745
+ if (tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(tradingAddress)) throw new Error('主账户地址必须是 0x 开头的 40 位十六进制地址')
1746
+ return { signer, web3Account, tradingAddress }
1747
+ }
1748
+
1492
1749
  function defaultSpanMs(interval) {
1493
1750
  const spans = { '1m': 6 * 3600e3, '3m': 18 * 3600e3, '5m': 24 * 3600e3, '15m': 3 * 86400e3, '30m': 6 * 86400e3, '1h': 7 * 86400e3, '2h': 14 * 86400e3, '4h': 30 * 86400e3, '8h': 60 * 86400e3, '12h': 90 * 86400e3, '1d': 180 * 86400e3, '3d': 540 * 86400e3, '1w': 1095 * 86400e3, '1M': 5475 * 86400e3 }
1494
1751
  return spans[interval] ?? 7 * 86400e3
@@ -1516,15 +1773,17 @@ function argsOf(key) {
1516
1773
  throw new Error(`unknown snapshot key: ${key}`)
1517
1774
  }
1518
1775
 
1519
- async function safeMids(remote) {
1520
- const cached = remote.client.snapshot(keys.allMids())
1521
- if (cached?.data && Date.now() - cached.at < 5_000) return cached.data.mids ?? cached.data
1522
- try {
1776
+ async function safeMids(remote, coins = []) {
1777
+ const core = async () => {
1778
+ const cached = remote.client.snapshot(keys.allMids())
1779
+ if (cached?.data && Date.now() - cached.at < 5_000) return cached.data.mids ?? cached.data
1523
1780
  const res = await remote.client.allMids()
1524
1781
  return res?.mids ?? res ?? {}
1525
- } catch {
1526
- return {}
1527
1782
  }
1783
+ // HIP-3 positions are priced from their own dex's mids.
1784
+ const dexes = [...new Set(coins.filter((coin) => coin.includes(':')).map((coin) => coin.split(':')[0]))]
1785
+ const parts = await Promise.allSettled([core(), ...dexes.map((dex) => remote.dexMids(dex))])
1786
+ return Object.assign({}, ...parts.map((part) => part.status === 'fulfilled' ? part.value : {}))
1528
1787
  }
1529
1788
 
1530
1789
  /** Mark every paper position before computing equity and occupied margin. */
@@ -1557,7 +1816,7 @@ function paperRiskState(paper, mids, strict = false) {
1557
1816
 
1558
1817
  // Remote markers: every method the web client may call.
1559
1818
  for (const m of [
1560
- 'indicatorTemplates', 'updatePaperCosts', 'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1819
+ 'indicatorTemplates', 'updatePaperCosts', 'status', 'setCustody', 'walletAccounts', 'updateWalletSettings', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
1561
1820
  'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
1562
1821
  'eventsSince',
1563
1822
  ]) {
@@ -1,3 +1,4 @@
1
+ import { canonicalMarketCoin } from './market-symbol.js'
1
2
  /** Validate the user-editable trading limits before they reach either guard. */
2
3
  export function normalizeRiskSettings(input) {
3
4
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('交易设置无效')
@@ -5,7 +6,7 @@ export function normalizeRiskSettings(input) {
5
6
  if (Object.keys(input).some((key) => !allowed.includes(key)) || allowed.some((key) => !(key in input))) throw new Error('交易设置字段无效')
6
7
  const coins = typeof input.allowedCoins === 'string' ? input.allowedCoins.split(',') : input.allowedCoins
7
8
  if (!Array.isArray(coins)) throw new Error('允许交易的币对无效')
8
- const allowedCoins = [...new Set(coins.map((coin) => String(coin).trim()).filter(Boolean))]
9
+ const allowedCoins = [...new Set(coins.map((coin) => canonicalMarketCoin(coin)).filter(Boolean))]
9
10
  if (!allowedCoins.length || allowedCoins.length > 100 || allowedCoins.some((coin) => coin.length > 32 || !/^[A-Za-z0-9:_-]+$/.test(coin))) {
10
11
  throw new Error('允许交易的币对无效')
11
12
  }