From d6c6ef3491d74510bbdf12eb8a5c5cb46309758a Mon Sep 17 00:00:00 2001 From: Zen Date: Tue, 14 Jul 2026 10:07:55 +0800 Subject: [PATCH 01/24] feat: use fast l2 order book subscriptions Apply the validated Fast L2 order-book path to the v6.5.0 bundle baseline while preserving target-scoped fallback, precision identity, and serialized subscription reconciliation.\n\nConstraint: the hot-update bundle must remain compatible with the v6.5.0 shell baseline and exclude temporary diagnostic logging.\nRejected: cherry-picking only the initial feature commit | later switching and precision fixes are required for stable mobile behavior.\nConfidence: high\nScope-risk: moderate\nDirective: keep order-book source precision attached across bg cache and main rendering boundaries.\nTested: 44 targeted Jest tests and yarn agent:check --profile commit.\nNot-tested: GitHub Actions bundle build and native hot-update smoke. --- packages/kit-bg/package.json | 1 + .../ServiceHyperliquidCache.test.ts | 15 + .../ServiceHyperliquidCache.ts | 18 +- .../ServiceHyperliquidSubscription.ts | 339 +++++++++++++++- .../utils/FastL2Book.test.ts | 161 ++++++++ .../ServiceHyperLiquid/utils/FastL2Book.ts | 361 ++++++++++++++++++ .../utils/SubscriptionConfig.test.ts | 97 ++++- .../utils/SubscriptionConfig.ts | 78 +++- .../utils/SubscriptionReconcileQueue.test.ts | 32 ++ .../utils/SubscriptionReconcileQueue.ts | 34 ++ .../jotai/contexts/hyperliquid/actions.ts | 5 +- .../OrderBook/tickSizeUtils.test.ts | 27 +- .../components/OrderBook/tickSizeUtils.ts | 24 ++ .../components/OrderBook/useTickOptions.ts | 18 +- .../views/Perp/components/PerpOrderBook.tsx | 12 +- .../src/views/Perp/hooks/usePerpMarketData.ts | 14 +- .../views/Perp/utils/l2BookFreshness.test.ts | 38 +- .../src/views/Perp/utils/l2BookFreshness.ts | 16 +- packages/shared/types/hyperliquid/sdk.ts | 11 +- packages/shared/types/hyperliquid/types.ts | 1 + yarn.lock | 8 + 21 files changed, 1253 insertions(+), 57 deletions(-) create mode 100644 packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts create mode 100644 packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts diff --git a/packages/kit-bg/package.json b/packages/kit-bg/package.json index 5ca991533dfb..d59ec85cbc84 100644 --- a/packages/kit-bg/package.json +++ b/packages/kit-bg/package.json @@ -5,6 +5,7 @@ "main": "src/index.tsx", "dependencies": { "eth-url-parser": "^1.0.4", + "fflate": "0.8.2", "idb": "^7.1.1", "ipaddr.js": "^2.3.0", "miscreant": "^0.3.2" diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts index 2b4958160615..49e2600cd5e0 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts @@ -114,6 +114,21 @@ describe('ServiceHyperliquidCache L2 book helpers', () => { mantissa: null, }); }); + + it('uses the source precision instead of current UI options', () => { + const data = Object.assign( + buildBook({ coin: 'ETH', bidLevels: 20, askLevels: 20 }), + { nSigFigs: 5, mantissa: 5 }, + ); + + expect( + buildL2BookSnapshotCachePayload({ + data, + activeBookCoin: 'ETH', + activeOptions: { nSigFigs: 5, mantissa: 2 }, + }), + ).toMatchObject({ nSigFigs: 5, mantissa: 5 }); + }); }); describe('ServiceHyperliquidCache account display write throttle', () => { diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts index d21348a6013b..21a3efda180a 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts @@ -139,10 +139,22 @@ export function buildL2BookSnapshotCachePayload({ } const isActiveBook = activeBookCoin === coin; + const hasSourceOptions = + Object.prototype.hasOwnProperty.call(data, 'nSigFigs') || + Object.prototype.hasOwnProperty.call(data, 'mantissa'); + let nSigFigs = null; + let mantissa = null; + if (hasSourceOptions) { + nSigFigs = data.nSigFigs ?? null; + mantissa = data.mantissa ?? null; + } else if (isActiveBook) { + nSigFigs = activeOptions?.nSigFigs ?? null; + mantissa = activeOptions?.mantissa ?? null; + } return { coin, - nSigFigs: isActiveBook ? (activeOptions?.nSigFigs ?? null) : null, - mantissa: isActiveBook ? (activeOptions?.mantissa ?? null) : null, + nSigFigs, + mantissa, data, }; } @@ -313,6 +325,8 @@ export default class ServiceHyperliquidCache extends ServiceBase { }); return { ...cacheEntry.data, + nSigFigs: cacheEntry.nSigFigs ?? null, + mantissa: cacheEntry.mantissa ?? null, localReceivedAt: cacheEntry.updatedAt, } as IBook & { localReceivedAt?: number }; } diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index 7e10fef5f9d4..fa6d97c44cda 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -75,11 +75,18 @@ import { spotActiveAssetAtom } from '../../states/jotai/atoms/spot'; import ServiceBase from '../ServiceBase'; import hyperLiquidCache from './hyperLiquidCache'; +import { + FastL2Book, + type IFastL2Frame, + isStaleFastL2TargetError, +} from './utils/FastL2Book'; import { SUBSCRIPTION_TYPE_INFO, calculateRequiredSubscriptionsMap, + isOrderBookOptionsTargetReady, } from './utils/SubscriptionConfig'; import { PerKeyMutationQueue } from './utils/SubscriptionMutationQueue'; +import { LatestSubscriptionReconcileQueue } from './utils/SubscriptionReconcileQueue'; import type { ISubscriptionSpec, @@ -125,6 +132,7 @@ interface ISubscriptionUpdateParams { tradingMode?: 'perp' | 'spot'; isConnected?: boolean; l2BookOptions?: IL2BookOptions | null; + orderBookTransport?: 'l2' | 'l2Book'; } interface IRequiredSubscriptionInfo { @@ -162,6 +170,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { spotAssetCtxsEnabled: false, currentSpotSymbol: undefined, tradingMode: 'perp', + orderBookTransport: 'l2', }; private _networkTimeoutTimer: ReturnType | null = null; @@ -192,6 +201,31 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _activeSubscriptions = new Map(); + private _fastL2Book: FastL2Book | null = null; + + private _fastL2SubscriptionKey: string | null = null; + + private _fastL2SnapshotTimer: ReturnType | null = null; + + private _fastL2TargetKey: string | null = null; + + private _fastL2FallbackTargetKey: string | null = null; + + private _fastL2RecoveryAttempts = 0; + + private _fastL2ReconnectAttempted = false; + + private _fastL2RecoveryPromise: Promise | null = null; + + private static readonly FAST_L2_SNAPSHOT_TIMEOUT_MS = 3000; + + private static readonly FAST_L2_RECOVERY_DELAYS_MS = [0, 1000, 3000]; + + private static readonly ORDER_BOOK_MUTATION_KEY = 'hyperliquid-order-book'; + + private static readonly ORDER_BOOK_STRATEGY: 'fastL2Primary' | 'l2BookOnly' = + 'fastL2Primary'; + // Cross-runtime atom sync can lag behind a reopened socket, leaving current // market subscriptions absent while the socket still looks healthy. private _subscriptionAtomsUnsubs: Array<() => void> = []; @@ -200,6 +234,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _subscriptionMutationQueue = new PerKeyMutationQueue(); + private _subscriptionReconcileQueue = new LatestSubscriptionReconcileQueue(); + private _destroyingSubscriptionKeys = new Set(); private _routeSubscriptionStateVersion = 0; @@ -234,6 +270,144 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ]; } + private _resetFastL2Book(subscriptionKey?: string): void { + if (subscriptionKey && this._fastL2SubscriptionKey !== subscriptionKey) { + return; + } + if (this._fastL2SnapshotTimer) { + clearTimeout(this._fastL2SnapshotTimer); + this._fastL2SnapshotTimer = null; + } + this._fastL2Book = null; + this._fastL2SubscriptionKey = null; + } + + private _resetFastL2Recovery(): void { + this._fastL2TargetKey = null; + this._fastL2FallbackTargetKey = null; + this._fastL2RecoveryAttempts = 0; + this._fastL2ReconnectAttempted = false; + this._fastL2RecoveryPromise = null; + } + + private _prepareFastL2Book( + spec: ISubscriptionSpec, + ): void { + this._resetFastL2Book(); + if (this._fastL2TargetKey !== spec.key) { + this._fastL2TargetKey = spec.key; + this._fastL2FallbackTargetKey = null; + this._fastL2RecoveryAttempts = 0; + this._fastL2ReconnectAttempted = false; + } + this._fastL2SubscriptionKey = spec.key; + this._fastL2Book = new FastL2Book(spec.params.c, { + nSigFigs: spec.params.s ?? null, + mantissa: spec.params.m ?? null, + }); + } + + private _startFastL2SnapshotTimer(subscriptionKey: string): void { + if ( + this._fastL2SubscriptionKey !== subscriptionKey || + this._fastL2Book?.hasSnapshot + ) { + return; + } + if (this._fastL2SnapshotTimer) { + clearTimeout(this._fastL2SnapshotTimer); + } + this._fastL2SnapshotTimer = setTimeout(() => { + if ( + this._fastL2SubscriptionKey === subscriptionKey && + !this._fastL2Book?.hasSnapshot + ) { + void this._recoverFastL2('snapshot_timeout'); + } + }, ServiceHyperliquidSubscription.FAST_L2_SNAPSHOT_TIMEOUT_MS); + } + + private async _recoverFastL2(reason: string): Promise { + if (this._fastL2RecoveryPromise || !this._fastL2TargetKey) { + return; + } + + const targetKey = this._fastL2TargetKey; + const spec = this.pendingSubSpecsMap[targetKey] as + | ISubscriptionSpec + | undefined; + if (!spec) { + return; + } + + const recoveryPromise = (async () => { + const delay = + ServiceHyperliquidSubscription.FAST_L2_RECOVERY_DELAYS_MS[ + this._fastL2RecoveryAttempts + ]; + if (delay !== undefined) { + this._fastL2RecoveryAttempts += 1; + if (delay > 0) { + await timerUtils.wait(delay); + } + if ( + this._fastL2TargetKey !== targetKey || + !this.pendingSubSpecsMap[targetKey] + ) { + return; + } + console.warn( + `[HyperLiquid WebSocket] Reset Fast L2 subscription: ${reason}`, + ); + const resetSucceeded = await this._subscriptionMutationQueue.enqueue( + ServiceHyperliquidSubscription.ORDER_BOOK_MUTATION_KEY, + async () => { + const destroyed = await this._destroySubscription(spec); + if (!destroyed) { + return false; + } + if ( + this._fastL2TargetKey === targetKey && + this.pendingSubSpecsMap[targetKey] + ) { + await this._createSubscription(spec); + } + return true; + }, + ); + if (!resetSucceeded && !this._fastL2ReconnectAttempted) { + this._fastL2ReconnectAttempted = true; + await this._forceReconnectTransport(); + } + return; + } + + if (!this._fastL2ReconnectAttempted) { + this._fastL2ReconnectAttempted = true; + console.warn( + `[HyperLiquid WebSocket] Reconnect after Fast L2 recovery exhausted: ${reason}`, + ); + await this._forceReconnectTransport(); + return; + } + + this._fastL2FallbackTargetKey = targetKey; + this._resetFastL2Book(targetKey); + console.warn( + `[HyperLiquid WebSocket] Fast L2 fallback to l2Book for current target: ${reason}`, + ); + await this.updateSubscriptions(); + })(); + this._fastL2RecoveryPromise = recoveryPromise; + try { + await recoveryPromise; + } finally { + if (this._fastL2RecoveryPromise === recoveryPromise) { + this._fastL2RecoveryPromise = null; + } + } + } + private _unwatchSubscriptionAtoms(): void { for (const unsub of this._subscriptionAtomsUnsubs) { try { @@ -262,6 +436,11 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const currentAssetId = currentMode === 'spot' ? spotActiveAsset?.assetId : activeAsset?.assetId; const activeOrderBookOptions = await perpsActiveOrderBookOptionsAtom.get(); + if ( + !isOrderBookOptionsTargetReady(currentCoin, activeOrderBookOptions?.coin) + ) { + return undefined; + } const isOrderBookOptionsForCurrentCoin = Boolean(currentCoin) && activeOrderBookOptions?.coin === currentCoin; @@ -307,9 +486,20 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { spotAssetCtxsEnabled: this._currentState.spotAssetCtxsEnabled, currentSpotSymbol, tradingMode: currentMode, + orderBookTransport: + ServiceHyperliquidSubscription.ORDER_BOOK_STRATEGY === 'l2BookOnly' + ? 'l2Book' + : 'l2', }; - const requiredSubSpecsMap = calculateRequiredSubscriptionsMap(params); + let requiredSubSpecsMap = calculateRequiredSubscriptionsMap(params); + const fastL2Spec = Object.values(requiredSubSpecsMap).find( + (spec) => spec.type === ESubscriptionType.L2, + ); + if (fastL2Spec?.key === this._fastL2FallbackTargetKey) { + params.orderBookTransport = 'l2Book'; + requiredSubSpecsMap = calculateRequiredSubscriptionsMap(params); + } return { requiredSubSpecsMap, params }; } @@ -328,6 +518,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { params.currentSymbol !== this._currentState.currentSymbol || params.currentSpotSymbol !== this._currentState.currentSpotSymbol || params.tradingMode !== this._currentState.tradingMode || + params.orderBookTransport !== this._currentState.orderBookTransport || !isEqual( params.l2BookOptions ?? null, this._currentState.l2BookOptions ?? null, @@ -383,9 +574,15 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._scheduleCriticalSubscriptionHealthCheck('update_subscriptions'); } + private async _enqueueSubscriptionReconcile(): Promise { + await this._subscriptionReconcileQueue.enqueue(async () => { + await this._updateSubscriptionsCore(); + }); + } + _updateSubscriptionsDebounced = debounce( async () => { - await this._updateSubscriptionsCore(); + await this._enqueueSubscriptionReconcile(); }, 300, { @@ -420,12 +617,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { // Skip debounce on first subscription to speed up initial load if (!this._hasInitialSubscription) { this._hasInitialSubscription = true; - const requiredSubInfo = await this.buildRequiredSubscriptionsMap(); - if (!requiredSubInfo) { - return; - } markPerpsColdStartPerf('service_update_subscriptions_core_first_start'); - await this._updateSubscriptionsCore(requiredSubInfo); + await this._enqueueSubscriptionReconcile(); markPerpsColdStartPerf('service_update_subscriptions_core_first_end'); return; } @@ -437,7 +630,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (this._shouldUpdateSubscriptionsImmediately(requiredSubInfo.params)) { this._updateSubscriptionsDebounced.cancel(); this._hasInitialSubscription = true; - await this._updateSubscriptionsCore(requiredSubInfo); + await this._enqueueSubscriptionReconcile(); markPerpsColdStartPerf('service_update_subscriptions_end'); return; } @@ -516,6 +709,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const criticalTypes = new Set([ ESubscriptionType.ALL_DEXS_ASSET_CTXS, ESubscriptionType.L2_BOOK, + ESubscriptionType.L2, ]); const now = Date.now(); const staleTypes = new Set(); @@ -550,6 +744,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const criticalTypes = new Set([ ESubscriptionType.ALL_DEXS_ASSET_CTXS, ESubscriptionType.L2_BOOK, + ESubscriptionType.L2, ]); const missingTypes = new Set(); for (const spec of Object.values(requiredSubSpecsMap)) { @@ -927,6 +1122,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { async disconnect(): Promise { this.backgroundApi.serviceHyperliquidCache.flushPendingL2BookSnapshotCache(); this._subscriptionLifecycleVersion += 1; + this._resetFastL2Book(); + this._resetFastL2Recovery(); this._updateSubscriptionsDebounced.cancel(); this._unwatchSubscriptionAtoms(); await this._cleanupAllSubscriptions(); @@ -953,6 +1150,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { async cleanup(): Promise { this.backgroundApi.serviceHyperliquidCache.flushPendingL2BookSnapshotCache(); this._subscriptionLifecycleVersion += 1; + this._resetFastL2Book(); + this._resetFastL2Recovery(); this._updateSubscriptionsDebounced.cancel(); this._unwatchSubscriptionAtoms(); this._stopPingLoop(); @@ -966,6 +1165,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private async _forceReconnectTransport(): Promise { this.backgroundApi.serviceHyperliquidCache.flushPendingL2BookSnapshotCache(); this._subscriptionLifecycleVersion += 1; + this._resetFastL2Book(); this._updateSubscriptionsDebounced.cancel(); this._unwatchSubscriptionAtoms(); this._clearPostOpenDataCheck(); @@ -1139,6 +1339,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if ('l2BookOptions' in params) { state.l2BookOptions = params.l2BookOptions; } + if ('orderBookTransport' in params) { + state.orderBookTransport = params.orderBookTransport; + } } // export interface ISubscriptionSpec { @@ -1167,6 +1370,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { void perpsWebSocketReadyStateAtom.set({ readyState }); // WS close event — readyState tracked via perpsWebSocketReadyStateAtom this._activeSubscriptions.clear(); + this._resetFastL2Book(); this._clearPostOpenDataCheck(); this._stopPingLoop(); // OK-53014: WS closed — drop any pending atom-change reconcile. A new @@ -1198,7 +1402,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { await perpsWebSocketReadyStateAtom.set({ readyState: readyState ?? WebSocket.OPEN, }); - + // A new transport retries Fast L2 even if the previous connection had + // temporarily degraded this target to l2Book. + this._fastL2FallbackTargetKey = null; const prevNetworkStatus = await perpsNetworkStatusAtom.get(); const wasConnected = prevNetworkStatus?.connected; const openClient = this._client; @@ -1355,6 +1561,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ESubscriptionType.ALL_MIDS, ESubscriptionType.BBO, ESubscriptionType.L2_BOOK, + ESubscriptionType.L2, ESubscriptionType.ACTIVE_ASSET_CTX, ESubscriptionType.ACTIVE_ASSET_DATA, ESubscriptionType.WEB_DATA2, @@ -1535,12 +1742,38 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } }); - // Different subscription keys must reconcile independently; otherwise an - // obsolete L2 subscribe ack can stall the next selected market. - await Promise.all([ - ...toDestroySubscriptions.map((spec) => this._destroySubscription(spec)), - ...toCreateSubscriptions.map((spec) => this._createSubscription(spec)), - ]); + const isOrderBookSpec = (spec: ISubscriptionSpec) => + spec.type === ESubscriptionType.L2 || + spec.type === ESubscriptionType.L2_BOOK; + const orderBookToDestroy = toDestroySubscriptions.filter(isOrderBookSpec); + const orderBookToCreate = toCreateSubscriptions.filter(isOrderBookSpec); + const otherTasks = [ + ...toDestroySubscriptions + .filter((spec) => !isOrderBookSpec(spec)) + .map((spec) => this._destroySubscription(spec)), + ...toCreateSubscriptions + .filter((spec) => !isOrderBookSpec(spec)) + .map((spec) => this._createSubscription(spec)), + ]; + + const orderBookTask = + orderBookToDestroy.length > 0 || orderBookToCreate.length > 0 + ? this._subscriptionMutationQueue.enqueue( + ServiceHyperliquidSubscription.ORDER_BOOK_MUTATION_KEY, + async () => { + for (const spec of orderBookToDestroy) { + await this._destroySubscription(spec); + } + for (const spec of orderBookToCreate) { + if (this._isSubscriptionSpecPending(spec)) { + await this._createSubscription(spec); + } + } + }, + ) + : Promise.resolve(); + + await Promise.all([...otherTasks, orderBookTask]); } private async _createSubscription( @@ -1579,6 +1812,10 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { try { const lifecycleVersion = this._subscriptionLifecycleVersion; + if (spec.type === ESubscriptionType.L2) { + const fastL2Spec = spec as ISubscriptionSpec; + this._prepareFastL2Book(fastL2Spec); + } const client = await this._createSubscriptionDirect(spec); const isCreateResultStale = this.subscriptionsHandlerDisabled || @@ -1603,11 +1840,20 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { lastActivity: Date.now(), isActive: true, }); + if (spec.type === ESubscriptionType.L2) { + this._startFastL2SnapshotTimer(spec.key); + } } catch (error) { + this._resetFastL2Book(spec.key); console.error( `[ServiceHyperliquidSubscription.createSubscription] Failed to create subscription ${spec.type}:`, error, ); + if (spec.type === ESubscriptionType.L2) { + setTimeout(() => { + void this._recoverFastL2('subscribe_failed'); + }, 0); + } } finally { if ( !this.subscriptionsHandlerDisabled && @@ -1635,6 +1881,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ): Promise { try { if (spec) { + if (spec.type === ESubscriptionType.L2) { + this._resetFastL2Book(spec.key); + } const shouldRemoveCache = options?.removeCache ?? true; const removeSubCache = () => { if (!shouldRemoveCache) { @@ -1769,7 +2018,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } const messageTimestamp = Date.now(); - this._markSubscriptionActivity(subscriptionType, messageTimestamp); + if (subscriptionType !== ESubscriptionType.L2) { + this._markSubscriptionActivity(subscriptionType, messageTimestamp); + } markPerpsColdStartPerfOnce(`service_ws_first_${subscriptionType}`, { subscriptionType, }); @@ -1952,16 +2203,68 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { subscriptionType, data as IWsAllDexsAssetCtxs, ); + } else if (subscriptionType === ESubscriptionType.L2) { + const subscriptionKey = this._fastL2SubscriptionKey; + const fastL2Book = this._fastL2Book; + if (!subscriptionKey || !fastL2Book) { + return; + } + // A late frame from a destroyed subscription must never mutate a + // newly selected market's book. + if (!this.pendingSubSpecsMap[subscriptionKey]) { + return; + } + + let normalizedBook: IBook | null; + try { + normalizedBook = fastL2Book.apply(data as IFastL2Frame); + } catch (error) { + if (isStaleFastL2TargetError(error)) { + return; + } + await this._recoverFastL2('invalid_frame'); + return; + } + if (!normalizedBook) { + return; + } + this._markSubscriptionActivity(subscriptionType, messageTimestamp); + if (this._fastL2SnapshotTimer && fastL2Book.hasSnapshot) { + clearTimeout(this._fastL2SnapshotTimer); + this._fastL2SnapshotTimer = null; + } + if (fastL2Book.hasSnapshot) { + this._fastL2RecoveryAttempts = 0; + this._fastL2ReconnectAttempted = false; + this._fastL2FallbackTargetKey = null; + } + this.backgroundApi.serviceHyperliquidCache.cacheL2BookSnapshot({ + data: normalizedBook, + activeBookCoin: + this._currentState.tradingMode === 'spot' + ? this._currentState.currentSpotSymbol + : this._currentState.currentSymbol, + activeOptions: this._currentState.l2BookOptions, + }); + this._emitHyperliquidDataUpdate( + ESubscriptionType.L2_BOOK, + normalizedBook, + ); } else if (subscriptionType === ESubscriptionType.L2_BOOK) { + const normalizedBook = { + ...(data as IBook), + nSigFigs: this._currentState.l2BookOptions?.nSigFigs ?? null, + mantissa: this._currentState.l2BookOptions?.mantissa ?? null, + }; this.backgroundApi.serviceHyperliquidCache.cacheL2BookSnapshot({ - data: data as IBook, + data: normalizedBook, activeBookCoin: this._currentState.tradingMode === 'spot' ? this._currentState.currentSpotSymbol : this._currentState.currentSymbol, activeOptions: this._currentState.l2BookOptions, }); - this._emitHyperliquidDataUpdate(subscriptionType, data); + this._emitHyperliquidDataUpdate(subscriptionType, normalizedBook); } else { this._emitHyperliquidDataUpdate(subscriptionType, data); } diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts new file mode 100644 index 000000000000..36a8e9caff10 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts @@ -0,0 +1,161 @@ +import { FastL2Book } from './FastL2Book'; + +describe('FastL2Book', () => { + it('keeps source precision on snapshots and updates', () => { + const book = new FastL2Book('ETH', { nSigFigs: 5, mantissa: 2 }); + book.apply({ + s: { + coin: 'ETH', + time: 1, + levels: [ + [{ px: '100', sz: '1', n: 1 }], + [{ px: '101', sz: '1', n: 1 }], + ], + }, + }); + + expect( + book.apply({ u: { c: 'ETH', t: 2, l: [[], []], r: [[], []] } }), + ).toMatchObject({ nSigFigs: 5, mantissa: 2 }); + }); + + it('merges an l2 delta into a snapshot without changing the book contract', () => { + const book = new FastL2Book('BTC'); + + expect( + book.apply({ + s: { + coin: 'BTC', + time: 1, + levels: [ + [ + { px: '100', sz: '1', n: 1 }, + { px: '99', sz: '2', n: 1 }, + ], + [ + { px: '101', sz: '3', n: 1 }, + { px: '102', sz: '4', n: 1 }, + ], + ], + }, + }), + ).toEqual({ + coin: 'BTC', + time: 1, + levels: [ + [ + { px: '100', sz: '1', n: 1 }, + { px: '99', sz: '2', n: 1 }, + ], + [ + { px: '101', sz: '3', n: 1 }, + { px: '102', sz: '4', n: 1 }, + ], + ], + }); + + expect( + book.apply({ + u: { + c: 'BTC', + t: 2, + l: [ + [ + { p: '100', s: '5' }, + { p: '98', s: '6' }, + ], + [{ p: '101', s: '7' }], + ], + r: [[1], [1]], + }, + }), + ).toEqual({ + coin: 'BTC', + time: 2, + levels: [ + [ + { px: '100', sz: '5', n: 0 }, + { px: '98', sz: '6', n: 0 }, + ], + [{ px: '101', sz: '7', n: 0 }], + ], + }); + }); + + it('drops a delta received before the first snapshot', () => { + const book = new FastL2Book('BTC'); + + expect( + book.apply({ + u: { + c: 'BTC', + t: 2, + l: [[], []], + r: [[], []], + }, + }), + ).toBeNull(); + }); + + it('classifies frames from a previous target as stale', () => { + const book = new FastL2Book('ETH'); + + expect(() => + book.apply({ + u: { + c: 'BTC', + t: 2, + l: [[], []], + r: [[], []], + }, + }), + ).toThrow( + expect.objectContaining({ + code: 'stale_target', + }), + ); + }); + + it('merges a compressed delta after the first snapshot', () => { + const book = new FastL2Book('BTC'); + book.apply({ + s: { + coin: 'BTC', + time: 1, + levels: [[{ px: '100', sz: '1', n: 1 }], []], + }, + }); + + expect( + book.apply({ + c: 'q1ZKVrJScgpxVtJRKlGyMtJRylGyio6uVipQslIyNDBQ0lEqVrJSMlWqjdWJjo3VUSoCSYPZtQA=', + }), + ).toEqual({ + coin: 'BTC', + time: 2, + levels: [[{ px: '100', sz: '5', n: 0 }], []], + }); + }); + + it('rejects removal indexes outside the current book', () => { + const book = new FastL2Book('BTC'); + book.apply({ + s: { + coin: 'BTC', + time: 1, + levels: [[{ px: '100', sz: '1', n: 1 }], []], + }, + }); + + expect(() => + book.apply({ + u: { + c: 'BTC', + t: 2, + l: [[], []], + r: [[1], []], + }, + }), + ).toThrow('Fast L2 book: Invalid L2 removal index'); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts new file mode 100644 index 000000000000..820cecfaa5f3 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts @@ -0,0 +1,361 @@ +import BigNumber from 'bignumber.js'; +import { Inflate, strFromU8 } from 'fflate'; + +import type { IBook, IBookLevel } from '@onekeyhq/shared/types/hyperliquid/sdk'; + +const MAX_LEVELS_PER_SIDE = 200; +const MAX_COMPRESSED_BYTES = 256 * 1024; +const MAX_DECOMPRESSED_BYTES = 1024 * 1024; +const INFLATE_CHUNK_BYTES = 16 * 1024; +const DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/; +const BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +type IFastL2Level = { + p: string; + s: string; +}; + +type IFastL2Update = { + c: string; + t: number; + l: [IFastL2Level[], IFastL2Level[]]; + r: [number[], number[]]; +}; + +export type IFastL2Frame = { s: IBook } | { u: IFastL2Update } | { c: string }; + +export type IFastL2BookError = Error & { + code: 'invalid_stream' | 'stale_target'; +}; + +export function isStaleFastL2TargetError( + error: unknown, +): error is IFastL2BookError { + return ( + error instanceof Error && 'code' in error && error.code === 'stale_target' + ); +} + +function createFastL2BookError( + message: string, + code: 'invalid_stream' | 'stale_target' = 'invalid_stream', +): IFastL2BookError { + return Object.assign(new Error(`Fast L2 book: ${message}`), { code }); +} + +function assertFiniteTimestamp(value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw createFastL2BookError('Invalid L2 timestamp'); + } +} + +function assertDecimal(value: unknown, field: string): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 64 || + !DECIMAL_PATTERN.test(value) + ) { + throw createFastL2BookError(`Invalid L2 ${field}`); + } + + const decimal = new BigNumber(value); + if (!decimal.isFinite() || decimal.lte(0)) { + throw createFastL2BookError(`Invalid L2 ${field}`); + } +} + +function comparePrices(left: string, right: string): number { + return new BigNumber(left).comparedTo(new BigNumber(right)); +} + +function decodeBase64(value: string): Uint8Array { + if ( + value.length === 0 || + value.length > Math.ceil((MAX_COMPRESSED_BYTES * 4) / 3) + 4 || + !BASE64_PATTERN.test(value) + ) { + throw createFastL2BookError('Invalid compressed L2 payload'); + } + + const global = globalThis as typeof globalThis & { + Buffer?: { + from: (input: string, encoding: 'base64') => Uint8Array; + }; + atob?: (input: string) => string; + }; + if (global.Buffer) { + const decoded = new Uint8Array(global.Buffer.from(value, 'base64')); + if (decoded.length > MAX_COMPRESSED_BYTES) { + throw createFastL2BookError('Compressed L2 payload exceeds byte limit'); + } + return decoded; + } + if (!global.atob) { + throw createFastL2BookError('Base64 decoder is unavailable'); + } + + const binary = global.atob(value); + if (binary.length > MAX_COMPRESSED_BYTES) { + throw createFastL2BookError('Compressed L2 payload exceeds byte limit'); + } + const decoded = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + decoded[index] = binary.charCodeAt(index); + } + return decoded; +} + +function decodeCompressedUpdate(payload: string): unknown { + const compressed = decodeBase64(payload); + const chunks: Uint8Array[] = []; + let outputLength = 0; + const inflate = new Inflate((chunk) => { + outputLength += chunk.length; + if (outputLength > MAX_DECOMPRESSED_BYTES) { + throw createFastL2BookError('Decompressed L2 payload exceeds byte limit'); + } + chunks.push(chunk); + }); + + for ( + let offset = 0; + offset < compressed.length; + offset += INFLATE_CHUNK_BYTES + ) { + inflate.push( + compressed.subarray(offset, offset + INFLATE_CHUNK_BYTES), + offset + INFLATE_CHUNK_BYTES >= compressed.length, + ); + } + + const output = new Uint8Array(outputLength); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + try { + return JSON.parse(strFromU8(output)); + } catch { + throw createFastL2BookError('Invalid decompressed L2 payload'); + } +} + +function assertSortedLevels(levels: IBookLevel[], side: 0 | 1): void { + for (let index = 1; index < levels.length; index += 1) { + const previous = levels[index - 1]; + const current = levels[index]; + if (!previous || !current) { + throw createFastL2BookError('Invalid L2 level'); + } + const comparison = comparePrices(previous.px, current.px); + if ((side === 0 && comparison <= 0) || (side === 1 && comparison >= 0)) { + throw createFastL2BookError('Invalid L2 price order'); + } + } +} + +function assertBookInvariant(levels: [IBookLevel[], IBookLevel[]]): void { + const [bids, asks] = levels; + assertSortedLevels(bids, 0); + assertSortedLevels(asks, 1); + + const bestBid = bids[0]; + const bestAsk = asks[0]; + if (bestBid && bestAsk && comparePrices(bestBid.px, bestAsk.px) >= 0) { + throw createFastL2BookError('Invalid crossed L2 book'); + } +} + +function parseSnapshot(snapshot: unknown, expectedCoin: string): IBook { + if (!snapshot || typeof snapshot !== 'object') { + throw createFastL2BookError('Invalid L2 snapshot'); + } + + const data = snapshot as Partial; + if (typeof data.coin === 'string' && data.coin !== expectedCoin) { + throw createFastL2BookError('Stale L2 snapshot target', 'stale_target'); + } + if (data.coin !== expectedCoin || !Array.isArray(data.levels)) { + throw createFastL2BookError('Invalid L2 snapshot coin or levels'); + } + assertFiniteTimestamp(data.time); + + const [bids, asks] = data.levels; + if (!Array.isArray(bids) || !Array.isArray(asks)) { + throw createFastL2BookError('Invalid L2 snapshot levels'); + } + if (bids.length > MAX_LEVELS_PER_SIDE || asks.length > MAX_LEVELS_PER_SIDE) { + throw createFastL2BookError('L2 snapshot exceeds level limit'); + } + + const normalized: [IBookLevel[], IBookLevel[]] = [ + bids.map((level) => { + assertDecimal(level?.px, 'price'); + assertDecimal(level?.sz, 'size'); + if ( + typeof level?.n !== 'number' || + !Number.isSafeInteger(level.n) || + level.n < 0 + ) { + throw createFastL2BookError('Invalid L2 order count'); + } + return { px: level.px, sz: level.sz, n: level.n }; + }), + asks.map((level) => { + assertDecimal(level?.px, 'price'); + assertDecimal(level?.sz, 'size'); + if ( + typeof level?.n !== 'number' || + !Number.isSafeInteger(level.n) || + level.n < 0 + ) { + throw createFastL2BookError('Invalid L2 order count'); + } + return { px: level.px, sz: level.sz, n: level.n }; + }), + ]; + assertBookInvariant(normalized); + + return { coin: expectedCoin, time: data.time, levels: normalized } as IBook; +} + +function parseUpdate(update: unknown, expectedCoin: string): IFastL2Update { + if (!update || typeof update !== 'object') { + throw createFastL2BookError('Invalid L2 update'); + } + const data = update as Partial; + if (typeof data.c === 'string' && data.c !== expectedCoin) { + throw createFastL2BookError('Stale L2 update target', 'stale_target'); + } + if ( + data.c !== expectedCoin || + !Array.isArray(data.l) || + !Array.isArray(data.r) + ) { + throw createFastL2BookError('Invalid L2 update coin or levels'); + } + assertFiniteTimestamp(data.t); + const [bidUpdates, askUpdates] = data.l; + const [bidRemovals, askRemovals] = data.r; + if ( + !Array.isArray(bidUpdates) || + !Array.isArray(askUpdates) || + !Array.isArray(bidRemovals) || + !Array.isArray(askRemovals) || + bidUpdates.length > MAX_LEVELS_PER_SIDE || + askUpdates.length > MAX_LEVELS_PER_SIDE || + bidRemovals.length > MAX_LEVELS_PER_SIDE || + askRemovals.length > MAX_LEVELS_PER_SIDE + ) { + throw createFastL2BookError('Invalid L2 update bounds'); + } + + const normalizeUpdates = (updates: IFastL2Level[]) => + updates.map((level) => { + assertDecimal(level?.p, 'price'); + assertDecimal(level?.s, 'size'); + return { p: level.p, s: level.s }; + }); + const normalizeRemovals = (removals: number[]) => + removals.map((index) => { + if (!Number.isSafeInteger(index) || index < 0) { + throw createFastL2BookError('Invalid L2 removal index'); + } + return index; + }); + + return { + c: expectedCoin, + t: data.t, + l: [normalizeUpdates(bidUpdates), normalizeUpdates(askUpdates)], + r: [normalizeRemovals(bidRemovals), normalizeRemovals(askRemovals)], + }; +} + +function mergeSide( + currentLevels: IBookLevel[], + updates: IFastL2Level[], + removals: number[], + side: 0 | 1, +): IBookLevel[] { + const removedIndexes = new Set(); + for (const index of removals) { + if (index >= currentLevels.length || removedIndexes.has(index)) { + throw createFastL2BookError('Invalid L2 removal index'); + } + removedIndexes.add(index); + } + + const levelsByPrice = new Map(); + currentLevels.forEach((level, index) => { + if (!removedIndexes.has(index)) { + levelsByPrice.set(level.px, level); + } + }); + updates.forEach((level) => { + levelsByPrice.set(level.p, { px: level.p, sz: level.s, n: 0 }); + }); + + const merged = Array.from(levelsByPrice.values()); + if (merged.length > MAX_LEVELS_PER_SIDE) { + throw createFastL2BookError('L2 book exceeds level limit'); + } + merged.sort((left, right) => { + const comparison = comparePrices(left.px, right.px); + return side === 0 ? -comparison : comparison; + }); + return merged; +} + +export class FastL2Book { + private _book: IBook | null = null; + + private readonly _coin: string; + + private readonly _options: Pick; + + constructor( + coin: string, + options: Pick = {}, + ) { + this._coin = coin; + this._options = options; + } + + get hasSnapshot(): boolean { + return this._book !== null; + } + + apply(frame: IFastL2Frame): IBook | null { + if ('s' in frame) { + this._book = { ...parseSnapshot(frame.s, this._coin), ...this._options }; + return this._book; + } + const update = parseUpdate( + 'c' in frame ? decodeCompressedUpdate(frame.c) : frame.u, + this._coin, + ); + if (!this._book) { + return null; + } + if (update.t < this._book.time) { + throw createFastL2BookError('Out-of-order L2 update'); + } + + const levels: [IBookLevel[], IBookLevel[]] = [ + mergeSide(this._book.levels[0], update.l[0], update.r[0], 0), + mergeSide(this._book.levels[1], update.l[1], update.r[1], 1), + ]; + assertBookInvariant(levels); + this._book = { + coin: this._coin, + time: update.t, + levels, + ...this._options, + } as IBook; + return this._book; + } +} diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts index 72a993fb5271..e02083e2afb3 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts @@ -1,6 +1,17 @@ import { ESubscriptionType } from '@onekeyhq/shared/types/hyperliquid/types'; -import { calculateRequiredSubscriptions } from './SubscriptionConfig'; +import { + calculateRequiredSubscriptions, + isOrderBookOptionsTargetReady, +} from './SubscriptionConfig'; + +describe('isOrderBookOptionsTargetReady', () => { + it('waits for order book options to catch up with the active coin', () => { + expect(isOrderBookOptionsTargetReady('ETH', 'BTC')).toBe(false); + expect(isOrderBookOptionsTargetReady('ETH', 'ETH')).toBe(true); + expect(isOrderBookOptionsTargetReady('ETH', undefined)).toBe(true); + }); +}); describe('calculateRequiredSubscriptions', () => { it('always subscribes to all dex asset contexts for token selector prices', () => { @@ -39,6 +50,90 @@ describe('calculateRequiredSubscriptions', () => { ]); }); + it('prefers fast L2 for a perp book when explicitly enabled', () => { + const specs = calculateRequiredSubscriptions({ + currentUser: null, + currentSymbol: 'BTC', + isConnected: true, + orderBookTransport: 'l2', + l2BookOptions: { + nSigFigs: 5, + mantissa: null, + }, + }); + + expect( + specs + .filter((spec) => spec.type === ESubscriptionType.L2) + .map((spec) => spec.params), + ).toEqual([ + { + c: 'BTC', + s: 5, + }, + ]); + expect(specs.some((spec) => spec.type === ESubscriptionType.L2_BOOK)).toBe( + false, + ); + }); + + it('prefers fast L2 for a spot book when explicitly enabled', () => { + const specs = calculateRequiredSubscriptions({ + currentUser: null, + currentSymbol: '', + currentSpotSymbol: '@107', + tradingMode: 'spot', + isConnected: true, + orderBookTransport: 'l2', + l2BookOptions: { + nSigFigs: 5, + mantissa: null, + }, + }); + + expect( + specs + .filter((spec) => spec.type === ESubscriptionType.L2) + .map((spec) => spec.params), + ).toEqual([ + { + c: '@107', + s: 5, + }, + ]); + expect(specs.some((spec) => spec.type === ESubscriptionType.L2_BOOK)).toBe( + false, + ); + }); + + it('uses l2Book when the service selects the fallback transport', () => { + const specs = calculateRequiredSubscriptions({ + currentUser: null, + currentSymbol: 'BTC', + isConnected: true, + orderBookTransport: 'l2Book', + l2BookOptions: { + nSigFigs: 5, + mantissa: null, + }, + }); + + expect(specs.some((spec) => spec.type === ESubscriptionType.L2)).toBe( + false, + ); + expect( + specs + .filter((spec) => spec.type === ESubscriptionType.L2_BOOK) + .map((spec) => spec.params), + ).toEqual([ + { + coin: 'BTC', + nSigFigs: 5, + mantissa: null, + }, + ]); + }); + it('subscribes openOrders to all supported perp dex response channels', () => { const specs = calculateRequiredSubscriptions({ currentUser: '0x0000000000000000000000000000000000000001', diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts index b7d27a1145fa..9e35767e44d9 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts @@ -3,6 +3,7 @@ import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; import { DEX_PREFIXES } from '@onekeyhq/shared/types/hyperliquid/perp.constants'; import type { IEventAllDexsClearinghouseStateParameters, + IEventFastL2Parameters, IEventL2BookParameters, IEventOpenOrdersParameters, IEventTwapStatesParameters, @@ -86,6 +87,10 @@ export const SUBSCRIPTION_TYPE_INFO: { eventType: EPerpsSubscriptionCategory.MARKET, priority: 3, }, + [ESubscriptionType.L2]: { + eventType: EPerpsSubscriptionCategory.MARKET, + priority: 3, + }, [ESubscriptionType.ACTIVE_ASSET_DATA]: { eventType: EPerpsSubscriptionCategory.ACCOUNT, priority: 3, @@ -114,6 +119,7 @@ export interface ISubscriptionState { currentSymbol: string; isConnected: boolean; l2BookOptions?: IL2BookOptions | null; + orderBookTransport?: 'l2' | 'l2Book'; enableLedgerUpdates?: boolean; spotEnabled?: boolean; spotAssetCtxsEnabled?: boolean; @@ -127,6 +133,13 @@ export interface ISubscriptionDiff { toSubscribe: ISubscriptionSpec[]; } +export function isOrderBookOptionsTargetReady( + currentCoin: string | undefined, + optionsCoin: string | undefined, +) { + return !currentCoin || !optionsCoin || currentCoin === optionsCoin; +} + export function generateSubscriptionKey( type: T, params: IPerpsSubscriptionParams[T], @@ -157,6 +170,20 @@ function buildSubscriptionSpec({ }; } +function buildFastL2Params( + coin: string, + l2BookParams: IEventL2BookParameters, +): IEventFastL2Parameters { + const fastL2Params: IEventFastL2Parameters = { c: coin }; + if (l2BookParams.nSigFigs !== null) { + fastL2Params.s = l2BookParams.nSigFigs; + } + if (l2BookParams.mantissa !== null) { + fastL2Params.m = l2BookParams.mantissa; + } + return fastL2Params; +} + export function calculateRequiredSubscriptions( state: ISubscriptionState, ): ISubscriptionSpec[] { @@ -198,16 +225,26 @@ export function calculateRequiredSubscriptions( }), ); if (state.l2BookOptions) { - specs.push( - buildSubscriptionSpec({ - type: ESubscriptionType.L2_BOOK, - params: { - coin: state.currentSpotSymbol, - nSigFigs: state.l2BookOptions.nSigFigs ?? null, - mantissa: state.l2BookOptions.mantissa ?? null, - }, - }), - ); + const l2BookParams: IEventL2BookParameters = { + coin: state.currentSpotSymbol, + nSigFigs: state.l2BookOptions.nSigFigs ?? null, + mantissa: state.l2BookOptions.mantissa ?? null, + }; + if (state.orderBookTransport === 'l2') { + specs.push( + buildSubscriptionSpec({ + type: ESubscriptionType.L2, + params: buildFastL2Params(state.currentSpotSymbol, l2BookParams), + }), + ); + } else { + specs.push( + buildSubscriptionSpec({ + type: ESubscriptionType.L2_BOOK, + params: l2BookParams, + }), + ); + } } } else if (state.currentSymbol) { specs.push( @@ -237,12 +274,21 @@ export function calculateRequiredSubscriptions( nSigFigs: state.l2BookOptions.nSigFigs ?? null, mantissa: state.l2BookOptions.mantissa ?? null, }; - specs.push( - buildSubscriptionSpec({ - type: ESubscriptionType.L2_BOOK, - params: l2BookParams, - }), - ); + if (state.orderBookTransport === 'l2') { + specs.push( + buildSubscriptionSpec({ + type: ESubscriptionType.L2, + params: buildFastL2Params(state.currentSymbol, l2BookParams), + }), + ); + } else { + specs.push( + buildSubscriptionSpec({ + type: ESubscriptionType.L2_BOOK, + params: l2BookParams, + }), + ); + } } } diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts new file mode 100644 index 000000000000..b391fb9dfed1 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts @@ -0,0 +1,32 @@ +import { LatestSubscriptionReconcileQueue } from './SubscriptionReconcileQueue'; + +describe('LatestSubscriptionReconcileQueue', () => { + it('serializes reconciles and runs only the latest pending request', async () => { + const queue = new LatestSubscriptionReconcileQueue(); + const events: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = queue.enqueue(async () => { + events.push('first:start'); + await firstBlocked; + events.push('first:end'); + }); + const stale = queue.enqueue(async () => { + events.push('stale'); + }); + const latest = queue.enqueue(async () => { + events.push('latest'); + }); + + await Promise.resolve(); + expect(events).toEqual(['first:start']); + + releaseFirst?.(); + await Promise.all([first, stale, latest]); + + expect(events).toEqual(['first:start', 'first:end', 'latest']); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts new file mode 100644 index 000000000000..c829d66ae88b --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts @@ -0,0 +1,34 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +export class LatestSubscriptionReconcileQueue { + private pendingTask: (() => Promise) | null = null; + + private runningPromise: Promise | null = null; + + enqueue(task: () => Promise): Promise { + this.pendingTask = task; + if (!this.runningPromise) { + this.runningPromise = this.drain().finally(() => { + this.runningPromise = null; + }); + } + return this.runningPromise; + } + + private async drain(): Promise { + let firstError: Error | undefined; + while (this.pendingTask) { + const task = this.pendingTask; + this.pendingTask = null; + try { + await task(); + } catch (error) { + firstError ??= + error instanceof Error ? error : new Error(String(error)); + } + } + if (firstError) { + throw new OneKeyLocalError(firstError.message); + } + } +} diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 574cf9022edb..36122ac7c592 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -1387,11 +1387,12 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { }); const keys = getPerpsL2BookSnapshotCacheKeys({ coin: data.coin, - nSigFigs: storedTickOptions?.nSigFigs ?? null, + nSigFigs: nextBook.nSigFigs ?? storedTickOptions?.nSigFigs ?? null, mantissa: + nextBook.mantissa === undefined && storedTickOptions?.mantissa === undefined ? undefined - : storedTickOptions.mantissa, + : (nextBook.mantissa ?? storedTickOptions?.mantissa), }); const updatedAt = nextBook.localReceivedAt ?? now; const nextColdCache = Object.fromEntries( diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts index 37ad9009dc22..ef2f11df50f4 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts @@ -2,7 +2,32 @@ import BigNumber from 'bignumber.js'; import { getDisplayPriceScaleDecimals } from '@onekeyhq/shared/src/utils/perpsUtils'; -import { buildTickOptions } from './tickSizeUtils'; +import { + buildTickOptions, + shouldPersistOrderBookTickOption, +} from './tickSizeUtils'; + +describe('shouldPersistOrderBookTickOption', () => { + it('does not replace precision while order book data is transitioning', () => { + expect( + shouldPersistOrderBookTickOption({ + hasMarketData: false, + persisted: { value: '0.2', nSigFigs: 5, mantissa: 2 }, + selected: { value: '100', nSigFigs: 2, mantissa: null }, + }), + ).toBe(false); + }); + + it('persists a changed selection derived from live market data', () => { + expect( + shouldPersistOrderBookTickOption({ + hasMarketData: true, + persisted: { value: '0.1', nSigFigs: 5, mantissa: null }, + selected: { value: '0.2', nSigFigs: 5, mantissa: 2 }, + }), + ).toBe(true); + }); +}); const fixtures = { BTC: { diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts index 92b750920eb4..b03549b03dd8 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts @@ -23,6 +23,30 @@ export interface ITickParam { value: string; } +type IOrderBookTickOptionState = { + value: string; + nSigFigs?: INSig; + mantissa?: IMantissa | null; +}; + +export function shouldPersistOrderBookTickOption({ + hasMarketData, + persisted, + selected, +}: { + hasMarketData: boolean; + persisted: IOrderBookTickOptionState | undefined; + selected: IOrderBookTickOptionState; +}) { + return ( + hasMarketData && + (!persisted || + persisted.value !== selected.value || + persisted.nSigFigs !== selected.nSigFigs || + persisted.mantissa !== selected.mantissa) + ); +} + function floorLog10(x: number): number { return Math.floor(Math.log10(x)); } diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index afc33b3cd14b..a0589ecc102d 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -18,6 +18,7 @@ import { type ITickParam, buildTickOptions, getDefaultTickOption, + shouldPersistOrderBookTickOption, } from './tickSizeUtils'; interface ITickOptionsResult { @@ -165,17 +166,24 @@ export function useTickOptions({ }; if ( - !persisted || - persisted.value !== currentPersist.value || - persisted.nSigFigs !== currentPersist.nSigFigs || - persisted.mantissa !== currentPersist.mantissa + shouldPersistOrderBookTickOption({ + hasMarketData: Boolean(tickOptionsData), + persisted, + selected: currentPersist, + }) ) { void actions.current.setOrderBookTickOption({ symbol, option: currentPersist, }); } - }, [symbol, persistedTickOptions, selectedTickOption, actions]); + }, [ + symbol, + persistedTickOptions, + selectedTickOption, + tickOptionsData, + actions, + ]); const handleSelectTickOption = useCallback( (option: ITickParam) => { diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index 30f66079f63d..935f9baba43c 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -41,6 +41,7 @@ import { useTradingPrice } from '../hooks/useTradingPrice'; import { getFreshL2BookSnapshotFromColdCache, getPerpsL2BookColdCacheGlobalSnapshot, + isL2BookForTarget, isPerpsL2BookInteractive, } from '../utils/l2BookFreshness'; import { @@ -600,8 +601,13 @@ export function PerpOrderBook({ l2SubscriptionOptions.mantissa, l2SubscriptionOptions.nSigFigs, ]); - const activeRenderL2Book = - renderL2Book?.coin === activeTradeInstrument.coin ? renderL2Book : null; + const activeRenderL2Book = isL2BookForTarget( + renderL2Book, + activeTradeInstrument.coin, + l2SubscriptionOptions, + ) + ? renderL2Book + : null; const visibleL2Book = activeRenderL2Book ?? initialCachedL2Book; const hasRenderOrderBook = Boolean(visibleL2Book); @@ -623,7 +629,7 @@ export function PerpOrderBook({ // Do NOT reset renderL2Book/isOrderBookInteractive on coin/options change: // the bridge only re-reports isInteractive on a boolean flip, so a reset // landing after a `true` report leaves it stuck out of sync. Render-time gates - // (activeRenderL2Book coin filter + freshness checks) already cover staleness. + // (activeRenderL2Book target filter + freshness checks) already cover staleness. useEffect(() => { const coin = activeTradeInstrument.coin; diff --git a/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts b/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts index ff4584bfa174..3e8a3f7096da 100644 --- a/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts +++ b/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts @@ -22,6 +22,7 @@ import { getPerpsL2BookColdCacheGlobalSnapshot, getPerpsL2BookInteractiveRefreshDelayMs, hasL2BookLevels, + isL2BookForTarget, isPerpsL2BookInteractive, } from '../utils/l2BookFreshness'; @@ -75,7 +76,8 @@ export function getFreshL2BookSnapshotFromSwr({ for (const key of keys) { const entry = swrCacheUtils.getWithTimestamp(key); if ( - entry?.data?.coin === coin && + entry && + isL2BookForTarget(entry.data, coin, options) && Date.now() - entry.updatedAt <= PERPS_L2_BOOK_SWR_CACHE_MAX_AGE_MS ) { return withPerpsL2BookLocalReceivedAt(entry.data, entry.updatedAt); @@ -109,6 +111,8 @@ export function normalizeL2BookData({ coin: bookData.coin, time: bookData.time, levels: bookData.levels, + nSigFigs: bookData.nSigFigs, + mantissa: bookData.mantissa, localReceivedAt: getPerpsMarketDataLocalReceivedAt(bookData), bids: bids || [], asks: asks || [], @@ -149,7 +153,13 @@ export function useL2Book(options?: IL2BookOptions): { const l2Book = useMemo((): IL2BookData | null => { let bookData: HL.IBook | null | undefined; - if (l2BookData?.coin === expectedCoin && hasL2BookLevels(l2BookData)) { + if ( + isL2BookForTarget(l2BookData, expectedCoin, { + nSigFigs, + mantissa, + }) && + hasL2BookLevels(l2BookData) + ) { bookData = l2BookData; } else if (expectedCoin) { const cacheOptions = { diff --git a/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts b/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts index 24c42fbbe4ca..15d3387ad400 100644 --- a/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts +++ b/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts @@ -6,6 +6,7 @@ import { getPerpsL2BookColdCacheGlobalSnapshot, getPerpsL2BookInteractiveRefreshDelayMs, hasL2BookLevels, + isL2BookForTarget, isPerpsL2BookInteractive, } from './l2BookFreshness'; @@ -63,12 +64,39 @@ describe('hasL2BookLevels', () => { }); }); +describe('isL2BookForTarget', () => { + it('requires both coin and source precision to match', () => { + const book = { + ...buildBook({}), + nSigFigs: 5, + mantissa: 5, + }; + + expect(isL2BookForTarget(book, 'ETH', { nSigFigs: 5, mantissa: 5 })).toBe( + true, + ); + expect(isL2BookForTarget(book, 'ETH', { nSigFigs: 5, mantissa: 2 })).toBe( + false, + ); + expect( + isL2BookForTarget(buildBook({}), 'ETH', { + nSigFigs: 5, + mantissa: 2, + }), + ).toBe(false); + }); +}); + describe('getFreshL2BookSnapshotFromColdCache', () => { - it('falls back from option-specific lookup to the latest coin snapshot', () => { - const book = buildBook({ - bidLevels: [{ px: '1', sz: '1', n: 1 }], - askLevels: [{ px: '2', sz: '1', n: 1 }], - }); + it('falls back only to a latest coin snapshot with matching precision', () => { + const book = { + ...buildBook({ + bidLevels: [{ px: '1', sz: '1', n: 1 }], + askLevels: [{ px: '2', sz: '1', n: 1 }], + }), + nSigFigs: 5, + mantissa: null, + }; expect( getFreshL2BookSnapshotFromColdCache({ diff --git a/packages/kit/src/views/Perp/utils/l2BookFreshness.ts b/packages/kit/src/views/Perp/utils/l2BookFreshness.ts index 09d32e09a4fa..ebdd7002f04b 100644 --- a/packages/kit/src/views/Perp/utils/l2BookFreshness.ts +++ b/packages/kit/src/views/Perp/utils/l2BookFreshness.ts @@ -29,6 +29,20 @@ export function hasL2BookLevels(bookData: HL.IBook | null | undefined) { ); } +export function isL2BookForTarget( + book: HL.IBook | null | undefined, + coin: string, + options?: IL2BookOptions, +) { + return ( + book?.coin === coin && + book.nSigFigs !== undefined && + book.mantissa !== undefined && + (book.nSigFigs ?? null) === (options?.nSigFigs ?? null) && + (book.mantissa ?? null) === (options?.mantissa ?? null) + ); +} + export function getFreshL2BookSnapshotFromColdCache({ coin, options, @@ -51,7 +65,7 @@ export function getFreshL2BookSnapshotFromColdCache({ for (const key of keys) { const entry = cache[key]; if ( - entry?.data?.coin === coin && + isL2BookForTarget(entry?.data, coin, options) && Date.now() - entry.updatedAt <= maxAgeMs ) { return entry.data; diff --git a/packages/shared/types/hyperliquid/sdk.ts b/packages/shared/types/hyperliquid/sdk.ts index e7c28e524313..4a1ee841fbd7 100644 --- a/packages/shared/types/hyperliquid/sdk.ts +++ b/packages/shared/types/hyperliquid/sdk.ts @@ -148,7 +148,10 @@ export type IWebSocketTransport = HL.WebSocketTransport; // Market data types export type IAllMids = HL.AllMidsResponse; export type ICandle = HL.CandleSnapshotResponse[number]; -export type IBook = HL.L2BookWsEvent; +export type IBook = HL.L2BookWsEvent & { + nSigFigs?: number | null; + mantissa?: number | null; +}; export type IBookLevel = IBook['levels'][number][number]; export type IFill = HL.UserFillsResponse[number]; @@ -177,6 +180,11 @@ export type IWsAllMidsParameters = HL.AllMidsWsParameters; export type IEventActiveAssetCtxParameters = HL.ActiveAssetCtxWsParameters; export type IEventActiveAssetDataParameters = HL.ActiveAssetDataWsParameters; export type IEventL2BookParameters = HL.L2BookWsParameters; +export type IEventFastL2Parameters = { + c: string; + s?: IEventL2BookParameters['nSigFigs']; + m?: IEventL2BookParameters['mantissa']; +}; export type IEventBboParameters = HL.BboWsParameters; export type IEventWebData2Parameters = HL.WebData2WsParameters; export type IEventUserFillsParameters = HL.UserFillsWsParameters; @@ -202,6 +210,7 @@ export type ISignature = unknown; export type IPerpsSubscriptionParams = { [ESubscriptionType.L2_BOOK]: IEventL2BookParameters; + [ESubscriptionType.L2]: IEventFastL2Parameters; [ESubscriptionType.BBO]: IEventBboParameters; [ESubscriptionType.USER_FILLS]: IEventUserFillsParameters; [ESubscriptionType.USER_NON_FUNDING_LEDGER_UPDATES]: IEventUserNonFundingLedgerUpdatesParameters; diff --git a/packages/shared/types/hyperliquid/types.ts b/packages/shared/types/hyperliquid/types.ts index 08272f5d260b..df01bd96cc8b 100644 --- a/packages/shared/types/hyperliquid/types.ts +++ b/packages/shared/types/hyperliquid/types.ts @@ -8,6 +8,7 @@ export enum EPerpsSubscriptionCategory { export enum ESubscriptionType { ALL_MIDS = 'allMids', L2_BOOK = 'l2Book', + L2 = 'l2', ACTIVE_ASSET_CTX = 'activeAssetCtx', ACTIVE_ASSET_DATA = 'activeAssetData', WEB_DATA2 = 'webData2', diff --git a/yarn.lock b/yarn.lock index 48616e6d879c..88221c8ae93d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10153,6 +10153,7 @@ __metadata: resolution: "@onekeyhq/kit-bg@workspace:packages/kit-bg" dependencies: eth-url-parser: "npm:^1.0.4" + fflate: "npm:0.8.2" idb: "npm:^7.1.1" ipaddr.js: "npm:^2.3.0" miscreant: "npm:^0.3.2" @@ -30861,6 +30862,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10/2bd26ba6d235d428de793c6a0cd1aaa96a06269ebd4e21b46c8fd1bd136abc631acf27e188d47c3936db090bf3e1ede11d15ce9eae9bffdc4bfe1b9dc66ca9cb + languageName: node + linkType: hard + "fflate@npm:^0.4.8": version: 0.4.8 resolution: "fflate@npm:0.4.8" From 6dcd0425b68da7704daf720713b44dd805146a61 Mon Sep 17 00:00:00 2001 From: Zen Date: Tue, 14 Jul 2026 12:19:42 +0800 Subject: [PATCH 02/24] fix: preserve order book identity during transitions Constraint: Fast L2 recovery must keep l2Book fallback safe across asynchronous coin and precision changes. Rejected: Label fallback frames from desired UI state | old wire subscriptions can still emit while teardown is in progress. Confidence: high Scope-risk: narrow Directive: Keep actual wire subscription identity separate from desired subscription state. Tested: 49 targeted Jest tests; lint-worktree-ts; lint-staged. Not-tested: Full tsc-staged remains blocked by the existing AutoSizeInput.native.tsx mostRecentEventCount type error on the release baseline. --- .../ServiceHyperliquidCache.test.ts | 46 ++++++++++++++++ .../ServiceHyperliquidCache.ts | 16 ++++-- .../ServiceHyperliquidSubscription.ts | 53 +++++++++++++++---- .../utils/SubscriptionConfig.test.ts | 46 ++++++++++++++++ .../utils/SubscriptionConfig.ts | 15 ++++++ .../utils/SubscriptionReconcileQueue.test.ts | 22 ++++++++ .../utils/SubscriptionReconcileQueue.ts | 7 +-- 7 files changed, 187 insertions(+), 18 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts index 49e2600cd5e0..5335841f5c3b 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts @@ -1,8 +1,13 @@ +import { + swrCacheUtils, + swrKeys, +} from '@onekeyhq/shared/src/utils/swrCacheUtils'; import type { IBook } from '@onekeyhq/shared/types/hyperliquid/sdk'; import { buildL2BookSnapshotCachePayload, getL2BookSnapshotCacheEntryLevelCount, + getL2BookSnapshotSwrCache, selectL2BookSnapshotCacheEntry, shouldWritePerpsAccountDisplayCache, } from './ServiceHyperliquidCache'; @@ -51,6 +56,11 @@ function buildEntry({ } describe('ServiceHyperliquidCache L2 book helpers', () => { + afterEach(() => { + swrCacheUtils.clearAll(); + swrCacheUtils.flushNow(); + }); + it('counts the shallower side of the cached L2 book', () => { expect( getL2BookSnapshotCacheEntryLevelCount( @@ -129,6 +139,42 @@ describe('ServiceHyperliquidCache L2 book helpers', () => { }), ).toMatchObject({ nSigFigs: 5, mantissa: 5 }); }); + + it('rejects a latest snapshot cached for another precision', () => { + const data = Object.assign( + buildBook({ coin: 'ETH', bidLevels: 20, askLevels: 20 }), + { nSigFigs: 5, mantissa: 5 }, + ); + swrCacheUtils.set(swrKeys.perpsL2BookSnapshotLatest({ coin: 'ETH' }), data); + + expect( + getL2BookSnapshotSwrCache({ + coin: 'ETH', + nSigFigs: 5, + mantissa: 2, + maxAgeMs: 60_000, + }), + ).toBeUndefined(); + }); + + it('preserves source precision when using an untargeted latest snapshot', () => { + const data = Object.assign( + buildBook({ coin: 'ETH', bidLevels: 20, askLevels: 20 }), + { nSigFigs: 5, mantissa: 5 }, + ); + swrCacheUtils.set(swrKeys.perpsL2BookSnapshotLatest({ coin: 'ETH' }), data); + + expect( + getL2BookSnapshotSwrCache({ + coin: 'ETH', + maxAgeMs: 60_000, + }), + ).toMatchObject({ + data, + nSigFigs: 5, + mantissa: 5, + }); + }); }); describe('ServiceHyperliquidCache account display write throttle', () => { diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts index 21a3efda180a..263fb3f19a5a 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts @@ -159,7 +159,7 @@ export function buildL2BookSnapshotCachePayload({ }; } -function getL2BookSnapshotSwrCache({ +export function getL2BookSnapshotSwrCache({ coin, nSigFigs, mantissa, @@ -175,17 +175,25 @@ function getL2BookSnapshotSwrCache({ nSigFigs, mantissa, }); + const hasRequestedPrecision = + nSigFigs !== undefined || mantissa !== undefined; for (const key of keys) { const entry = swrCacheUtils.getWithTimestamp(key); + const sourceNSigFigs = entry?.data?.nSigFigs ?? null; + const sourceMantissa = entry?.data?.mantissa ?? null; + const matchesRequestedPrecision = + sourceNSigFigs === (nSigFigs ?? null) && + sourceMantissa === (mantissa ?? null); if ( entry?.data?.coin === coin && - Date.now() - entry.updatedAt <= maxAgeMs + Date.now() - entry.updatedAt <= maxAgeMs && + (!hasRequestedPrecision || matchesRequestedPrecision) ) { return { data: entry.data, updatedAt: entry.updatedAt, - nSigFigs, - mantissa, + nSigFigs: sourceNSigFigs, + mantissa: sourceMantissa, }; } } diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index fa6d97c44cda..ca2b94ebcc9a 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -84,6 +84,7 @@ import { SUBSCRIPTION_TYPE_INFO, calculateRequiredSubscriptionsMap, isOrderBookOptionsTargetReady, + normalizeL2BookForSubscriptionSpec, } from './utils/SubscriptionConfig'; import { PerKeyMutationQueue } from './utils/SubscriptionMutationQueue'; import { LatestSubscriptionReconcileQueue } from './utils/SubscriptionReconcileQueue'; @@ -201,6 +202,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _activeSubscriptions = new Map(); + private _activeL2BookSpec: ISubscriptionSpec | null = + null; + private _fastL2Book: FastL2Book | null = null; private _fastL2SubscriptionKey: string | null = null; @@ -282,6 +286,13 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._fastL2SubscriptionKey = null; } + private _clearActiveL2BookSpec(subscriptionKey?: string): void { + if (subscriptionKey && this._activeL2BookSpec?.key !== subscriptionKey) { + return; + } + this._activeL2BookSpec = null; + } + private _resetFastL2Recovery(): void { this._fastL2TargetKey = null; this._fastL2FallbackTargetKey = null; @@ -1685,6 +1696,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private async _closeClient(): Promise { this._unwatchSubscriptionAtoms(); + this._clearActiveL2BookSpec(); if (this._client) { try { // TODO remove all eventListeners @@ -1840,6 +1852,10 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { lastActivity: Date.now(), isActive: true, }); + if (spec.type === ESubscriptionType.L2_BOOK) { + this._activeL2BookSpec = + spec as ISubscriptionSpec; + } if (spec.type === ESubscriptionType.L2) { this._startFastL2SnapshotTimer(spec.key); } @@ -1885,6 +1901,11 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._resetFastL2Book(spec.key); } const shouldRemoveCache = options?.removeCache ?? true; + const clearActiveL2BookSpec = () => { + if (spec.type === ESubscriptionType.L2_BOOK) { + this._clearActiveL2BookSpec(spec.key); + } + }; const removeSubCache = () => { if (!shouldRemoveCache) { return; @@ -1896,16 +1917,19 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._destroyingSubscriptionKeys.add(spec.key); const client = targetClient ?? (await this.getWebSocketClient()); if (!client) { + clearActiveL2BookSpec(); removeSubCache(); return true; } // await sdkSub.unsubscribe(); await client.unsubscribe(spec.type, spec.params); + clearActiveL2BookSpec(); removeSubCache(); return true; } catch (error) { const e = error as OneKeyError | undefined; if (e?.message?.includes('Already unsubscribed')) { + clearActiveL2BookSpec(); removeSubCache(); return true; } @@ -2018,7 +2042,10 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } const messageTimestamp = Date.now(); - if (subscriptionType !== ESubscriptionType.L2) { + if ( + subscriptionType !== ESubscriptionType.L2 && + subscriptionType !== ESubscriptionType.L2_BOOK + ) { this._markSubscriptionActivity(subscriptionType, messageTimestamp); } markPerpsColdStartPerfOnce(`service_ws_first_${subscriptionType}`, { @@ -2251,18 +2278,22 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { normalizedBook, ); } else if (subscriptionType === ESubscriptionType.L2_BOOK) { - const normalizedBook = { - ...(data as IBook), - nSigFigs: this._currentState.l2BookOptions?.nSigFigs ?? null, - mantissa: this._currentState.l2BookOptions?.mantissa ?? null, - }; + const activeL2BookSpec = this._activeL2BookSpec; + const normalizedBook = normalizeL2BookForSubscriptionSpec( + data as IBook, + activeL2BookSpec, + ); + if (!normalizedBook || !activeL2BookSpec) { + return; + } + this._markSubscriptionActivity(subscriptionType, messageTimestamp); this.backgroundApi.serviceHyperliquidCache.cacheL2BookSnapshot({ data: normalizedBook, - activeBookCoin: - this._currentState.tradingMode === 'spot' - ? this._currentState.currentSpotSymbol - : this._currentState.currentSymbol, - activeOptions: this._currentState.l2BookOptions, + activeBookCoin: activeL2BookSpec.params.coin, + activeOptions: { + nSigFigs: activeL2BookSpec.params.nSigFigs, + mantissa: activeL2BookSpec.params.mantissa, + }, }); this._emitHyperliquidDataUpdate(subscriptionType, normalizedBook); } else { diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts index e02083e2afb3..486b8bc95e77 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts @@ -1,10 +1,14 @@ +import type { IBook } from '@onekeyhq/shared/types/hyperliquid/sdk'; import { ESubscriptionType } from '@onekeyhq/shared/types/hyperliquid/types'; import { calculateRequiredSubscriptions, isOrderBookOptionsTargetReady, + normalizeL2BookForSubscriptionSpec, } from './SubscriptionConfig'; +import type { ISubscriptionSpec } from './SubscriptionConfig'; + describe('isOrderBookOptionsTargetReady', () => { it('waits for order book options to catch up with the active coin', () => { expect(isOrderBookOptionsTargetReady('ETH', 'BTC')).toBe(false); @@ -13,6 +17,48 @@ describe('isOrderBookOptionsTargetReady', () => { }); }); +describe('normalizeL2BookForSubscriptionSpec', () => { + const data: IBook = { + coin: 'ETH', + time: 1, + levels: [[], []], + }; + + it('uses the precision of the active wire subscription', () => { + const spec = calculateRequiredSubscriptions({ + currentUser: null, + currentSymbol: 'ETH', + isConnected: true, + orderBookTransport: 'l2Book', + l2BookOptions: { nSigFigs: 5, mantissa: 2 }, + }).find( + (item) => item.type === ESubscriptionType.L2_BOOK, + ) as ISubscriptionSpec; + + expect(normalizeL2BookForSubscriptionSpec(data, spec)).toMatchObject({ + coin: 'ETH', + nSigFigs: 5, + mantissa: 2, + }); + }); + + it('drops frames when no matching wire subscription is active', () => { + expect(normalizeL2BookForSubscriptionSpec(data, null)).toBeUndefined(); + + const spec = calculateRequiredSubscriptions({ + currentUser: null, + currentSymbol: 'BTC', + isConnected: true, + orderBookTransport: 'l2Book', + l2BookOptions: { nSigFigs: 5, mantissa: 2 }, + }).find( + (item) => item.type === ESubscriptionType.L2_BOOK, + ) as ISubscriptionSpec; + + expect(normalizeL2BookForSubscriptionSpec(data, spec)).toBeUndefined(); + }); +}); + describe('calculateRequiredSubscriptions', () => { it('always subscribes to all dex asset contexts for token selector prices', () => { const specs = calculateRequiredSubscriptions({ diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts index 9e35767e44d9..ab376fc83f60 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts @@ -2,6 +2,7 @@ import { PERPS_EMPTY_ADDRESS } from '@onekeyhq/shared/src/consts/perp'; import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; import { DEX_PREFIXES } from '@onekeyhq/shared/types/hyperliquid/perp.constants'; import type { + IBook, IEventAllDexsClearinghouseStateParameters, IEventFastL2Parameters, IEventL2BookParameters, @@ -114,6 +115,20 @@ export interface ISubscriptionSpec { readonly priority: number; } +export function normalizeL2BookForSubscriptionSpec( + data: IBook, + spec: ISubscriptionSpec | null, +): IBook | undefined { + if (!spec || data.coin !== spec.params.coin) { + return undefined; + } + return { + ...data, + nSigFigs: spec.params.nSigFigs ?? null, + mantissa: spec.params.mantissa ?? null, + }; +} + export interface ISubscriptionState { currentUser: IHex | null; currentSymbol: string; diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts index b391fb9dfed1..e8740a9eb1fc 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.test.ts @@ -29,4 +29,26 @@ describe('LatestSubscriptionReconcileQueue', () => { expect(events).toEqual(['first:start', 'first:end', 'latest']); }); + + it('does not strand a reconcile queued while the current drain settles', async () => { + const queue = new LatestSubscriptionReconcileQueue(); + const events: string[] = []; + let lateReconcile: Promise | undefined; + + const firstReconcile = queue.enqueue(async () => { + events.push('first'); + queueMicrotask(() => { + queueMicrotask(() => { + lateReconcile = queue.enqueue(async () => { + events.push('late'); + }); + }); + }); + }); + + await firstReconcile; + await lateReconcile; + + expect(events).toEqual(['first', 'late']); + }); }); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts index c829d66ae88b..82a9587a5b45 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionReconcileQueue.ts @@ -8,9 +8,7 @@ export class LatestSubscriptionReconcileQueue { enqueue(task: () => Promise): Promise { this.pendingTask = task; if (!this.runningPromise) { - this.runningPromise = this.drain().finally(() => { - this.runningPromise = null; - }); + this.runningPromise = this.drain(); } return this.runningPromise; } @@ -27,6 +25,9 @@ export class LatestSubscriptionReconcileQueue { error instanceof Error ? error : new Error(String(error)); } } + // Clear the running marker before this async function settles. A task + // queued by an already-scheduled microtask will then start a new drain. + this.runningPromise = null; if (firstError) { throw new OneKeyLocalError(firstError.message); } From 2f60fb7a801982020b8b52186711577e718a269a Mon Sep 17 00:00:00 2001 From: Zen Date: Tue, 14 Jul 2026 15:00:44 +0800 Subject: [PATCH 03/24] fix: refresh order book when precision changes Constraint: Identical order book levels may be emitted for different precision targets during a subscription transition. Rejected: Rely on timestamp freshness to replace the book | the new target can arrive inside the dedupe interval. Confidence: high Scope-risk: narrow Directive: Treat coin and source precision as part of order book identity before content deduplication. Tested: 64 targeted Jest tests; lint-worktree-ts; lint-staged. Not-tested: Full tsc-staged remains blocked by the existing AutoSizeInput.native.tsx mostRecentEventCount type error on the release baseline. --- .../hyperliquid/utils/l2BookUtils.test.ts | 38 +++++++++++++++++++ .../contexts/hyperliquid/utils/l2BookUtils.ts | 7 ++++ 2 files changed, 45 insertions(+) diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts index c01d969909f3..27ece694297e 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts @@ -14,17 +14,23 @@ function buildBook({ coin = 'ETH', bidPx = '100', askPx = '101', + nSigFigs, + mantissa, levels, }: { time?: number; coin?: string; bidPx?: string; askPx?: string; + nSigFigs?: number | null; + mantissa?: number | null; levels?: HL.IBook['levels']; }): HL.IBook { return { coin, time: time as number, + nSigFigs, + mantissa, levels: levels ?? [ [{ px: bidPx, sz: '1', n: 1 }], [{ px: askPx, sz: '2', n: 1 }], @@ -114,6 +120,38 @@ describe('shouldUpdatePerpsL2Book', () => { ).toBe(true); }); + it('updates identical levels when source precision changes', () => { + expect( + shouldUpdatePerpsL2Book({ + currentBook: buildBook({ + time: 1000, + nSigFigs: 5, + mantissa: 2, + }), + nextBook: buildBook({ + time: 1001, + nSigFigs: 5, + mantissa: 5, + }), + }), + ).toBe(true); + + expect( + shouldUpdatePerpsL2Book({ + currentBook: buildBook({ + time: 1000, + nSigFigs: 4, + mantissa: null, + }), + nextBook: buildBook({ + time: 1001, + nSigFigs: 5, + mantissa: null, + }), + }), + ).toBe(true); + }); + it('updates when side or level counts change', () => { expect( shouldUpdatePerpsL2Book({ diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts index f47b3d9ddb29..84377b5494c9 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts @@ -103,6 +103,13 @@ export function shouldUpdatePerpsL2Book({ return true; } + if ( + (currentBook?.nSigFigs ?? null) !== (nextBook.nSigFigs ?? null) || + (currentBook?.mantissa ?? null) !== (nextBook.mantissa ?? null) + ) { + return true; + } + const currentTime = currentBook?.time; const nextTime = nextBook.time; const hasCurrentTime = From 1958aa99b4a72e42af2e519f027d888a6dbe9f4e Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 17:27:24 +0800 Subject: [PATCH 04/24] fix: prevent stale spot order book sync Guard the async order-book option read before publishing so an older iOS spot switch cannot overwrite the latest coin target. Constraint: Native main and bg runtimes synchronize global atoms asynchronously during rapid spot switching. Rejected: Retry subscriptions in bg | the stale main-runtime option write is the source of the target mismatch. Confidence: high Scope-risk: narrow Directive: Revalidate the active instrument after awaited work before publishing subscription targets. Tested: 8 targeted Jest suites, 66 tests; lint-worktree-ts; lint-staged; git diff --check. Not-tested: iOS device QA; tsc-staged is blocked by the existing AutoSizeInput.native.tsx mostRecentEventCount type error. --- .../jotai/contexts/hyperliquid/actions.ts | 15 ++++--- .../utils/instrumentSwitch.test.ts | 44 +++++++++++++++++++ .../hyperliquid/utils/instrumentSwitch.ts | 19 ++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts create mode 100644 packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 36122ac7c592..66bbd534835e 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -1,7 +1,7 @@ import { useRef } from 'react'; import { BigNumber } from 'bignumber.js'; -import { isEqual, isNil } from 'lodash'; +import { isNil } from 'lodash'; import { Toast } from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; @@ -121,6 +121,7 @@ import { shouldResetOpenOrdersForAccount, sortActivePerpsPositions, } from './utils/coldStartMergeUtils'; +import { publishLatestOrderBookOptions } from './utils/instrumentSwitch'; import { shouldClearPerpsMarketDataForInstrument, shouldUpdatePerpsBbo, @@ -532,11 +533,13 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { nSigFigs: stored?.nSigFigs ?? null, mantissa: stored?.mantissa ?? null, }; - const prevOrderBookOptions = await perpsActiveOrderBookOptionsAtom.get(); - if (!isEqual(prevOrderBookOptions, nextOrderBookOptions)) { - await perpsActiveOrderBookOptionsAtom.set(() => nextOrderBookOptions); - } - if (!this.isLatestActiveInstrumentChange(params.requestId)) { + const isLatest = await publishLatestOrderBookOptions({ + read: () => perpsActiveOrderBookOptionsAtom.get(), + write: (value) => perpsActiveOrderBookOptionsAtom.set(() => value), + next: nextOrderBookOptions, + isLatest: () => this.isLatestActiveInstrumentChange(params.requestId), + }); + if (!isLatest) { return; } diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts new file mode 100644 index 000000000000..5bc73962f5c1 --- /dev/null +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts @@ -0,0 +1,44 @@ +import { publishLatestOrderBookOptions } from './instrumentSwitch'; + +describe('publishLatestOrderBookOptions', () => { + it('does not publish stale options after a newer instrument switch starts', async () => { + let resolveRead: (value: { coin: string } | undefined) => void = () => + undefined; + const read = new Promise<{ coin: string } | undefined>((resolve) => { + resolveRead = resolve; + }); + const write = jest.fn, [{ coin: string }]>(() => + Promise.resolve(), + ); + let latestRequestId = 1; + + const publish = publishLatestOrderBookOptions({ + read: () => read, + write, + next: { coin: '@107' }, + isLatest: () => latestRequestId === 1, + }); + + latestRequestId = 2; + resolveRead(undefined); + + await expect(publish).resolves.toBe(false); + expect(write).not.toHaveBeenCalled(); + }); + + it('publishes options when the instrument switch is still current', async () => { + const write = jest.fn, [{ coin: string }]>(() => + Promise.resolve(), + ); + + await expect( + publishLatestOrderBookOptions({ + read: () => Promise.resolve({ coin: '@106' }), + write, + next: { coin: '@107' }, + isLatest: () => true, + }), + ).resolves.toBe(true); + expect(write).toHaveBeenCalledWith({ coin: '@107' }); + }); +}); diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts new file mode 100644 index 000000000000..cc85515861cb --- /dev/null +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts @@ -0,0 +1,19 @@ +import { isEqual } from 'lodash'; + +export async function publishLatestOrderBookOptions(params: { + read: () => Promise; + write: (value: T) => Promise; + next: T; + isLatest: () => boolean; +}): Promise { + const previous = await params.read(); + if (!params.isLatest()) { + return false; + } + + if (!isEqual(previous, params.next)) { + await params.write(params.next); + } + + return params.isLatest(); +} From eaf0ce20256cb69b8307901c8f8c0c7dfe7e8121 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 18:17:58 +0800 Subject: [PATCH 05/24] fix: stabilize spot order book transitions Load persisted precision before publishing a spot subscription target, keep existing selections authoritative, and hide provisional first frames until the final precision is initialized. Add scoped main/bg diagnostics for bundle verification. Constraint: iOS main and background runtimes initialize independently during rapid spot switching. Rejected: render a mismatched provisional snapshot | it recreates the incorrect-price flash. Confidence: high Scope-risk: moderate Directive: remove orderBookSwitchDiagnostic after bundle logs confirm stable switching. Tested: 8 targeted Jest suites (56 tests), lint-worktree-ts, lint-staged, git diff --check. Not-tested: iOS bundle; tsc-staged is blocked only by the pre-existing AutoSizeInput.native.tsx mostRecentEventCount error. --- .../ServiceHyperliquidSubscription.ts | 120 +++++++++++++++++- .../jotai/contexts/hyperliquid/actions.ts | 66 ++++++++++ .../OrderBook/tickSizeUtils.test.ts | 21 ++- .../components/OrderBook/tickSizeUtils.ts | 12 +- .../components/OrderBook/useTickOptions.ts | 5 +- .../views/Perp/components/PerpOrderBook.tsx | 47 ++++++- .../logger/scopes/perp/scenes/hyperliquid.ts | 21 +++ 7 files changed, 261 insertions(+), 31 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index ca2b94ebcc9a..439744ac6ede 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -221,6 +221,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _fastL2RecoveryPromise: Promise | null = null; + private _orderBookDiagnosticSequence = 0; + private static readonly FAST_L2_SNAPSHOT_TIMEOUT_MS = 3000; private static readonly FAST_L2_RECOVERY_DELAYS_MS = [0, 1000, 3000]; @@ -230,6 +232,24 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private static readonly ORDER_BOOK_STRATEGY: 'fastL2Primary' | 'l2BookOnly' = 'fastL2Primary'; + private _logOrderBookSwitchDiagnostic( + event: string, + params: Omit< + Parameters< + typeof defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic + >[0], + 'runtime' | 'event' | 'sequence' + > = {}, + ): void { + this._orderBookDiagnosticSequence += 1; + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'bg', + event, + sequence: this._orderBookDiagnosticSequence, + ...params, + }); + } + // Cross-runtime atom sync can lag behind a reopened socket, leaving current // market subscriptions absent while the socket still looks healthy. private _subscriptionAtomsUnsubs: Array<() => void> = []; @@ -256,21 +276,22 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } this._unwatchSubscriptionAtoms(); - const handler = () => { + const handler = (source: string) => { const client = this._client; if (!client || client.transport?.socket?.readyState !== WebSocket.OPEN) { return; } + this._logOrderBookSwitchDiagnostic('atom-change', { source }); console.log('updateSubscriptions__by__atomWatcher'); void this.updateSubscriptions(); }; this._subscriptionAtomsUnsubs = [ - perpsActiveAccountAtom.sub(handler), - perpsActiveAssetAtom.sub(handler), - spotActiveAssetAtom.sub(handler), - tradingModeAtom.sub(handler), - perpsActiveOrderBookOptionsAtom.sub(handler), + perpsActiveAccountAtom.sub(() => handler('active-account')), + perpsActiveAssetAtom.sub(() => handler('active-asset')), + spotActiveAssetAtom.sub(() => handler('spot-active-asset')), + tradingModeAtom.sub(() => handler('trading-mode')), + perpsActiveOrderBookOptionsAtom.sub(() => handler('order-book-options')), ]; } @@ -450,6 +471,11 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if ( !isOrderBookOptionsTargetReady(currentCoin, activeOrderBookOptions?.coin) ) { + this._logOrderBookSwitchDiagnostic('reconcile-blocked-target-mismatch', { + mode: currentMode, + coin: currentCoin, + source: activeOrderBookOptions?.coin ?? 'none', + }); return undefined; } const isOrderBookOptionsForCurrentCoin = @@ -580,6 +606,38 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._applyStateUpdates(newState, requiredSubInfo.params); this._currentState = newState; + const orderBookSpec = Object.values( + requiredSubInfo.requiredSubSpecsMap, + ).find( + (spec) => + spec.type === ESubscriptionType.L2 || + spec.type === ESubscriptionType.L2_BOOK, + ); + const fastL2Spec = + orderBookSpec?.type === ESubscriptionType.L2 + ? (orderBookSpec as ISubscriptionSpec) + : undefined; + const l2BookSpec = + orderBookSpec?.type === ESubscriptionType.L2_BOOK + ? (orderBookSpec as ISubscriptionSpec) + : undefined; + let orderBookTransport: 'l2' | 'l2Book' | 'none' = 'none'; + if (fastL2Spec) { + orderBookTransport = 'l2'; + } else if (l2BookSpec) { + orderBookTransport = 'l2Book'; + } + this._logOrderBookSwitchDiagnostic('reconcile-target', { + mode: requiredSubInfo.params.tradingMode, + coin: fastL2Spec?.params.c ?? l2BookSpec?.params.coin, + nSigFigs: fastL2Spec + ? (fastL2Spec.params.s ?? null) + : (l2BookSpec?.params.nSigFigs ?? null), + mantissa: fastL2Spec + ? (fastL2Spec.params.m ?? null) + : (l2BookSpec?.params.mantissa ?? null), + transport: orderBookTransport, + }); this._emitConnectionStatus(); await this._executeSubscriptionChanges(); this._scheduleCriticalSubscriptionHealthCheck('update_subscriptions'); @@ -1826,6 +1884,13 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const lifecycleVersion = this._subscriptionLifecycleVersion; if (spec.type === ESubscriptionType.L2) { const fastL2Spec = spec as ISubscriptionSpec; + this._logOrderBookSwitchDiagnostic('subscribe-start', { + mode: this._currentState.tradingMode, + coin: fastL2Spec.params.c, + nSigFigs: fastL2Spec.params.s ?? null, + mantissa: fastL2Spec.params.m ?? null, + transport: 'l2', + }); this._prepareFastL2Book(fastL2Spec); } const client = await this._createSubscriptionDirect(spec); @@ -1857,6 +1922,15 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { spec as ISubscriptionSpec; } if (spec.type === ESubscriptionType.L2) { + const fastL2Spec = spec as ISubscriptionSpec; + this._logOrderBookSwitchDiagnostic('subscribe-complete', { + mode: this._currentState.tradingMode, + coin: fastL2Spec.params.c, + nSigFigs: fastL2Spec.params.s ?? null, + mantissa: fastL2Spec.params.m ?? null, + transport: 'l2', + hasBook: this._fastL2Book?.hasSnapshot ?? false, + }); this._startFastL2SnapshotTimer(spec.key); } } catch (error) { @@ -1897,7 +1971,18 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ): Promise { try { if (spec) { - if (spec.type === ESubscriptionType.L2) { + const fastL2Spec = + spec.type === ESubscriptionType.L2 + ? (spec as ISubscriptionSpec) + : undefined; + if (fastL2Spec) { + this._logOrderBookSwitchDiagnostic('unsubscribe-start', { + mode: this._currentState.tradingMode, + coin: fastL2Spec.params.c, + nSigFigs: fastL2Spec.params.s ?? null, + mantissa: fastL2Spec.params.m ?? null, + transport: 'l2', + }); this._resetFastL2Book(spec.key); } const shouldRemoveCache = options?.removeCache ?? true; @@ -1923,6 +2008,15 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } // await sdkSub.unsubscribe(); await client.unsubscribe(spec.type, spec.params); + if (fastL2Spec) { + this._logOrderBookSwitchDiagnostic('unsubscribe-complete', { + mode: this._currentState.tradingMode, + coin: fastL2Spec.params.c, + nSigFigs: fastL2Spec.params.s ?? null, + mantissa: fastL2Spec.params.m ?? null, + transport: 'l2', + }); + } clearActiveL2BookSpec(); removeSubCache(); return true; @@ -2255,6 +2349,18 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (!normalizedBook) { return; } + if ('s' in (data as IFastL2Frame)) { + this._logOrderBookSwitchDiagnostic('snapshot-applied', { + mode: this._currentState.tradingMode, + coin: normalizedBook.coin, + nSigFigs: normalizedBook.nSigFigs ?? null, + mantissa: normalizedBook.mantissa ?? null, + transport: 'l2', + hasBook: true, + bidLevels: normalizedBook.levels?.[0]?.length ?? 0, + askLevels: normalizedBook.levels?.[1]?.length ?? 0, + }); + } this._markSubscriptionActivity(subscriptionType, messageTimestamp); if (this._fastL2SnapshotTimer && fastL2Book.hasSnapshot) { clearTimeout(this._fastL2SnapshotTimer); diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 66bbd534835e..d99c4754efeb 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -533,12 +533,33 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { nSigFigs: stored?.nSigFigs ?? null, mantissa: stored?.mantissa ?? null, }; + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'options-sync-start', + requestId: params.requestId, + mode: params.instrument.mode, + coin: params.instrument.coin, + nSigFigs: nextOrderBookOptions.nSigFigs, + mantissa: nextOrderBookOptions.mantissa, + hasTickOption: Boolean(stored), + latest: this.isLatestActiveInstrumentChange(params.requestId), + }); const isLatest = await publishLatestOrderBookOptions({ read: () => perpsActiveOrderBookOptionsAtom.get(), write: (value) => perpsActiveOrderBookOptionsAtom.set(() => value), next: nextOrderBookOptions, isLatest: () => this.isLatestActiveInstrumentChange(params.requestId), }); + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'options-sync-result', + requestId: params.requestId, + mode: params.instrument.mode, + coin: params.instrument.coin, + nSigFigs: nextOrderBookOptions.nSigFigs, + mantissa: nextOrderBookOptions.mantissa, + latest: isLatest, + }); if (!isLatest) { return; } @@ -1693,6 +1714,13 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { }, ) => { const requestId = existingRequestId ?? this.beginActiveInstrumentChange(); + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'spot-switch-start', + requestId, + mode: 'spot', + coin, + }); const optimisticInstrument: IActiveTradeInstrument = { mode: 'spot', coin, @@ -1732,6 +1760,33 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { } } + await this.ensureOrderBookTickOptionsLoaded.call(set); + if (!this.isLatestActiveInstrumentChange(requestId)) { + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'spot-switch-stale-after-options-load', + requestId, + mode: 'spot', + coin, + latest: false, + }); + return false; + } + const storedTickOption = getPerpsOrderBookTickOptionWithCache({ + coin, + options: get(orderBookTickOptionsAtom()), + }); + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'spot-switch-options-ready', + requestId, + mode: 'spot', + coin, + nSigFigs: storedTickOption?.nSigFigs ?? null, + mantissa: storedTickOption?.mantissa ?? null, + hasTickOption: Boolean(storedTickOption), + latest: true, + }); if (!(await this.waitForActiveInstrumentChangeSettle(requestId))) { return false; } @@ -1749,6 +1804,17 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { if (!this.isLatestActiveInstrumentChange(requestId)) { return false; } + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'spot-switch-target-published', + requestId, + mode: 'spot', + coin, + nSigFigs: storedTickOption?.nSigFigs ?? null, + mantissa: storedTickOption?.mantissa ?? null, + hasTickOption: Boolean(storedTickOption), + latest: true, + }); void this.syncSubscriptionsAfterInstrumentChange({ instrument: optimisticInstrument, orderBookTickOptions: get(orderBookTickOptionsAtom()), diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts index ef2f11df50f4..73cbea8a95f0 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts @@ -4,26 +4,33 @@ import { getDisplayPriceScaleDecimals } from '@onekeyhq/shared/src/utils/perpsUt import { buildTickOptions, - shouldPersistOrderBookTickOption, + shouldInitializeOrderBookTickOption, } from './tickSizeUtils'; -describe('shouldPersistOrderBookTickOption', () => { +describe('shouldInitializeOrderBookTickOption', () => { it('does not replace precision while order book data is transitioning', () => { expect( - shouldPersistOrderBookTickOption({ + shouldInitializeOrderBookTickOption({ hasMarketData: false, persisted: { value: '0.2', nSigFigs: 5, mantissa: 2 }, - selected: { value: '100', nSigFigs: 2, mantissa: null }, }), ).toBe(false); }); - it('persists a changed selection derived from live market data', () => { + it('does not overwrite a persisted selection from a live market frame', () => { expect( - shouldPersistOrderBookTickOption({ + shouldInitializeOrderBookTickOption({ hasMarketData: true, persisted: { value: '0.1', nSigFigs: 5, mantissa: null }, - selected: { value: '0.2', nSigFigs: 5, mantissa: 2 }, + }), + ).toBe(false); + }); + + it('initializes a missing selection after market data arrives', () => { + expect( + shouldInitializeOrderBookTickOption({ + hasMarketData: true, + persisted: undefined, }), ).toBe(true); }); diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts index b03549b03dd8..eb70b969cc29 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts @@ -29,22 +29,14 @@ type IOrderBookTickOptionState = { mantissa?: IMantissa | null; }; -export function shouldPersistOrderBookTickOption({ +export function shouldInitializeOrderBookTickOption({ hasMarketData, persisted, - selected, }: { hasMarketData: boolean; persisted: IOrderBookTickOptionState | undefined; - selected: IOrderBookTickOptionState; }) { - return ( - hasMarketData && - (!persisted || - persisted.value !== selected.value || - persisted.nSigFigs !== selected.nSigFigs || - persisted.mantissa !== selected.mantissa) - ); + return hasMarketData && !persisted; } function floorLog10(x: number): number { diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index a0589ecc102d..50ccb4efc30c 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -18,7 +18,7 @@ import { type ITickParam, buildTickOptions, getDefaultTickOption, - shouldPersistOrderBookTickOption, + shouldInitializeOrderBookTickOption, } from './tickSizeUtils'; interface ITickOptionsResult { @@ -166,10 +166,9 @@ export function useTickOptions({ }; if ( - shouldPersistOrderBookTickOption({ + shouldInitializeOrderBookTickOption({ hasMarketData: Boolean(tickOptionsData), persisted, - selected: currentPersist, }) ) { void actions.current.setOrderBookTickOption({ diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index 935f9baba43c..822499dccdf8 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -561,6 +561,16 @@ export function PerpOrderBook({ stored?.mantissa === undefined ? undefined : stored.mantissa; return { nSigFigs, mantissa }; }, [activeTradeInstrument.coin, orderBookTickOptions]); + const hasInitializedTickOption = useMemo( + () => + Boolean( + getPerpsOrderBookTickOptionWithCache({ + coin: activeTradeInstrument.coin, + options: orderBookTickOptions, + }), + ), + [activeTradeInstrument.coin, orderBookTickOptions], + ); const enableVisualSnapshot = !gtMd; const [renderL2Book, setRenderL2Book] = useState(null); @@ -608,8 +618,13 @@ export function PerpOrderBook({ ) ? renderL2Book : null; - const visibleL2Book = activeRenderL2Book ?? initialCachedL2Book; + const candidateL2Book = activeRenderL2Book ?? initialCachedL2Book; + const visibleL2Book = hasInitializedTickOption ? candidateL2Book : null; const hasRenderOrderBook = Boolean(visibleL2Book); + let orderBookRenderSource = 'none'; + if (candidateL2Book) { + orderBookRenderSource = hasRenderOrderBook ? 'visible' : 'provisional'; + } const handleVisualBookChange = useCallback((book: IL2BookData | null) => { setRenderL2Book((prevBook) => (prevBook === book ? prevBook : book)); @@ -744,9 +759,9 @@ export function PerpOrderBook({ ]); const tickOptionsData = useTickOptions({ - symbol: visibleL2Book?.coin, - bids: visibleL2Book?.bids ?? [], - asks: visibleL2Book?.asks ?? [], + symbol: candidateL2Book?.coin, + bids: candidateL2Book?.bids ?? [], + asks: candidateL2Book?.asks ?? [], }); const { tickOptions, @@ -866,6 +881,10 @@ export function PerpOrderBook({ visibleL2Book?.coin ?? '', visibleL2Book?.bids.length ?? 0, visibleL2Book?.asks.length ?? 0, + candidateL2Book?.coin ?? '', + candidateL2Book?.bids.length ?? 0, + candidateL2Book?.asks.length ?? 0, + hasInitializedTickOption ? 'tickReady' : 'tickPending', shouldShowEnableTradingButton ? 'enableTrading' : 'trade', formData.hasTpsl ? 'tpsl' : 'noTpsl', mobileMaxLevelsPerSide, @@ -886,13 +905,33 @@ export function PerpOrderBook({ shouldShowEnableTradingButton, hasTpsl: formData.hasTpsl, }); + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'ui-render-state', + mode: activeTradeInstrument.mode, + coin: activeTradeInstrument.coin, + nSigFigs: l2SubscriptionOptions.nSigFigs, + mantissa: l2SubscriptionOptions.mantissa ?? null, + hasTickOption: hasInitializedTickOption, + hasBook: hasRenderOrderBook, + bidLevels: candidateL2Book?.bids.length ?? 0, + askLevels: candidateL2Book?.asks.length ?? 0, + source: orderBookRenderSource, + }); }, [ activeTradeInstrument.coin, activeTradeInstrument.mode, + candidateL2Book?.asks.length, + candidateL2Book?.bids.length, + candidateL2Book?.coin, entry, formData.hasTpsl, gtMd, + hasInitializedTickOption, hasRenderOrderBook, + l2SubscriptionOptions.mantissa, + l2SubscriptionOptions.nSigFigs, + orderBookRenderSource, visibleL2Book?.asks.length, visibleL2Book?.bids.length, visibleL2Book?.coin, diff --git a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts index b44d856ed6ac..686ae8e8c532 100644 --- a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts +++ b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts @@ -417,6 +417,27 @@ export class HyperLiquidScene extends BaseScene { }) { return params; } + + @LogToLocal({ level: 'info' }) + public orderBookSwitchDiagnostic(params: { + runtime: 'main' | 'bg'; + event: string; + sequence?: number; + requestId?: number; + mode?: 'perp' | 'spot'; + coin?: string; + nSigFigs?: number | null; + mantissa?: number | null; + hasTickOption?: boolean; + hasBook?: boolean; + bidLevels?: number; + askLevels?: number; + latest?: boolean; + transport?: 'l2' | 'l2Book' | 'none'; + source?: string; + }) { + return params; + } } export type IHyperLiquidOrderAction = From fb044093de21a4f7ee4cee340d27faaa76d76e01 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 20:39:24 +0800 Subject: [PATCH 06/24] fix: keep order book precision stable during reloads Retain same-coin tick option data while a Fast L2 precision resubscription temporarily removes the renderable book, without carrying precision across coin switches. Constraint: strict precision filtering intentionally creates a short empty-book window in main JS. Rejected: show the generic fallback precision during reload | it causes the selector to flash an incorrect value. Confidence: high Scope-risk: narrow Directive: keep cache reuse scoped to the active coin. Tested: 8 targeted Jest suites (58 tests), lint-worktree-ts, lint-staged, git diff --check. Not-tested: iOS bundle; tsc-staged is blocked only by the pre-existing AutoSizeInput.native.tsx mostRecentEventCount error. --- .../OrderBook/tickSizeUtils.test.ts | 28 +++++++++++++++++++ .../components/OrderBook/tickSizeUtils.ts | 17 +++++++++++ .../components/OrderBook/useTickOptions.ts | 13 +++++++-- .../views/Perp/components/PerpOrderBook.tsx | 2 +- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts index 73cbea8a95f0..c3861387c1e0 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts @@ -4,9 +4,37 @@ import { getDisplayPriceScaleDecimals } from '@onekeyhq/shared/src/utils/perpsUt import { buildTickOptions, + getTickOptionsDataDuringTransition, shouldInitializeOrderBookTickOption, } from './tickSizeUtils'; +describe('getTickOptionsDataDuringTransition', () => { + const cached = { + symbol: '@188', + marker: 'cached', + }; + + it('keeps same-coin precision data while the order book is temporarily empty', () => { + expect( + getTickOptionsDataDuringTransition({ + symbol: '@188', + hasMarketData: false, + cached, + }), + ).toBe(cached); + }); + + it('does not reuse precision data after switching coins', () => { + expect( + getTickOptionsDataDuringTransition({ + symbol: '@166', + hasMarketData: false, + cached, + }), + ).toBeNull(); + }); +}); + describe('shouldInitializeOrderBookTickOption', () => { it('does not replace precision while order book data is transitioning', () => { expect( diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts index eb70b969cc29..525744d15564 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts @@ -29,6 +29,23 @@ type IOrderBookTickOptionState = { mantissa?: IMantissa | null; }; +export function getTickOptionsDataDuringTransition< + T extends { symbol: string }, +>({ + symbol, + hasMarketData, + cached, +}: { + symbol: string | undefined; + hasMarketData: boolean; + cached: T | null; +}): T | null { + if (hasMarketData || !symbol || cached?.symbol !== symbol) { + return null; + } + return cached; +} + export function shouldInitializeOrderBookTickOption({ hasMarketData, persisted, diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index 50ccb4efc30c..06177dd285bd 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -18,6 +18,7 @@ import { type ITickParam, buildTickOptions, getDefaultTickOption, + getTickOptionsDataDuringTransition, shouldInitializeOrderBookTickOption, } from './tickSizeUtils'; @@ -65,13 +66,19 @@ export function useTickOptions({ if (!symbol) return null; const marketPrice = topBidPrice || topAskPrice || '0'; - if (marketPrice === '0') return null; - - const priceDecimals = getDisplayPriceScaleDecimals(marketPrice); const cached = tickOptionsCache.current?.symbol === symbol ? tickOptionsCache.current : null; + if (marketPrice === '0') { + return getTickOptionsDataDuringTransition({ + symbol, + hasMarketData: false, + cached, + }); + } + + const priceDecimals = getDisplayPriceScaleDecimals(marketPrice); if (cached && priceDecimals <= cached.priceDecimals) { return cached; diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index 822499dccdf8..b381872a571a 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -759,7 +759,7 @@ export function PerpOrderBook({ ]); const tickOptionsData = useTickOptions({ - symbol: candidateL2Book?.coin, + symbol: activeTradeInstrument.coin, bids: candidateL2Book?.bids ?? [], asks: candidateL2Book?.asks ?? [], }); From 7af92cbd1cc5586586b95f2fc9e95b6c15dfc2fc Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 21:31:11 +0800 Subject: [PATCH 07/24] chore: trace order book precision transitions Extend the existing local Fast L2 diagnostic with selector source, value, option count, initialization, and explicit selection events for bundle verification. Constraint: exported production bundle logs require LogToLocal rather than development-only console output. Rejected: log full order book payloads | scalar state is sufficient and safer. Confidence: high Scope-risk: narrow Directive: remove temporary orderBookSwitchDiagnostic instrumentation after verification. Tested: tickSizeUtils Jest suite (13 tests), lint-worktree-ts, lint-staged, git diff --check. Not-tested: iOS bundle; tsc-staged is blocked only by the pre-existing AutoSizeInput.native.tsx mostRecentEventCount error. --- .../components/OrderBook/useTickOptions.ts | 43 ++++++++++++++++++- .../views/Perp/components/PerpOrderBook.tsx | 7 +++ .../logger/scopes/perp/scenes/hyperliquid.ts | 3 ++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index 06177dd285bd..90b393fb00bc 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -6,6 +6,7 @@ import { useHyperliquidActions, useOrderBookTickOptionsAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { getPerpsOrderBookTickOptionsWithCache } from '@onekeyhq/shared/src/utils/perpsOrderBookTickOptionsCache'; import { analyzeOrderBookPrecision, @@ -29,6 +30,7 @@ interface ITickOptionsResult { setSelectedTickOption: (option: ITickParam) => void; priceDecimals: number; sizeDecimals: number; + tickOptionSource: 'market' | 'cache' | 'fallback'; } export function useTickOptions({ @@ -135,6 +137,14 @@ export function useTickOptions({ return tickOptionsData; }, [tickOptionsData]); + const tickOptionSource = useMemo(() => { + if (!tickOptionsData) { + return 'fallback' as const; + } + return topBidPrice || topAskPrice + ? ('market' as const) + : ('cache' as const); + }, [tickOptionsData, topAskPrice, topBidPrice]); const selectedTickOption = useMemo(() => { const { tickOptions, defaultTickOption } = baseTickOptionsData; @@ -178,6 +188,16 @@ export function useTickOptions({ persisted, }) ) { + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'tick-option-initialize', + coin: symbol, + nSigFigs: currentPersist.nSigFigs, + mantissa: currentPersist.mantissa, + tickOptionValue: currentPersist.value, + tickOptionsCount: baseTickOptionsData.tickOptions.length, + tickOptionSource, + }); void actions.current.setOrderBookTickOption({ symbol, option: currentPersist, @@ -188,6 +208,8 @@ export function useTickOptions({ persistedTickOptions, selectedTickOption, tickOptionsData, + baseTickOptionsData.tickOptions.length, + tickOptionSource, actions, ]); @@ -202,6 +224,17 @@ export function useTickOptions({ return; } + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'tick-option-select', + coin: symbol, + nSigFigs: option.nSigFigs, + mantissa: option.mantissa ?? null, + tickOptionValue: option.value, + tickOptionsCount: baseTickOptionsData.tickOptions.length, + tickOptionSource, + }); + void actions.current.setOrderBookTickOption({ symbol, option: { @@ -211,7 +244,13 @@ export function useTickOptions({ }, }); }, - [actions, selectedTickOption, symbol], + [ + actions, + baseTickOptionsData.tickOptions.length, + selectedTickOption, + symbol, + tickOptionSource, + ], ); return useMemo(() => { @@ -222,11 +261,13 @@ export function useTickOptions({ setSelectedTickOption: handleSelectTickOption, priceDecimals: baseTickOptionsData.priceDecimals, sizeDecimals, + tickOptionSource, }; }, [ baseTickOptionsData, selectedTickOption, handleSelectTickOption, sizeDecimals, + tickOptionSource, ]); } diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index b381872a571a..f7d3abb0ed35 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -769,6 +769,7 @@ export function PerpOrderBook({ setSelectedTickOption, priceDecimals, sizeDecimals, + tickOptionSource, } = tickOptionsData; const handleTickOptionChange = useCallback( @@ -916,6 +917,9 @@ export function PerpOrderBook({ hasBook: hasRenderOrderBook, bidLevels: candidateL2Book?.bids.length ?? 0, askLevels: candidateL2Book?.asks.length ?? 0, + tickOptionValue: selectedTickOption.value, + tickOptionsCount: tickOptions.length, + tickOptionSource, source: orderBookRenderSource, }); }, [ @@ -932,6 +936,9 @@ export function PerpOrderBook({ l2SubscriptionOptions.mantissa, l2SubscriptionOptions.nSigFigs, orderBookRenderSource, + selectedTickOption.value, + tickOptionSource, + tickOptions.length, visibleL2Book?.asks.length, visibleL2Book?.bids.length, visibleL2Book?.coin, diff --git a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts index 686ae8e8c532..3ed4f6fa9fe8 100644 --- a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts +++ b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts @@ -432,6 +432,9 @@ export class HyperLiquidScene extends BaseScene { hasBook?: boolean; bidLevels?: number; askLevels?: number; + tickOptionValue?: string; + tickOptionsCount?: number; + tickOptionSource?: 'market' | 'cache' | 'fallback'; latest?: boolean; transport?: 'l2' | 'l2Book' | 'none'; source?: string; From 8836ca61dd21a243f1718d7f22beccb3496d32d6 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 21:43:19 +0800 Subject: [PATCH 08/24] chore: trace mobile open order filtering Add temporary local diagnostics around the mobile filter checkbox, committed list snapshots, next-frame state, and row lifecycle to distinguish main-state fallback from an iOS native-view residue. Constraint: production-shaped bundle exports require LogToLocal diagnostics. Rejected: log account addresses or order identifiers | coin and bounded counts are sufficient for this single-row reproduction. Confidence: high Scope-risk: narrow Directive: remove openOrdersFilterDiagnostic and its call sites after OK-57833 verification. Tested: lint-worktree-ts, lint-staged, git diff --check. Not-tested: iOS bundle; tsc-staged is blocked only by the pre-existing AutoSizeInput.native.tsx mostRecentEventCount error. --- .../Components/MobileOpenOrdersListHeader.tsx | 14 ++- .../Components/OpenOrdersRow.tsx | 25 +++- .../List/PerpOpenOrdersList.tsx | 117 +++++++++++++++++- .../logger/scopes/perp/scenes/hyperliquid.ts | 27 ++++ 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx index b3a3e8596e7a..81d06dc2ea2b 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import { Button, Checkbox, SizableText, XStack } from '@onekeyhq/components'; import { useOrderFilterByCurrentTokenAtom } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { showCancelAllOrdersDialog } from '../CancelAllOrdersModal'; @@ -32,9 +33,18 @@ export function MobileOpenOrdersListHeader({ const handleFilterChange = useCallback( (value: boolean | 'indeterminate') => { - setFilterByCurrentToken(value === true); + const nextFilterValue = value === true; + defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ + runtime: 'main', + event: 'checkbox-change', + isMobile: true, + filterByCurrentToken, + checkboxValue: value, + nextFilterValue, + }); + setFilterByCurrentToken(nextFilterValue); }, - [setFilterByCurrentToken], + [filterByCurrentToken, setFilterByCurrentToken], ); // Early return when no orders exist diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx index 37156ae4f5be..e1687865e46a 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo } from 'react'; import BigNumber from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -11,6 +11,7 @@ import { useSpotPairDisplayNameMapAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { @@ -241,6 +242,28 @@ const OpenOrdersRow = memo( const shouldRenderLeft = renderMode === 'full' || renderMode === 'left'; const shouldRenderRight = renderMode === 'full' || renderMode === 'right'; + useEffect(() => { + if (!isMobile) { + return undefined; + } + defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ + runtime: 'main', + event: 'row-commit', + isMobile: true, + rowCoin: order.coin, + rowIndex: index, + }); + return () => { + defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ + runtime: 'main', + event: 'row-cleanup', + isMobile: true, + rowCoin: order.coin, + rowIndex: index, + }); + }; + }, [index, isMobile, order.coin]); + if (isMobile) { return ( { @@ -217,6 +249,7 @@ function PerpOpenOrdersList({ const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const actions = useHyperliquidActions(); const [currentListPage, setCurrentListPage] = useState(1); + const diagnosticSequenceRef = useRef(0); const canMutateScopedOrders = isPerpsAccountAddressMatched({ activeAccountAddress: currentUser?.accountAddress, dataAccountAddress: accountScopedAddress, @@ -384,6 +417,88 @@ function PerpOpenOrdersList({ ]; }, [activeOpenOrdersSubTab, filteredOrders, filteredTwapOrders, isMobile]); + useEffect(() => { + if (!isMobile) { + return; + } + diagnosticSequenceRef.current += 1; + const sequence = diagnosticSequenceRef.current; + const activeCoin = activeTradeInstrument?.coin ?? ''; + const filterMode = getOrderFilterMode({ + isMobile, + filterByCurrentToken, + activeCoin, + }); + const visibleOrders = + activeOpenOrdersSubTab === 'twap' ? filteredTwapOrders : filteredOrders; + const sourceOrders = + activeOpenOrdersSubTab === 'twap' ? scopedTwapOrders : openOrders; + const orderCoins = sourceOrders + .map(getOrderCoin) + .slice(0, DIAGNOSTIC_COIN_LIMIT); + const filteredCoins = visibleOrders + .map(getOrderCoin) + .slice(0, DIAGNOSTIC_COIN_LIMIT); + const displayCoins = displayRows + .map(getDisplayRowCoin) + .slice(0, DIAGNOSTIC_COIN_LIMIT); + const unsafeFallback = Boolean( + isMobile && + filterByCurrentToken && + !activeCoin && + sourceOrders.length > 0, + ); + const mismatchedVisibleCount = activeCoin + ? visibleOrders.filter((order) => getOrderCoin(order) !== activeCoin) + .length + : 0; + const snapshot = { + runtime: 'main' as const, + sequence, + isMobile: Boolean(isMobile), + filterByCurrentToken, + activeCoin, + activeSubTab: activeOpenOrdersSubTab, + currentPage: currentListPage, + filterMode, + unsafeFallback, + openOrdersCount: sourceOrders.length, + filteredOrdersCount: visibleOrders.length, + displayRowsCount: displayRows.length, + mismatchedVisibleCount, + orderCoins, + filteredCoins, + displayCoins, + }; + defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ + event: 'list-commit', + ...snapshot, + }); + if (unsafeFallback) { + defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ + event: 'unsafe-unfiltered-fallback', + ...snapshot, + }); + } + requestAnimationFrame(() => { + defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ + event: 'list-next-frame', + ...snapshot, + }); + }); + }, [ + activeOpenOrdersSubTab, + activeTradeInstrument?.coin, + currentListPage, + displayRows, + filterByCurrentToken, + filteredOrders, + filteredTwapOrders, + isMobile, + openOrders, + scopedTwapOrders, + ]); + const columnsConfig = useOpenOrdersColumnsConfig({ openOrdersLength: openOrders.length, enableCancelAll: canMutateScopedOrders, diff --git a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts index 3ed4f6fa9fe8..d0c09d8ad7f0 100644 --- a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts +++ b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts @@ -441,6 +441,33 @@ export class HyperLiquidScene extends BaseScene { }) { return params; } + + @LogToLocal({ level: 'info' }) + public openOrdersFilterDiagnostic(params: { + runtime: 'main'; + event: string; + sequence?: number; + isMobile?: boolean; + filterByCurrentToken?: boolean; + checkboxValue?: boolean | 'indeterminate'; + nextFilterValue?: boolean; + activeCoin?: string; + activeSubTab?: 'basic' | 'twap'; + currentPage?: number; + filterMode?: 'not-mobile' | 'disabled' | 'missing-active-coin' | 'active'; + unsafeFallback?: boolean; + openOrdersCount?: number; + filteredOrdersCount?: number; + displayRowsCount?: number; + mismatchedVisibleCount?: number; + orderCoins?: string[]; + filteredCoins?: string[]; + displayCoins?: string[]; + rowCoin?: string; + rowIndex?: number; + }) { + return params; + } } export type IHyperLiquidOrderAction = From 51a58e107faeaa527512f8531fab468e9eaac5db Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 22:23:37 +0800 Subject: [PATCH 09/24] fix: keep funding stable during order book reloads Keep the mobile funding header mounted while only the order book body switches between loading and ready states. Constraint: Funding data is independent from Fast L2 snapshot readiness. Rejected: Continue forcing a funding placeholder during order book reloads | it couples unrelated data and resets the countdown through remounts. Confidence: high Scope-risk: narrow Directive: Keep the mobile funding header outside order book loading and ready branches. Tested: 39 targeted Perp order book tests passed; commit-profile lint checks passed. Not-tested: iOS bundle/device verification; staged typecheck remains blocked by the pre-existing AutoSizeInput.native.tsx mostRecentEventCount error. --- .../views/Perp/components/PerpOrderBook.tsx | 169 +++++++++--------- .../PerpOrderBookMobileVerticalShell.test.tsx | 66 +++++++ .../PerpOrderBookMobileVerticalShell.tsx | 26 +++ 3 files changed, 172 insertions(+), 89 deletions(-) create mode 100644 packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.test.tsx create mode 100644 packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.tsx diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index f7d3abb0ed35..79047ec7716e 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -62,16 +62,13 @@ import { } from './OrderBook'; import { DefaultLoadingNode } from './OrderBook/DefaultLoadingNode'; import { useTickOptions } from './OrderBook/useTickOptions'; +import { PerpOrderBookMobileVerticalShell } from './PerpOrderBookMobileVerticalShell'; import type { ITickParam } from './OrderBook/tickSizeUtils'; import type { IOrderBookVariant } from './OrderBook/types'; import type { LayoutChangeEvent } from 'react-native'; -function MobileHeader({ - showPlaceholder = false, -}: { - showPlaceholder?: boolean; -}) { +function MobileHeader() { const intl = useIntl(); const layoutRef = useRef(undefined); const countdown = useFundingCountdown(); @@ -114,7 +111,6 @@ function MobileHeader({ useEffect(() => { tracePerpsMobileLayout('orderBook.mobileHeader.state', { coin: activeTradeInstrument.coin, - showPlaceholder, showSkeleton, isReady, hasError, @@ -131,7 +127,6 @@ function MobileHeader({ hasError, isReady, markPrice, - showPlaceholder, showSkeleton, ]); @@ -141,14 +136,13 @@ function MobileHeader({ if (isPerpsMobileLayoutTraceRectChanged(layoutRef.current, rect)) { tracePerpsMobileLayout('orderBook.mobileHeader.layout', { rect, - showPlaceholder, showSkeleton, coin: activeTradeInstrument.coin, }); layoutRef.current = rect; } }, - [activeTradeInstrument.coin, showPlaceholder, showSkeleton], + [activeTradeInstrument.coin, showSkeleton], ); if (isSpot) { @@ -180,7 +174,7 @@ function MobileHeader({ })} - {showPlaceholder || showSkeleton ? ( + {showSkeleton ? ( -- @@ -949,74 +943,47 @@ export function PerpOrderBook({ const mobileOrderBook = useMemo(() => { if (!hasRenderOrderBook || !visibleL2Book) return null; if (gtMd) return null; - if (entry === 'perpMobileMarket') { - return ( - - } - style={{ - paddingLeft: 16, - paddingRight: 16, - paddingTop: 8, - paddingBottom: 8, - }} - variant="mobileHorizontal" - /> - ); - } + if (entry !== 'perpMobileMarket') return null; return ( - handleTraceLayout('mobileVerticalReady', event)} - > - - - + + } + style={{ + paddingLeft: 16, + paddingRight: 16, + paddingTop: 8, + paddingBottom: 8, + }} + variant="mobileHorizontal" + /> ); }, [ entry, gtMd, - handleTraceLayout, handleTickOptionChange, visibleL2Book, handleLevelSelect, selectedTickOption, hasRenderOrderBook, isVisibleOrderBookInteractive, - mobileMaxLevelsPerSide, tickOptions, priceDecimals, sizeDecimals, @@ -1031,25 +998,21 @@ export function PerpOrderBook({ /> ); - if (!hasRenderOrderBook || !visibleL2Book) { - let loadingVariant = 'desktop'; - if (!gtMd) { - loadingVariant = - entry === 'perpMobileMarket' ? 'mobileHorizontal' : 'mobileVertical'; - } - if (!gtMd && loadingVariant === 'mobileVertical') { - return ( - <> - {dataBridge} - - handleTraceLayout('mobileVerticalLoading', event) - } - > - + if (!gtMd && entry !== 'perpMobileMarket') { + const isLoading = !hasRenderOrderBook || !visibleL2Book; + return ( + <> + {dataBridge} + } + isLoading={isLoading} + onLayout={(event) => + handleTraceLayout( + isLoading ? 'mobileVerticalLoading' : 'mobileVerticalReady', + event, + ) + } + loadingBody={ - - - ); + } + readyBody={ + visibleL2Book ? ( + + ) : null + } + /> + + ); + } + + if (!hasRenderOrderBook || !visibleL2Book) { + let loadingVariant = 'desktop'; + if (!gtMd) { + loadingVariant = + entry === 'perpMobileMarket' ? 'mobileHorizontal' : 'mobileVertical'; } if (!gtMd && loadingVariant === 'mobileHorizontal') { return ( diff --git a/packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.test.tsx b/packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.test.tsx new file mode 100644 index 000000000000..24902e741784 --- /dev/null +++ b/packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.test.tsx @@ -0,0 +1,66 @@ +/** @jest-environment jsdom */ + +import { useEffect } from 'react'; +import type { ReactNode } from 'react'; + +import { render } from '@testing-library/react'; + +import { PerpOrderBookMobileVerticalShell } from './PerpOrderBookMobileVerticalShell'; + +jest.mock('@onekeyhq/components', () => ({ + YStack: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +describe('PerpOrderBookMobileVerticalShell', () => { + it('keeps the funding header mounted while the order book reloads', () => { + const onHeaderMount = jest.fn(); + const onHeaderUnmount = jest.fn(); + + function FundingHeaderProbe() { + useEffect(() => { + onHeaderMount(); + return onHeaderUnmount; + }, []); + return null; + } + + const { getByText, queryByText, rerender, unmount } = render( + } + isLoading + loadingBody={loading} + readyBody={ready} + />, + ); + + expect(getByText('loading')).toBeTruthy(); + expect(queryByText('ready')).toBeNull(); + + rerender( + } + isLoading={false} + loadingBody={loading} + readyBody={ready} + />, + ); + + expect(getByText('ready')).toBeTruthy(); + expect(queryByText('loading')).toBeNull(); + + rerender( + } + isLoading + loadingBody={loading} + readyBody={ready} + />, + ); + + expect(onHeaderMount).toHaveBeenCalledTimes(1); + expect(onHeaderUnmount).not.toHaveBeenCalled(); + + unmount(); + expect(onHeaderUnmount).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.tsx b/packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.tsx new file mode 100644 index 000000000000..e24ab045998f --- /dev/null +++ b/packages/kit/src/views/Perp/components/PerpOrderBookMobileVerticalShell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; + +import { YStack } from '@onekeyhq/components'; + +import type { LayoutChangeEvent } from 'react-native'; + +export function PerpOrderBookMobileVerticalShell({ + header, + isLoading, + loadingBody, + onLayout, + readyBody, +}: { + header: ReactNode; + isLoading: boolean; + loadingBody: ReactNode; + onLayout?: (event: LayoutChangeEvent) => void; + readyBody: ReactNode; +}) { + return ( + + {header} + {isLoading ? loadingBody : readyBody} + + ); +} From 9b56cc360fa94f31480b1da26da13e097fd8ec61 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 15 Jul 2026 23:30:43 +0800 Subject: [PATCH 10/24] fix: keep order book precision stable before l2 loads Derive the full-precision tick from the coin reference price and Hyperliquid size-decimal rules so loading and live frames use the same selector value. Constraint: Default precision must be available before the first L2 snapshot.\nRejected: Generic buildTickOptions(1, 0) fallback | It exposes a false transient precision.\nConfidence: high\nScope-risk: narrow\nDirective: Keep default precision coin-keyed and derived from current-instrument szDecimals.\nTested: 42 targeted order book tests; changed-file lint; staged TypeScript has no changed-file errors.\nNot-tested: iOS bundle/device; repository baseline TypeScript error remains in AutoSizeInput.native.tsx:103. --- .../OrderBook/tickSizeUtils.test.ts | 86 +++++++++++++++++++ .../components/OrderBook/tickSizeUtils.ts | 86 ++++++++++++++++++- .../components/OrderBook/useTickOptions.ts | 78 ++++++++++++++--- .../views/Perp/components/PerpOrderBook.tsx | 8 ++ .../logger/scopes/perp/scenes/hyperliquid.ts | 2 +- 5 files changed, 244 insertions(+), 16 deletions(-) diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts index c3861387c1e0..bee2f35c74e2 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts @@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js'; import { getDisplayPriceScaleDecimals } from '@onekeyhq/shared/src/utils/perpsUtils'; import { + buildReferenceTickOptions, buildTickOptions, getTickOptionsDataDuringTransition, shouldInitializeOrderBookTickOption, @@ -25,11 +26,96 @@ describe('getTickOptionsDataDuringTransition', () => { }); it('does not reuse precision data after switching coins', () => { + const reference = { + symbol: '@166', + marker: 'reference', + }; expect( getTickOptionsDataDuringTransition({ symbol: '@166', hasMarketData: false, cached, + reference, + }), + ).toBe(reference); + }); + + it('does not use reference precision from another coin', () => { + expect( + getTickOptionsDataDuringTransition({ + symbol: '@166', + hasMarketData: false, + cached, + reference: { + symbol: '@107', + marker: 'reference', + }, + }), + ).toBeNull(); + }); +}); + +describe('buildReferenceTickOptions', () => { + it.each([ + { + name: 'ETH perp', + price: '4400', + szDecimals: 4, + isSpot: false, + expectedTick: '0.1', + expectedDecimals: 1, + }, + { + name: 'HYPE perp', + price: '55', + szDecimals: 2, + isSpot: false, + expectedTick: '0.001', + expectedDecimals: 3, + }, + { + name: 'BTC perp', + price: '114000', + szDecimals: 5, + isSpot: false, + expectedTick: '1', + expectedDecimals: 0, + }, + { + name: 'low price spot', + price: '0.002699', + szDecimals: 0, + isSpot: true, + expectedTick: '0.0000001', + expectedDecimals: 7, + }, + ])( + 'derives the finest full-precision option for $name', + ({ price, szDecimals, isSpot, expectedTick, expectedDecimals }) => { + expect(buildReferenceTickOptions).toBeDefined(); + const result = buildReferenceTickOptions({ + symbol: 'TEST', + price, + szDecimals, + isSpot, + }); + + expect(result?.defaultTickOption).toMatchObject({ + value: expectedTick, + label: expectedTick, + nSigFigs: null, + }); + expect(result?.priceDecimals).toBe(expectedDecimals); + }, + ); + + it('does not invent precision without valid reference inputs', () => { + expect( + buildReferenceTickOptions({ + symbol: 'ETH', + price: undefined, + szDecimals: 4, + isSpot: false, }), ).toBeNull(); }); diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts index 525744d15564..184a7082e65c 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts @@ -23,6 +23,13 @@ export interface ITickParam { value: string; } +export interface IReferenceTickOptionsData { + symbol: string; + tickOptions: ITickParam[]; + defaultTickOption: ITickParam; + priceDecimals: number; +} + type IOrderBookTickOptionState = { value: string; nSigFigs?: INSig; @@ -35,15 +42,20 @@ export function getTickOptionsDataDuringTransition< symbol, hasMarketData, cached, + reference, }: { symbol: string | undefined; hasMarketData: boolean; cached: T | null; + reference?: T | null; }): T | null { - if (hasMarketData || !symbol || cached?.symbol !== symbol) { + if (hasMarketData || !symbol) { return null; } - return cached; + if (cached?.symbol === symbol) { + return cached; + } + return reference?.symbol === symbol ? reference : null; } export function shouldInitializeOrderBookTickOption({ @@ -60,6 +72,76 @@ function floorLog10(x: number): number { return Math.floor(Math.log10(x)); } +export function buildReferenceTickOptions({ + symbol, + price, + szDecimals, + isSpot, +}: { + symbol: string; + price: string | undefined; + szDecimals: number | undefined; + isSpot: boolean; +}): IReferenceTickOptionsData | null { + const priceNumber = Number(price); + if ( + !symbol || + !Number.isFinite(priceNumber) || + priceNumber <= 0 || + !Number.isInteger(szDecimals) || + (szDecimals ?? -1) < 0 + ) { + return null; + } + + const maximumPriceDecimals = Math.max( + 0, + (isSpot ? 8 : 6) - (szDecimals ?? 0), + ); + const priceDecimals = Math.min( + maximumPriceDecimals, + Math.max(0, 4 - floorLog10(priceNumber)), + ); + const minimumTick = new BigNumber(10).pow(-priceDecimals); + const minimumTickNumber = minimumTick.toNumber(); + const minimumTickValue = minimumTick.toFixed(); + const defaultTickOption: ITickParam = { + targetTick: minimumTickNumber, + nSigFigs: null, + apiTick: minimumTickNumber, + exact: true, + multiplier: 1, + label: minimumTickValue, + value: minimumTickValue, + }; + const multipliers = + minimumTickNumber >= 1 + ? [1, 10, 20, 50, 100, 1000, 10_000] + : [1, 2, 5, 10, 100, 1000]; + const tickOptions = [defaultTickOption]; + const seenApiTicks = new Set([minimumTickValue]); + + for (const multiplier of multipliers) { + const targetTick = minimumTick.multipliedBy(multiplier).toNumber(); + const mapped = mapTickToParams(priceNumber, targetTick); + const apiTickKey = new BigNumber(mapped.apiTick).toFixed(); + if (!seenApiTicks.has(apiTickKey)) { + seenApiTicks.add(apiTickKey); + tickOptions.push({ + ...mapped, + multiplier, + }); + } + } + + return { + symbol, + tickOptions, + defaultTickOption, + priceDecimals, + }; +} + /** * List all valid (nSigFigs, mantissa) combinations and their step sizes * for the given price magnitude diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index 90b393fb00bc..3633e410d2bb 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -17,6 +17,7 @@ import type { IPerpOrderBookTickOptionPersist } from '@onekeyhq/shared/types/hyp import { type ITickParam, + buildReferenceTickOptions, buildTickOptions, getDefaultTickOption, getTickOptionsDataDuringTransition, @@ -30,17 +31,33 @@ interface ITickOptionsResult { setSelectedTickOption: (option: ITickParam) => void; priceDecimals: number; sizeDecimals: number; - tickOptionSource: 'market' | 'cache' | 'fallback'; + tickOptionSource: 'market' | 'cache' | 'reference' | 'fallback'; } +const emptyTickOption: ITickParam = { + targetTick: 0, + nSigFigs: null, + apiTick: 0, + exact: true, + multiplier: 1, + label: '', + value: '', +}; + export function useTickOptions({ symbol, bids, asks, + referencePrice, + szDecimals, + isSpot, }: { symbol?: string; bids: IBookLevel[]; asks: IBookLevel[]; + referencePrice?: string; + szDecimals?: number; + isSpot: boolean; }): ITickOptionsResult { // Use ref to cache tick options calculation results by symbol const tickOptionsCache = useRef<{ @@ -63,6 +80,18 @@ export function useTickOptions({ const topBidPrice = bids[0]?.px; const topAskPrice = asks[0]?.px; + const referenceTickOptionsData = useMemo( + () => + symbol + ? buildReferenceTickOptions({ + symbol, + price: referencePrice, + szDecimals, + isSpot, + }) + : null, + [isSpot, referencePrice, symbol, szDecimals], + ); const tickOptionsData = useMemo(() => { if (!symbol) return null; @@ -77,9 +106,27 @@ export function useTickOptions({ symbol, hasMarketData: false, cached, + reference: referenceTickOptionsData, }); } + const marketTickOptionsData = buildReferenceTickOptions({ + symbol, + price: marketPrice, + szDecimals, + isSpot, + }); + if (marketTickOptionsData) { + if ( + cached && + marketTickOptionsData.priceDecimals <= cached.priceDecimals + ) { + return cached; + } + tickOptionsCache.current = marketTickOptionsData; + return marketTickOptionsData; + } + const priceDecimals = getDisplayPriceScaleDecimals(marketPrice); if (cached && priceDecimals <= cached.priceDecimals) { @@ -109,7 +156,14 @@ export function useTickOptions({ tickOptionsCache.current = result; return result; - }, [symbol, topBidPrice, topAskPrice]); + }, [ + isSpot, + referenceTickOptionsData, + symbol, + szDecimals, + topAskPrice, + topBidPrice, + ]); // Calculate size decimals separately as it may need to update more frequently const sizeDecimals = useMemo(() => { @@ -123,15 +177,10 @@ export function useTickOptions({ const baseTickOptionsData = useMemo(() => { // Fallback when no data available if (!tickOptionsData) { - const priceDecimals = 0; - const decimalsArg = 0; - const tickOptions = buildTickOptions(1, decimalsArg); - const defaultTickOption = getDefaultTickOption(tickOptions); - return { - tickOptions, - defaultTickOption, - priceDecimals, + tickOptions: [], + defaultTickOption: emptyTickOption, + priceDecimals: 0, }; } @@ -141,10 +190,13 @@ export function useTickOptions({ if (!tickOptionsData) { return 'fallback' as const; } - return topBidPrice || topAskPrice - ? ('market' as const) + if (topBidPrice || topAskPrice) { + return 'market' as const; + } + return tickOptionsData === referenceTickOptionsData + ? ('reference' as const) : ('cache' as const); - }, [tickOptionsData, topAskPrice, topBidPrice]); + }, [referenceTickOptionsData, tickOptionsData, topAskPrice, topBidPrice]); const selectedTickOption = useMemo(() => { const { tickOptions, defaultTickOption } = baseTickOptionsData; diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index 79047ec7716e..abe8fe439579 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -19,6 +19,7 @@ import { useHyperliquidActions, useOrderBookTickOptionsAtom, usePerpsL2BookColdCacheAtom, + usePerpsMidByCoin, useTradingFormAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; import type { ITradingFormData } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; @@ -535,6 +536,7 @@ export function PerpOrderBook({ >({}); const renderStateSignatureRef = useRef(undefined); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); + const tickReferencePrice = usePerpsMidByCoin(activeTradeInstrument.coin); const [formData] = useTradingFormAtom(); const [orderBookTickOptions] = useOrderBookTickOptionsAtom(); const [l2BookColdCache] = usePerpsL2BookColdCacheAtom(); @@ -756,6 +758,12 @@ export function PerpOrderBook({ symbol: activeTradeInstrument.coin, bids: candidateL2Book?.bids ?? [], asks: candidateL2Book?.asks ?? [], + referencePrice: tickReferencePrice, + szDecimals: + activeTradeInstrument.mode === 'spot' + ? activeTradeInstrument.universe?.baseSzDecimals + : activeTradeInstrument.universe?.szDecimals, + isSpot: activeTradeInstrument.mode === 'spot', }); const { tickOptions, diff --git a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts index d0c09d8ad7f0..4910ed587840 100644 --- a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts +++ b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts @@ -434,7 +434,7 @@ export class HyperLiquidScene extends BaseScene { askLevels?: number; tickOptionValue?: string; tickOptionsCount?: number; - tickOptionSource?: 'market' | 'cache' | 'fallback'; + tickOptionSource?: 'market' | 'cache' | 'reference' | 'fallback'; latest?: boolean; transport?: 'l2' | 'l2Book' | 'none'; source?: string; From d6becb41b4153374484ff33a7f967c459c67900d Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 00:35:32 +0800 Subject: [PATCH 11/24] fix: harden order book subscription recovery Keep Fast L2 recovery budgets across snapshots, serialize cross-runtime option writes, and reconnect instead of creating a replacement feed after teardown failure. Constraint: Fast L2 frames do not identify their originating precision subscription and main/bg writes complete asynchronously.\nRejected: Continue after an order-book unsubscribe failure | It can leave overlapping server streams.\nConfidence: high\nScope-risk: moderate\nDirective: Reset Fast L2 recovery only after an applied delta and preserve latest-write ordering across runtimes.\nTested: 92 targeted Fast L2, subscription, cache, switch, and order-book tests; changed-file lint; git diff --check.\nNot-tested: iOS bundle/device; repository baseline TypeScript error remains in AutoSizeInput.native.tsx:103. --- .../ServiceHyperliquidSubscription.ts | 41 ++++++++------- .../utils/FastL2Book.test.ts | 27 +++++++++- .../ServiceHyperLiquid/utils/FastL2Book.ts | 7 +++ .../utils/SubscriptionMutationQueue.test.ts | 51 +++++++++++++++++++ .../utils/SubscriptionMutationQueue.ts | 37 ++++++++++++++ .../utils/instrumentSwitch.test.ts | 42 +++++++++++++++ .../hyperliquid/utils/instrumentSwitch.ts | 30 +++++++---- 7 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index 439744ac6ede..975bd05c6f26 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -79,6 +79,7 @@ import { FastL2Book, type IFastL2Frame, isStaleFastL2TargetError, + shouldResetFastL2RecoveryAfterFrame, } from './utils/FastL2Book'; import { SUBSCRIPTION_TYPE_INFO, @@ -86,7 +87,10 @@ import { isOrderBookOptionsTargetReady, normalizeL2BookForSubscriptionSpec, } from './utils/SubscriptionConfig'; -import { PerKeyMutationQueue } from './utils/SubscriptionMutationQueue'; +import { + PerKeyMutationQueue, + executeOrderBookSubscriptionTransition, +} from './utils/SubscriptionMutationQueue'; import { LatestSubscriptionReconcileQueue } from './utils/SubscriptionReconcileQueue'; import type { @@ -1828,20 +1832,20 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const orderBookTask = orderBookToDestroy.length > 0 || orderBookToCreate.length > 0 - ? this._subscriptionMutationQueue.enqueue( - ServiceHyperliquidSubscription.ORDER_BOOK_MUTATION_KEY, - async () => { - for (const spec of orderBookToDestroy) { - await this._destroySubscription(spec); - } - for (const spec of orderBookToCreate) { - if (this._isSubscriptionSpecPending(spec)) { - await this._createSubscription(spec); - } - } - }, - ) - : Promise.resolve(); + ? executeOrderBookSubscriptionTransition({ + toDestroy: orderBookToDestroy, + toCreate: orderBookToCreate, + destroy: (spec) => this._destroySubscription(spec), + create: (spec) => this._createSubscription(spec), + isPending: (spec) => this._isSubscriptionSpecPending(spec), + runExclusive: (task) => + this._subscriptionMutationQueue.enqueue( + ServiceHyperliquidSubscription.ORDER_BOOK_MUTATION_KEY, + task, + ), + reconnect: () => this._forceReconnectTransport(), + }) + : Promise.resolve(true); await Promise.all([...otherTasks, orderBookTask]); } @@ -2336,9 +2340,10 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { return; } + const fastL2Frame = data as IFastL2Frame; let normalizedBook: IBook | null; try { - normalizedBook = fastL2Book.apply(data as IFastL2Frame); + normalizedBook = fastL2Book.apply(fastL2Frame); } catch (error) { if (isStaleFastL2TargetError(error)) { return; @@ -2349,7 +2354,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (!normalizedBook) { return; } - if ('s' in (data as IFastL2Frame)) { + if ('s' in fastL2Frame) { this._logOrderBookSwitchDiagnostic('snapshot-applied', { mode: this._currentState.tradingMode, coin: normalizedBook.coin, @@ -2366,7 +2371,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { clearTimeout(this._fastL2SnapshotTimer); this._fastL2SnapshotTimer = null; } - if (fastL2Book.hasSnapshot) { + if (shouldResetFastL2RecoveryAfterFrame(fastL2Frame, normalizedBook)) { this._fastL2RecoveryAttempts = 0; this._fastL2ReconnectAttempted = false; this._fastL2FallbackTargetKey = null; diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts index 36a8e9caff10..e95cf02dbf30 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts @@ -1,4 +1,29 @@ -import { FastL2Book } from './FastL2Book'; +import type { IBook } from '@onekeyhq/shared/types/hyperliquid/sdk'; + +import { FastL2Book, shouldResetFastL2RecoveryAfterFrame } from './FastL2Book'; + +describe('shouldResetFastL2RecoveryAfterFrame', () => { + const snapshot: IBook = { + coin: 'ETH', + time: 1, + levels: [[{ px: '100', sz: '1', n: 1 }], [{ px: '101', sz: '1', n: 1 }]], + }; + + it('keeps the recovery budget after a snapshot', () => { + expect(shouldResetFastL2RecoveryAfterFrame({ s: snapshot }, snapshot)).toBe( + false, + ); + }); + + it('resets the recovery budget only after an update is applied', () => { + expect( + shouldResetFastL2RecoveryAfterFrame( + { u: { c: 'ETH', t: 2, l: [[], []], r: [[], []] } }, + snapshot, + ), + ).toBe(true); + }); +}); describe('FastL2Book', () => { it('keeps source precision on snapshots and updates', () => { diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts index 820cecfaa5f3..4b162a3c5be6 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts @@ -25,6 +25,13 @@ type IFastL2Update = { export type IFastL2Frame = { s: IBook } | { u: IFastL2Update } | { c: string }; +export function shouldResetFastL2RecoveryAfterFrame( + frame: IFastL2Frame, + normalizedBook: IBook | null, +): boolean { + return normalizedBook !== null && !('s' in frame); +} + export type IFastL2BookError = Error & { code: 'invalid_stream' | 'stale_target'; }; diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts new file mode 100644 index 000000000000..134801d3426d --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts @@ -0,0 +1,51 @@ +import { + PerKeyMutationQueue, + executeOrderBookSubscriptionTransition, +} from './SubscriptionMutationQueue'; + +describe('PerKeyMutationQueue', () => { + it('keeps tasks for the same key in order', async () => { + const queue = new PerKeyMutationQueue(); + const events: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = queue.enqueue('order-book', async () => { + events.push('first:start'); + await firstBlocked; + events.push('first:end'); + }); + const second = queue.enqueue('order-book', async () => { + events.push('second'); + }); + + await Promise.resolve(); + expect(events).toEqual(['first:start']); + releaseFirst?.(); + await Promise.all([first, second]); + expect(events).toEqual(['first:start', 'first:end', 'second']); + }); +}); + +describe('executeOrderBookSubscriptionTransition', () => { + it('reconnects and skips replacement creation when unsubscribe fails', async () => { + const create = jest.fn, [string]>(() => Promise.resolve()); + const reconnect = jest.fn, []>(() => Promise.resolve()); + + const result = await executeOrderBookSubscriptionTransition({ + toDestroy: ['old'], + toCreate: ['new'], + destroy: () => Promise.resolve(false), + create, + isPending: () => true, + runExclusive: (task) => task(), + reconnect, + }); + + expect(result).toBe(false); + expect(create).not.toHaveBeenCalled(); + expect(reconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts index b98e17a196ba..fa1156d5a361 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts @@ -14,3 +14,40 @@ export class PerKeyMutationQueue { return nextTask; } } + +export async function executeOrderBookSubscriptionTransition({ + toDestroy, + toCreate, + destroy, + create, + isPending, + runExclusive, + reconnect, +}: { + toDestroy: T[]; + toCreate: T[]; + destroy: (spec: T) => Promise; + create: (spec: T) => Promise; + isPending: (spec: T) => boolean; + runExclusive: (task: () => Promise) => Promise; + reconnect: () => Promise; +}): Promise { + const succeeded = await runExclusive(async () => { + for (const spec of toDestroy) { + if (!(await destroy(spec))) { + return false; + } + } + for (const spec of toCreate) { + if (isPending(spec)) { + await create(spec); + } + } + return true; + }); + + if (!succeeded) { + await reconnect(); + } + return succeeded; +} diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts index 5bc73962f5c1..0f33aa85a2ca 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.test.ts @@ -41,4 +41,46 @@ describe('publishLatestOrderBookOptions', () => { ).resolves.toBe(true); expect(write).toHaveBeenCalledWith({ coin: '@107' }); }); + + it('serializes writes so the latest instrument remains committed', async () => { + let latestRequestId = 1; + let committed = { coin: '@106' }; + let releaseFirstWrite: (() => void) | undefined; + let markFirstWriteStarted: (() => void) | undefined; + const firstWriteStarted = new Promise((resolve) => { + markFirstWriteStarted = resolve; + }); + const firstWriteBlocked = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + const write = async (value: { coin: string }) => { + if (value.coin === '@107') { + markFirstWriteStarted?.(); + await firstWriteBlocked; + } + committed = value; + }; + + const first = publishLatestOrderBookOptions({ + read: () => Promise.resolve(committed), + write, + next: { coin: '@107' }, + isLatest: () => latestRequestId === 1, + }); + await firstWriteStarted; + + latestRequestId = 2; + const latest = publishLatestOrderBookOptions({ + read: () => Promise.resolve(committed), + write, + next: { coin: '@108' }, + isLatest: () => latestRequestId === 2, + }); + await Promise.resolve(); + releaseFirstWrite?.(); + + await expect(first).resolves.toBe(false); + await expect(latest).resolves.toBe(true); + expect(committed).toEqual({ coin: '@108' }); + }); }); diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts index cc85515861cb..5ea8615a0389 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/instrumentSwitch.ts @@ -1,19 +1,31 @@ import { isEqual } from 'lodash'; -export async function publishLatestOrderBookOptions(params: { +let orderBookOptionsWriteQueue = Promise.resolve(); + +export function publishLatestOrderBookOptions(params: { read: () => Promise; write: (value: T) => Promise; next: T; isLatest: () => boolean; }): Promise { - const previous = await params.read(); - if (!params.isLatest()) { - return false; - } + const publish = orderBookOptionsWriteQueue.then(async () => { + if (!params.isLatest()) { + return false; + } + const previous = await params.read(); + if (!params.isLatest()) { + return false; + } - if (!isEqual(previous, params.next)) { - await params.write(params.next); - } + if (!isEqual(previous, params.next)) { + await params.write(params.next); + } - return params.isLatest(); + return params.isLatest(); + }); + orderBookOptionsWriteQueue = publish.then( + () => undefined, + () => undefined, + ); + return publish; } From 7b3a75616789c82410138525f974acd0a607106e Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 01:11:53 +0800 Subject: [PATCH 12/24] chore: trace Hyperliquid market data flow Add rate-limited diagnostics across bg Fast L2 processing and main-runtime order book and mark-price rendering so the final iOS bundle can isolate a stalled layer without logging full payloads. Constraint: The next iOS bundle is the penultimate diagnostic round and must distinguish bg data flow from main rendering. Rejected: Log every Fast L2 frame or full order books | Excessive production log volume and unnecessary market payload retention. Confidence: high Scope-risk: narrow Directive: Remove these temporary diagnostics after the next device-log verification. Tested: 12 related Jest suites (87 tests), worktree and staged lint, git diff check. Not-tested: iOS bundle/device; repository baseline TypeScript error remains in AutoSizeInput.native.tsx:103. --- .../ServiceHyperliquidSubscription.ts | 36 +++++++++ .../views/Perp/components/PerpOrderBook.tsx | 64 ++++++++++++++++ .../TickerBar/MobilePerpMarketHeader.tsx | 64 ++++++++++++++++ .../logger/scopes/perp/scenes/hyperliquid.ts | 10 +++ .../src/utils/hyperliquidDiagnostic.test.ts | 75 +++++++++++++++++++ .../shared/src/utils/hyperliquidDiagnostic.ts | 48 ++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 packages/shared/src/utils/hyperliquidDiagnostic.test.ts create mode 100644 packages/shared/src/utils/hyperliquidDiagnostic.ts diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index 975bd05c6f26..e969c82b232f 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -21,6 +21,11 @@ import { import platformEnv from '@onekeyhq/shared/src/platformEnv'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { isAppVisible } from '@onekeyhq/shared/src/utils/appVisibility'; +import { + HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, + type IHyperliquidDiagnosticHeartbeatState, + recordHyperliquidDiagnosticHeartbeat, +} from '@onekeyhq/shared/src/utils/hyperliquidDiagnostic'; import { clearTrackedInterval, trackedSetInterval, @@ -227,6 +232,10 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _orderBookDiagnosticSequence = 0; + private _fastL2DeltaDiagnosticState: IHyperliquidDiagnosticHeartbeatState = { + pendingCount: 0, + }; + private static readonly FAST_L2_SNAPSHOT_TIMEOUT_MS = 3000; private static readonly FAST_L2_RECOVERY_DELAYS_MS = [0, 1000, 3000]; @@ -309,6 +318,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } this._fastL2Book = null; this._fastL2SubscriptionKey = null; + this._fastL2DeltaDiagnosticState = { pendingCount: 0 }; } private _clearActiveL2BookSpec(subscriptionKey?: string): void { @@ -2365,6 +2375,32 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { bidLevels: normalizedBook.levels?.[0]?.length ?? 0, askLevels: normalizedBook.levels?.[1]?.length ?? 0, }); + } else { + const heartbeat = recordHyperliquidDiagnosticHeartbeat({ + state: this._fastL2DeltaDiagnosticState, + now: Date.now(), + intervalMs: HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, + }); + this._fastL2DeltaDiagnosticState = heartbeat.state; + if (heartbeat.shouldLog) { + const bestBid = normalizedBook.levels?.[0]?.[0]; + const bestAsk = normalizedBook.levels?.[1]?.[0]; + this._logOrderBookSwitchDiagnostic('fast-l2-delta-heartbeat', { + mode: this._currentState.tradingMode, + coin: normalizedBook.coin, + nSigFigs: normalizedBook.nSigFigs ?? null, + mantissa: normalizedBook.mantissa ?? null, + transport: 'l2', + hasBook: true, + bidLevels: normalizedBook.levels?.[0]?.length ?? 0, + askLevels: normalizedBook.levels?.[1]?.length ?? 0, + sampleCount: heartbeat.sampleCount, + bestBidPx: bestBid?.px, + bestBidSz: bestBid?.sz, + bestAskPx: bestAsk?.px, + bestAskSz: bestAsk?.sz, + }); + } } this._markSubscriptionActivity(subscriptionType, messageTimestamp); if (this._fastL2SnapshotTimer && fastL2Book.hasSnapshot) { diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index abe8fe439579..42f680c76061 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -27,6 +27,12 @@ import { usePerpsShouldShowEnableTradingButtonAtom } from '@onekeyhq/kit-bg/src/ import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { markPerpsColdStartPerfOnce } from '@onekeyhq/shared/src/performance/perpsColdStartPerf'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { + HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, + type IHyperliquidDiagnosticHeartbeatState, + recordHyperliquidDiagnosticHeartbeat, +} from '@onekeyhq/shared/src/utils/hyperliquidDiagnostic'; import { getPerpsOrderBookTickOptionWithCache } from '@onekeyhq/shared/src/utils/perpsOrderBookTickOptionsCache'; import type { IL2BookOptions } from '@onekeyhq/shared/types/hyperliquid/types'; @@ -535,6 +541,9 @@ export function PerpOrderBook({ Record >({}); const renderStateSignatureRef = useRef(undefined); + const visibleBookDiagnosticTargetRef = useRef(undefined); + const visibleBookDiagnosticStateRef = + useRef({ pendingCount: 0 }); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const tickReferencePrice = usePerpsMidByCoin(activeTradeInstrument.coin); const [formData] = useTradingFormAtom(); @@ -948,6 +957,61 @@ export function PerpOrderBook({ shouldShowEnableTradingButton, ]); + useEffect(() => { + if (!platformEnv.isNative || gtMd) { + return; + } + const target = [ + activeTradeInstrument.mode, + activeTradeInstrument.coin, + visibleL2Book?.coin ?? '', + hasRenderOrderBook ? 'book' : 'loading', + orderBookRenderSource, + ].join('|'); + const force = visibleBookDiagnosticTargetRef.current !== target; + visibleBookDiagnosticTargetRef.current = target; + + const heartbeat = recordHyperliquidDiagnosticHeartbeat({ + state: visibleBookDiagnosticStateRef.current, + now: Date.now(), + intervalMs: HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, + force, + }); + visibleBookDiagnosticStateRef.current = heartbeat.state; + if (!heartbeat.shouldLog) { + return; + } + + const bestBid = visibleL2Book?.bids[0]; + const bestAsk = visibleL2Book?.asks[0]; + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'ui-book-heartbeat', + mode: activeTradeInstrument.mode, + coin: activeTradeInstrument.coin, + nSigFigs: l2SubscriptionOptions.nSigFigs, + mantissa: l2SubscriptionOptions.mantissa ?? null, + hasBook: hasRenderOrderBook, + bidLevels: visibleL2Book?.bids.length ?? 0, + askLevels: visibleL2Book?.asks.length ?? 0, + sampleCount: heartbeat.sampleCount, + bestBidPx: bestBid?.px, + bestBidSz: bestBid?.sz, + bestAskPx: bestAsk?.px, + bestAskSz: bestAsk?.sz, + source: orderBookRenderSource, + }); + }, [ + activeTradeInstrument.coin, + activeTradeInstrument.mode, + gtMd, + hasRenderOrderBook, + l2SubscriptionOptions.mantissa, + l2SubscriptionOptions.nSigFigs, + orderBookRenderSource, + visibleL2Book, + ]); + const mobileOrderBook = useMemo(() => { if (!hasRenderOrderBook || !visibleL2Book) return null; if (gtMd) return null; diff --git a/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx b/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx index 8d72a3f8cb70..2d7164b6bc68 100644 --- a/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx +++ b/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx @@ -20,7 +20,14 @@ import { useSpotExternalMarketCapsAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { markPerpsColdStartPerfOnce } from '@onekeyhq/shared/src/performance/perpsColdStartPerf'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { + HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, + type IHyperliquidDiagnosticHeartbeatState, + recordHyperliquidDiagnosticHeartbeat, +} from '@onekeyhq/shared/src/utils/hyperliquidDiagnostic'; import { formatLocalizedNumberString, numberFormat, @@ -58,6 +65,9 @@ function StatRow({ label, children }: { label: string; children: ReactNode }) { function MobilePerpMarketHeader() { const intl = useIntl(); const layoutRef = useRef(undefined); + const markPriceDiagnosticTargetRef = useRef(undefined); + const markPriceDiagnosticStateRef = + useRef({ pendingCount: 0 }); const { coin, mode } = useActiveTradeDisplay(); const [connectionState] = useConnectionStateAtom(); const { @@ -145,6 +155,60 @@ function MobilePerpMarketHeader() { showSkeleton, ]); + useEffect(() => { + if (!platformEnv.isNative) { + return; + } + let source: string = assetCtxSource; + if (isSpot) { + source = currentCtx ? 'spot-active-asset' : 'empty'; + } + const target = [ + mode, + coin, + source, + currentCtx ? 'ctx' : 'noCtx', + showSkeleton ? 'skeleton' : 'ready', + ].join('|'); + const force = markPriceDiagnosticTargetRef.current !== target; + markPriceDiagnosticTargetRef.current = target; + + const heartbeat = recordHyperliquidDiagnosticHeartbeat({ + state: markPriceDiagnosticStateRef.current, + now: Date.now(), + intervalMs: HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, + force, + }); + markPriceDiagnosticStateRef.current = heartbeat.state; + if (!heartbeat.shouldLog) { + return; + } + + defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ + runtime: 'main', + event: 'mark-price-state', + mode, + coin, + source, + markPrice, + midPrice, + hasCurrentCtx: Boolean(currentCtx), + showSkeleton, + cacheAgeMs: cacheAgeMs ?? null, + sampleCount: heartbeat.sampleCount, + }); + }, [ + assetCtxSource, + cacheAgeMs, + coin, + currentCtx, + isSpot, + markPrice, + midPrice, + mode, + showSkeleton, + ]); + const handleLayout = useCallback( (state: 'skeleton' | 'ready', event: LayoutChangeEvent) => { const rect = getPerpsMobileLayoutTraceRect(event); diff --git a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts index 4910ed587840..808112c7cce9 100644 --- a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts +++ b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts @@ -432,6 +432,16 @@ export class HyperLiquidScene extends BaseScene { hasBook?: boolean; bidLevels?: number; askLevels?: number; + sampleCount?: number; + bestBidPx?: string; + bestBidSz?: string; + bestAskPx?: string; + bestAskSz?: string; + markPrice?: string; + midPrice?: string; + hasCurrentCtx?: boolean; + showSkeleton?: boolean; + cacheAgeMs?: number | null; tickOptionValue?: string; tickOptionsCount?: number; tickOptionSource?: 'market' | 'cache' | 'reference' | 'fallback'; diff --git a/packages/shared/src/utils/hyperliquidDiagnostic.test.ts b/packages/shared/src/utils/hyperliquidDiagnostic.test.ts new file mode 100644 index 000000000000..8636291d221a --- /dev/null +++ b/packages/shared/src/utils/hyperliquidDiagnostic.test.ts @@ -0,0 +1,75 @@ +import { recordHyperliquidDiagnosticHeartbeat } from './hyperliquidDiagnostic'; + +describe('recordHyperliquidDiagnosticHeartbeat', () => { + it('logs the first sample immediately', () => { + const result = recordHyperliquidDiagnosticHeartbeat({ + state: { pendingCount: 0 }, + now: 1000, + intervalMs: 1000, + }); + + expect(result).toEqual({ + shouldLog: true, + sampleCount: 1, + state: { + lastLoggedAt: 1000, + pendingCount: 0, + }, + }); + }); + + it('aggregates samples until the interval elapses', () => { + const first = recordHyperliquidDiagnosticHeartbeat({ + state: { pendingCount: 0 }, + now: 1000, + intervalMs: 1000, + }); + const second = recordHyperliquidDiagnosticHeartbeat({ + state: first.state, + now: 1200, + intervalMs: 1000, + }); + const third = recordHyperliquidDiagnosticHeartbeat({ + state: second.state, + now: 1500, + intervalMs: 1000, + }); + const fourth = recordHyperliquidDiagnosticHeartbeat({ + state: third.state, + now: 2000, + intervalMs: 1000, + }); + + expect(second.shouldLog).toBe(false); + expect(third.shouldLog).toBe(false); + expect(fourth).toEqual({ + shouldLog: true, + sampleCount: 3, + state: { + lastLoggedAt: 2000, + pendingCount: 0, + }, + }); + }); + + it('logs a forced transition without waiting for the interval', () => { + const result = recordHyperliquidDiagnosticHeartbeat({ + state: { + lastLoggedAt: 1000, + pendingCount: 2, + }, + now: 1100, + intervalMs: 1000, + force: true, + }); + + expect(result).toEqual({ + shouldLog: true, + sampleCount: 3, + state: { + lastLoggedAt: 1100, + pendingCount: 0, + }, + }); + }); +}); diff --git a/packages/shared/src/utils/hyperliquidDiagnostic.ts b/packages/shared/src/utils/hyperliquidDiagnostic.ts new file mode 100644 index 000000000000..ff391520a9cb --- /dev/null +++ b/packages/shared/src/utils/hyperliquidDiagnostic.ts @@ -0,0 +1,48 @@ +export type IHyperliquidDiagnosticHeartbeatState = { + lastLoggedAt?: number; + pendingCount: number; +}; + +export const HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS = 1000; + +export function recordHyperliquidDiagnosticHeartbeat({ + state, + now, + intervalMs, + force = false, +}: { + state: IHyperliquidDiagnosticHeartbeatState; + now: number; + intervalMs: number; + force?: boolean; +}): { + shouldLog: boolean; + sampleCount: number; + state: IHyperliquidDiagnosticHeartbeatState; +} { + const sampleCount = state.pendingCount + 1; + const shouldLog = + force || + state.lastLoggedAt === undefined || + now - state.lastLoggedAt >= intervalMs; + + if (!shouldLog) { + return { + shouldLog: false, + sampleCount, + state: { + ...state, + pendingCount: sampleCount, + }, + }; + } + + return { + shouldLog: true, + sampleCount, + state: { + lastLoggedAt: now, + pendingCount: 0, + }, + }; +} From a836b093bdd88228c29c7523e284c5b89665f7f8 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 02:08:54 +0800 Subject: [PATCH 13/24] chore: remove temporary Hyperliquid diagnostics Remove the completed Fast L2, order-book transition, mark-price, and open-orders diagnostics while preserving the validated switching and recovery behavior. Constraint: Device verification is complete and production-shaped bundles must no longer persist troubleshooting-only market or list state. Rejected: Keep the rate-limited heartbeat for future debugging | It still adds production log volume and review surface after the issue is verified. Confidence: high Scope-risk: narrow Directive: Add new temporary diagnostics only for a new reproducible investigation and remove them after verification. Tested: 11 related Jest suites (84 tests), worktree and staged lint, git diff check, diagnostic symbol search. Not-tested: iOS device after log removal; repository baseline TypeScript error remains in AutoSizeInput.native.tsx:103. --- .../ServiceHyperliquidSubscription.ts | 161 ++---------------- .../jotai/contexts/hyperliquid/actions.ts | 62 ------- .../components/OrderBook/useTickOptions.ts | 47 +---- .../Components/MobileOpenOrdersListHeader.tsx | 14 +- .../Components/OpenOrdersRow.tsx | 25 +-- .../List/PerpOpenOrdersList.tsx | 117 +------------ .../views/Perp/components/PerpOrderBook.tsx | 91 ---------- .../TickerBar/MobilePerpMarketHeader.tsx | 64 ------- .../logger/scopes/perp/scenes/hyperliquid.ts | 61 ------- .../src/utils/hyperliquidDiagnostic.test.ts | 75 -------- .../shared/src/utils/hyperliquidDiagnostic.ts | 48 ------ 11 files changed, 15 insertions(+), 750 deletions(-) delete mode 100644 packages/shared/src/utils/hyperliquidDiagnostic.test.ts delete mode 100644 packages/shared/src/utils/hyperliquidDiagnostic.ts diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index e969c82b232f..c6605ea9b043 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -21,11 +21,6 @@ import { import platformEnv from '@onekeyhq/shared/src/platformEnv'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { isAppVisible } from '@onekeyhq/shared/src/utils/appVisibility'; -import { - HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, - type IHyperliquidDiagnosticHeartbeatState, - recordHyperliquidDiagnosticHeartbeat, -} from '@onekeyhq/shared/src/utils/hyperliquidDiagnostic'; import { clearTrackedInterval, trackedSetInterval, @@ -230,12 +225,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _fastL2RecoveryPromise: Promise | null = null; - private _orderBookDiagnosticSequence = 0; - - private _fastL2DeltaDiagnosticState: IHyperliquidDiagnosticHeartbeatState = { - pendingCount: 0, - }; - private static readonly FAST_L2_SNAPSHOT_TIMEOUT_MS = 3000; private static readonly FAST_L2_RECOVERY_DELAYS_MS = [0, 1000, 3000]; @@ -245,24 +234,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private static readonly ORDER_BOOK_STRATEGY: 'fastL2Primary' | 'l2BookOnly' = 'fastL2Primary'; - private _logOrderBookSwitchDiagnostic( - event: string, - params: Omit< - Parameters< - typeof defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic - >[0], - 'runtime' | 'event' | 'sequence' - > = {}, - ): void { - this._orderBookDiagnosticSequence += 1; - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'bg', - event, - sequence: this._orderBookDiagnosticSequence, - ...params, - }); - } - // Cross-runtime atom sync can lag behind a reopened socket, leaving current // market subscriptions absent while the socket still looks healthy. private _subscriptionAtomsUnsubs: Array<() => void> = []; @@ -289,22 +260,21 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } this._unwatchSubscriptionAtoms(); - const handler = (source: string) => { + const handler = () => { const client = this._client; if (!client || client.transport?.socket?.readyState !== WebSocket.OPEN) { return; } - this._logOrderBookSwitchDiagnostic('atom-change', { source }); console.log('updateSubscriptions__by__atomWatcher'); void this.updateSubscriptions(); }; this._subscriptionAtomsUnsubs = [ - perpsActiveAccountAtom.sub(() => handler('active-account')), - perpsActiveAssetAtom.sub(() => handler('active-asset')), - spotActiveAssetAtom.sub(() => handler('spot-active-asset')), - tradingModeAtom.sub(() => handler('trading-mode')), - perpsActiveOrderBookOptionsAtom.sub(() => handler('order-book-options')), + perpsActiveAccountAtom.sub(handler), + perpsActiveAssetAtom.sub(handler), + spotActiveAssetAtom.sub(handler), + tradingModeAtom.sub(handler), + perpsActiveOrderBookOptionsAtom.sub(handler), ]; } @@ -318,7 +288,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } this._fastL2Book = null; this._fastL2SubscriptionKey = null; - this._fastL2DeltaDiagnosticState = { pendingCount: 0 }; } private _clearActiveL2BookSpec(subscriptionKey?: string): void { @@ -485,11 +454,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if ( !isOrderBookOptionsTargetReady(currentCoin, activeOrderBookOptions?.coin) ) { - this._logOrderBookSwitchDiagnostic('reconcile-blocked-target-mismatch', { - mode: currentMode, - coin: currentCoin, - source: activeOrderBookOptions?.coin ?? 'none', - }); return undefined; } const isOrderBookOptionsForCurrentCoin = @@ -620,38 +584,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._applyStateUpdates(newState, requiredSubInfo.params); this._currentState = newState; - const orderBookSpec = Object.values( - requiredSubInfo.requiredSubSpecsMap, - ).find( - (spec) => - spec.type === ESubscriptionType.L2 || - spec.type === ESubscriptionType.L2_BOOK, - ); - const fastL2Spec = - orderBookSpec?.type === ESubscriptionType.L2 - ? (orderBookSpec as ISubscriptionSpec) - : undefined; - const l2BookSpec = - orderBookSpec?.type === ESubscriptionType.L2_BOOK - ? (orderBookSpec as ISubscriptionSpec) - : undefined; - let orderBookTransport: 'l2' | 'l2Book' | 'none' = 'none'; - if (fastL2Spec) { - orderBookTransport = 'l2'; - } else if (l2BookSpec) { - orderBookTransport = 'l2Book'; - } - this._logOrderBookSwitchDiagnostic('reconcile-target', { - mode: requiredSubInfo.params.tradingMode, - coin: fastL2Spec?.params.c ?? l2BookSpec?.params.coin, - nSigFigs: fastL2Spec - ? (fastL2Spec.params.s ?? null) - : (l2BookSpec?.params.nSigFigs ?? null), - mantissa: fastL2Spec - ? (fastL2Spec.params.m ?? null) - : (l2BookSpec?.params.mantissa ?? null), - transport: orderBookTransport, - }); this._emitConnectionStatus(); await this._executeSubscriptionChanges(); this._scheduleCriticalSubscriptionHealthCheck('update_subscriptions'); @@ -1897,15 +1829,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { try { const lifecycleVersion = this._subscriptionLifecycleVersion; if (spec.type === ESubscriptionType.L2) { - const fastL2Spec = spec as ISubscriptionSpec; - this._logOrderBookSwitchDiagnostic('subscribe-start', { - mode: this._currentState.tradingMode, - coin: fastL2Spec.params.c, - nSigFigs: fastL2Spec.params.s ?? null, - mantissa: fastL2Spec.params.m ?? null, - transport: 'l2', - }); - this._prepareFastL2Book(fastL2Spec); + this._prepareFastL2Book( + spec as ISubscriptionSpec, + ); } const client = await this._createSubscriptionDirect(spec); const isCreateResultStale = @@ -1936,15 +1862,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { spec as ISubscriptionSpec; } if (spec.type === ESubscriptionType.L2) { - const fastL2Spec = spec as ISubscriptionSpec; - this._logOrderBookSwitchDiagnostic('subscribe-complete', { - mode: this._currentState.tradingMode, - coin: fastL2Spec.params.c, - nSigFigs: fastL2Spec.params.s ?? null, - mantissa: fastL2Spec.params.m ?? null, - transport: 'l2', - hasBook: this._fastL2Book?.hasSnapshot ?? false, - }); this._startFastL2SnapshotTimer(spec.key); } } catch (error) { @@ -1985,18 +1902,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ): Promise { try { if (spec) { - const fastL2Spec = - spec.type === ESubscriptionType.L2 - ? (spec as ISubscriptionSpec) - : undefined; - if (fastL2Spec) { - this._logOrderBookSwitchDiagnostic('unsubscribe-start', { - mode: this._currentState.tradingMode, - coin: fastL2Spec.params.c, - nSigFigs: fastL2Spec.params.s ?? null, - mantissa: fastL2Spec.params.m ?? null, - transport: 'l2', - }); + if (spec.type === ESubscriptionType.L2) { this._resetFastL2Book(spec.key); } const shouldRemoveCache = options?.removeCache ?? true; @@ -2022,15 +1928,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } // await sdkSub.unsubscribe(); await client.unsubscribe(spec.type, spec.params); - if (fastL2Spec) { - this._logOrderBookSwitchDiagnostic('unsubscribe-complete', { - mode: this._currentState.tradingMode, - coin: fastL2Spec.params.c, - nSigFigs: fastL2Spec.params.s ?? null, - mantissa: fastL2Spec.params.m ?? null, - transport: 'l2', - }); - } clearActiveL2BookSpec(); removeSubCache(); return true; @@ -2364,44 +2261,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (!normalizedBook) { return; } - if ('s' in fastL2Frame) { - this._logOrderBookSwitchDiagnostic('snapshot-applied', { - mode: this._currentState.tradingMode, - coin: normalizedBook.coin, - nSigFigs: normalizedBook.nSigFigs ?? null, - mantissa: normalizedBook.mantissa ?? null, - transport: 'l2', - hasBook: true, - bidLevels: normalizedBook.levels?.[0]?.length ?? 0, - askLevels: normalizedBook.levels?.[1]?.length ?? 0, - }); - } else { - const heartbeat = recordHyperliquidDiagnosticHeartbeat({ - state: this._fastL2DeltaDiagnosticState, - now: Date.now(), - intervalMs: HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, - }); - this._fastL2DeltaDiagnosticState = heartbeat.state; - if (heartbeat.shouldLog) { - const bestBid = normalizedBook.levels?.[0]?.[0]; - const bestAsk = normalizedBook.levels?.[1]?.[0]; - this._logOrderBookSwitchDiagnostic('fast-l2-delta-heartbeat', { - mode: this._currentState.tradingMode, - coin: normalizedBook.coin, - nSigFigs: normalizedBook.nSigFigs ?? null, - mantissa: normalizedBook.mantissa ?? null, - transport: 'l2', - hasBook: true, - bidLevels: normalizedBook.levels?.[0]?.length ?? 0, - askLevels: normalizedBook.levels?.[1]?.length ?? 0, - sampleCount: heartbeat.sampleCount, - bestBidPx: bestBid?.px, - bestBidSz: bestBid?.sz, - bestAskPx: bestAsk?.px, - bestAskSz: bestAsk?.sz, - }); - } - } this._markSubscriptionActivity(subscriptionType, messageTimestamp); if (this._fastL2SnapshotTimer && fastL2Book.hasSnapshot) { clearTimeout(this._fastL2SnapshotTimer); diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index d99c4754efeb..929bc8febb77 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -533,33 +533,12 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { nSigFigs: stored?.nSigFigs ?? null, mantissa: stored?.mantissa ?? null, }; - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'options-sync-start', - requestId: params.requestId, - mode: params.instrument.mode, - coin: params.instrument.coin, - nSigFigs: nextOrderBookOptions.nSigFigs, - mantissa: nextOrderBookOptions.mantissa, - hasTickOption: Boolean(stored), - latest: this.isLatestActiveInstrumentChange(params.requestId), - }); const isLatest = await publishLatestOrderBookOptions({ read: () => perpsActiveOrderBookOptionsAtom.get(), write: (value) => perpsActiveOrderBookOptionsAtom.set(() => value), next: nextOrderBookOptions, isLatest: () => this.isLatestActiveInstrumentChange(params.requestId), }); - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'options-sync-result', - requestId: params.requestId, - mode: params.instrument.mode, - coin: params.instrument.coin, - nSigFigs: nextOrderBookOptions.nSigFigs, - mantissa: nextOrderBookOptions.mantissa, - latest: isLatest, - }); if (!isLatest) { return; } @@ -1714,13 +1693,6 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { }, ) => { const requestId = existingRequestId ?? this.beginActiveInstrumentChange(); - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'spot-switch-start', - requestId, - mode: 'spot', - coin, - }); const optimisticInstrument: IActiveTradeInstrument = { mode: 'spot', coin, @@ -1762,31 +1734,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { await this.ensureOrderBookTickOptionsLoaded.call(set); if (!this.isLatestActiveInstrumentChange(requestId)) { - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'spot-switch-stale-after-options-load', - requestId, - mode: 'spot', - coin, - latest: false, - }); return false; } - const storedTickOption = getPerpsOrderBookTickOptionWithCache({ - coin, - options: get(orderBookTickOptionsAtom()), - }); - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'spot-switch-options-ready', - requestId, - mode: 'spot', - coin, - nSigFigs: storedTickOption?.nSigFigs ?? null, - mantissa: storedTickOption?.mantissa ?? null, - hasTickOption: Boolean(storedTickOption), - latest: true, - }); if (!(await this.waitForActiveInstrumentChangeSettle(requestId))) { return false; } @@ -1804,17 +1753,6 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { if (!this.isLatestActiveInstrumentChange(requestId)) { return false; } - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'spot-switch-target-published', - requestId, - mode: 'spot', - coin, - nSigFigs: storedTickOption?.nSigFigs ?? null, - mantissa: storedTickOption?.mantissa ?? null, - hasTickOption: Boolean(storedTickOption), - latest: true, - }); void this.syncSubscriptionsAfterInstrumentChange({ instrument: optimisticInstrument, orderBookTickOptions: get(orderBookTickOptionsAtom()), diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index 3633e410d2bb..dd6e32b6341d 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -6,7 +6,6 @@ import { useHyperliquidActions, useOrderBookTickOptionsAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; -import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { getPerpsOrderBookTickOptionsWithCache } from '@onekeyhq/shared/src/utils/perpsOrderBookTickOptionsCache'; import { analyzeOrderBookPrecision, @@ -31,7 +30,6 @@ interface ITickOptionsResult { setSelectedTickOption: (option: ITickParam) => void; priceDecimals: number; sizeDecimals: number; - tickOptionSource: 'market' | 'cache' | 'reference' | 'fallback'; } const emptyTickOption: ITickParam = { @@ -186,18 +184,6 @@ export function useTickOptions({ return tickOptionsData; }, [tickOptionsData]); - const tickOptionSource = useMemo(() => { - if (!tickOptionsData) { - return 'fallback' as const; - } - if (topBidPrice || topAskPrice) { - return 'market' as const; - } - return tickOptionsData === referenceTickOptionsData - ? ('reference' as const) - : ('cache' as const); - }, [referenceTickOptionsData, tickOptionsData, topAskPrice, topBidPrice]); - const selectedTickOption = useMemo(() => { const { tickOptions, defaultTickOption } = baseTickOptionsData; @@ -240,16 +226,6 @@ export function useTickOptions({ persisted, }) ) { - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'tick-option-initialize', - coin: symbol, - nSigFigs: currentPersist.nSigFigs, - mantissa: currentPersist.mantissa, - tickOptionValue: currentPersist.value, - tickOptionsCount: baseTickOptionsData.tickOptions.length, - tickOptionSource, - }); void actions.current.setOrderBookTickOption({ symbol, option: currentPersist, @@ -260,8 +236,6 @@ export function useTickOptions({ persistedTickOptions, selectedTickOption, tickOptionsData, - baseTickOptionsData.tickOptions.length, - tickOptionSource, actions, ]); @@ -276,17 +250,6 @@ export function useTickOptions({ return; } - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'tick-option-select', - coin: symbol, - nSigFigs: option.nSigFigs, - mantissa: option.mantissa ?? null, - tickOptionValue: option.value, - tickOptionsCount: baseTickOptionsData.tickOptions.length, - tickOptionSource, - }); - void actions.current.setOrderBookTickOption({ symbol, option: { @@ -296,13 +259,7 @@ export function useTickOptions({ }, }); }, - [ - actions, - baseTickOptionsData.tickOptions.length, - selectedTickOption, - symbol, - tickOptionSource, - ], + [actions, selectedTickOption, symbol], ); return useMemo(() => { @@ -313,13 +270,11 @@ export function useTickOptions({ setSelectedTickOption: handleSelectTickOption, priceDecimals: baseTickOptionsData.priceDecimals, sizeDecimals, - tickOptionSource, }; }, [ baseTickOptionsData, selectedTickOption, handleSelectTickOption, sizeDecimals, - tickOptionSource, ]); } diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx index 81d06dc2ea2b..b3a3e8596e7a 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileOpenOrdersListHeader.tsx @@ -5,7 +5,6 @@ import { useIntl } from 'react-intl'; import { Button, Checkbox, SizableText, XStack } from '@onekeyhq/components'; import { useOrderFilterByCurrentTokenAtom } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; -import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { showCancelAllOrdersDialog } from '../CancelAllOrdersModal'; @@ -33,18 +32,9 @@ export function MobileOpenOrdersListHeader({ const handleFilterChange = useCallback( (value: boolean | 'indeterminate') => { - const nextFilterValue = value === true; - defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ - runtime: 'main', - event: 'checkbox-change', - isMobile: true, - filterByCurrentToken, - checkboxValue: value, - nextFilterValue, - }); - setFilterByCurrentToken(nextFilterValue); + setFilterByCurrentToken(value === true); }, - [filterByCurrentToken, setFilterByCurrentToken], + [setFilterByCurrentToken], ); // Early return when no orders exist diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx index e1687865e46a..37156ae4f5be 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import BigNumber from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -11,7 +11,6 @@ import { useSpotPairDisplayNameMapAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; -import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { @@ -242,28 +241,6 @@ const OpenOrdersRow = memo( const shouldRenderLeft = renderMode === 'full' || renderMode === 'left'; const shouldRenderRight = renderMode === 'full' || renderMode === 'right'; - useEffect(() => { - if (!isMobile) { - return undefined; - } - defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ - runtime: 'main', - event: 'row-commit', - isMobile: true, - rowCoin: order.coin, - rowIndex: index, - }); - return () => { - defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ - runtime: 'main', - event: 'row-cleanup', - isMobile: true, - rowCoin: order.coin, - rowIndex: index, - }); - }; - }, [index, isMobile, order.coin]); - if (isMobile) { return ( { @@ -249,7 +217,6 @@ function PerpOpenOrdersList({ const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const actions = useHyperliquidActions(); const [currentListPage, setCurrentListPage] = useState(1); - const diagnosticSequenceRef = useRef(0); const canMutateScopedOrders = isPerpsAccountAddressMatched({ activeAccountAddress: currentUser?.accountAddress, dataAccountAddress: accountScopedAddress, @@ -417,88 +384,6 @@ function PerpOpenOrdersList({ ]; }, [activeOpenOrdersSubTab, filteredOrders, filteredTwapOrders, isMobile]); - useEffect(() => { - if (!isMobile) { - return; - } - diagnosticSequenceRef.current += 1; - const sequence = diagnosticSequenceRef.current; - const activeCoin = activeTradeInstrument?.coin ?? ''; - const filterMode = getOrderFilterMode({ - isMobile, - filterByCurrentToken, - activeCoin, - }); - const visibleOrders = - activeOpenOrdersSubTab === 'twap' ? filteredTwapOrders : filteredOrders; - const sourceOrders = - activeOpenOrdersSubTab === 'twap' ? scopedTwapOrders : openOrders; - const orderCoins = sourceOrders - .map(getOrderCoin) - .slice(0, DIAGNOSTIC_COIN_LIMIT); - const filteredCoins = visibleOrders - .map(getOrderCoin) - .slice(0, DIAGNOSTIC_COIN_LIMIT); - const displayCoins = displayRows - .map(getDisplayRowCoin) - .slice(0, DIAGNOSTIC_COIN_LIMIT); - const unsafeFallback = Boolean( - isMobile && - filterByCurrentToken && - !activeCoin && - sourceOrders.length > 0, - ); - const mismatchedVisibleCount = activeCoin - ? visibleOrders.filter((order) => getOrderCoin(order) !== activeCoin) - .length - : 0; - const snapshot = { - runtime: 'main' as const, - sequence, - isMobile: Boolean(isMobile), - filterByCurrentToken, - activeCoin, - activeSubTab: activeOpenOrdersSubTab, - currentPage: currentListPage, - filterMode, - unsafeFallback, - openOrdersCount: sourceOrders.length, - filteredOrdersCount: visibleOrders.length, - displayRowsCount: displayRows.length, - mismatchedVisibleCount, - orderCoins, - filteredCoins, - displayCoins, - }; - defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ - event: 'list-commit', - ...snapshot, - }); - if (unsafeFallback) { - defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ - event: 'unsafe-unfiltered-fallback', - ...snapshot, - }); - } - requestAnimationFrame(() => { - defaultLogger.perp.hyperliquid.openOrdersFilterDiagnostic({ - event: 'list-next-frame', - ...snapshot, - }); - }); - }, [ - activeOpenOrdersSubTab, - activeTradeInstrument?.coin, - currentListPage, - displayRows, - filterByCurrentToken, - filteredOrders, - filteredTwapOrders, - isMobile, - openOrders, - scopedTwapOrders, - ]); - const columnsConfig = useOpenOrdersColumnsConfig({ openOrdersLength: openOrders.length, enableCancelAll: canMutateScopedOrders, diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index 42f680c76061..79b6974fdf6d 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -27,12 +27,6 @@ import { usePerpsShouldShowEnableTradingButtonAtom } from '@onekeyhq/kit-bg/src/ import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { markPerpsColdStartPerfOnce } from '@onekeyhq/shared/src/performance/perpsColdStartPerf'; -import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import { - HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, - type IHyperliquidDiagnosticHeartbeatState, - recordHyperliquidDiagnosticHeartbeat, -} from '@onekeyhq/shared/src/utils/hyperliquidDiagnostic'; import { getPerpsOrderBookTickOptionWithCache } from '@onekeyhq/shared/src/utils/perpsOrderBookTickOptionsCache'; import type { IL2BookOptions } from '@onekeyhq/shared/types/hyperliquid/types'; @@ -541,9 +535,6 @@ export function PerpOrderBook({ Record >({}); const renderStateSignatureRef = useRef(undefined); - const visibleBookDiagnosticTargetRef = useRef(undefined); - const visibleBookDiagnosticStateRef = - useRef({ pendingCount: 0 }); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const tickReferencePrice = usePerpsMidByCoin(activeTradeInstrument.coin); const [formData] = useTradingFormAtom(); @@ -626,10 +617,6 @@ export function PerpOrderBook({ const candidateL2Book = activeRenderL2Book ?? initialCachedL2Book; const visibleL2Book = hasInitializedTickOption ? candidateL2Book : null; const hasRenderOrderBook = Boolean(visibleL2Book); - let orderBookRenderSource = 'none'; - if (candidateL2Book) { - orderBookRenderSource = hasRenderOrderBook ? 'visible' : 'provisional'; - } const handleVisualBookChange = useCallback((book: IL2BookData | null) => { setRenderL2Book((prevBook) => (prevBook === book ? prevBook : book)); @@ -780,7 +767,6 @@ export function PerpOrderBook({ setSelectedTickOption, priceDecimals, sizeDecimals, - tickOptionSource, } = tickOptionsData; const handleTickOptionChange = useCallback( @@ -917,22 +903,6 @@ export function PerpOrderBook({ shouldShowEnableTradingButton, hasTpsl: formData.hasTpsl, }); - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'ui-render-state', - mode: activeTradeInstrument.mode, - coin: activeTradeInstrument.coin, - nSigFigs: l2SubscriptionOptions.nSigFigs, - mantissa: l2SubscriptionOptions.mantissa ?? null, - hasTickOption: hasInitializedTickOption, - hasBook: hasRenderOrderBook, - bidLevels: candidateL2Book?.bids.length ?? 0, - askLevels: candidateL2Book?.asks.length ?? 0, - tickOptionValue: selectedTickOption.value, - tickOptionsCount: tickOptions.length, - tickOptionSource, - source: orderBookRenderSource, - }); }, [ activeTradeInstrument.coin, activeTradeInstrument.mode, @@ -944,12 +914,6 @@ export function PerpOrderBook({ gtMd, hasInitializedTickOption, hasRenderOrderBook, - l2SubscriptionOptions.mantissa, - l2SubscriptionOptions.nSigFigs, - orderBookRenderSource, - selectedTickOption.value, - tickOptionSource, - tickOptions.length, visibleL2Book?.asks.length, visibleL2Book?.bids.length, visibleL2Book?.coin, @@ -957,61 +921,6 @@ export function PerpOrderBook({ shouldShowEnableTradingButton, ]); - useEffect(() => { - if (!platformEnv.isNative || gtMd) { - return; - } - const target = [ - activeTradeInstrument.mode, - activeTradeInstrument.coin, - visibleL2Book?.coin ?? '', - hasRenderOrderBook ? 'book' : 'loading', - orderBookRenderSource, - ].join('|'); - const force = visibleBookDiagnosticTargetRef.current !== target; - visibleBookDiagnosticTargetRef.current = target; - - const heartbeat = recordHyperliquidDiagnosticHeartbeat({ - state: visibleBookDiagnosticStateRef.current, - now: Date.now(), - intervalMs: HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, - force, - }); - visibleBookDiagnosticStateRef.current = heartbeat.state; - if (!heartbeat.shouldLog) { - return; - } - - const bestBid = visibleL2Book?.bids[0]; - const bestAsk = visibleL2Book?.asks[0]; - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'ui-book-heartbeat', - mode: activeTradeInstrument.mode, - coin: activeTradeInstrument.coin, - nSigFigs: l2SubscriptionOptions.nSigFigs, - mantissa: l2SubscriptionOptions.mantissa ?? null, - hasBook: hasRenderOrderBook, - bidLevels: visibleL2Book?.bids.length ?? 0, - askLevels: visibleL2Book?.asks.length ?? 0, - sampleCount: heartbeat.sampleCount, - bestBidPx: bestBid?.px, - bestBidSz: bestBid?.sz, - bestAskPx: bestAsk?.px, - bestAskSz: bestAsk?.sz, - source: orderBookRenderSource, - }); - }, [ - activeTradeInstrument.coin, - activeTradeInstrument.mode, - gtMd, - hasRenderOrderBook, - l2SubscriptionOptions.mantissa, - l2SubscriptionOptions.nSigFigs, - orderBookRenderSource, - visibleL2Book, - ]); - const mobileOrderBook = useMemo(() => { if (!hasRenderOrderBook || !visibleL2Book) return null; if (gtMd) return null; diff --git a/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx b/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx index 2d7164b6bc68..8d72a3f8cb70 100644 --- a/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx +++ b/packages/kit/src/views/Perp/components/TickerBar/MobilePerpMarketHeader.tsx @@ -20,14 +20,7 @@ import { useSpotExternalMarketCapsAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; -import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { markPerpsColdStartPerfOnce } from '@onekeyhq/shared/src/performance/perpsColdStartPerf'; -import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import { - HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, - type IHyperliquidDiagnosticHeartbeatState, - recordHyperliquidDiagnosticHeartbeat, -} from '@onekeyhq/shared/src/utils/hyperliquidDiagnostic'; import { formatLocalizedNumberString, numberFormat, @@ -65,9 +58,6 @@ function StatRow({ label, children }: { label: string; children: ReactNode }) { function MobilePerpMarketHeader() { const intl = useIntl(); const layoutRef = useRef(undefined); - const markPriceDiagnosticTargetRef = useRef(undefined); - const markPriceDiagnosticStateRef = - useRef({ pendingCount: 0 }); const { coin, mode } = useActiveTradeDisplay(); const [connectionState] = useConnectionStateAtom(); const { @@ -155,60 +145,6 @@ function MobilePerpMarketHeader() { showSkeleton, ]); - useEffect(() => { - if (!platformEnv.isNative) { - return; - } - let source: string = assetCtxSource; - if (isSpot) { - source = currentCtx ? 'spot-active-asset' : 'empty'; - } - const target = [ - mode, - coin, - source, - currentCtx ? 'ctx' : 'noCtx', - showSkeleton ? 'skeleton' : 'ready', - ].join('|'); - const force = markPriceDiagnosticTargetRef.current !== target; - markPriceDiagnosticTargetRef.current = target; - - const heartbeat = recordHyperliquidDiagnosticHeartbeat({ - state: markPriceDiagnosticStateRef.current, - now: Date.now(), - intervalMs: HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS, - force, - }); - markPriceDiagnosticStateRef.current = heartbeat.state; - if (!heartbeat.shouldLog) { - return; - } - - defaultLogger.perp.hyperliquid.orderBookSwitchDiagnostic({ - runtime: 'main', - event: 'mark-price-state', - mode, - coin, - source, - markPrice, - midPrice, - hasCurrentCtx: Boolean(currentCtx), - showSkeleton, - cacheAgeMs: cacheAgeMs ?? null, - sampleCount: heartbeat.sampleCount, - }); - }, [ - assetCtxSource, - cacheAgeMs, - coin, - currentCtx, - isSpot, - markPrice, - midPrice, - mode, - showSkeleton, - ]); - const handleLayout = useCallback( (state: 'skeleton' | 'ready', event: LayoutChangeEvent) => { const rect = getPerpsMobileLayoutTraceRect(event); diff --git a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts index 808112c7cce9..b44d856ed6ac 100644 --- a/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts +++ b/packages/shared/src/logger/scopes/perp/scenes/hyperliquid.ts @@ -417,67 +417,6 @@ export class HyperLiquidScene extends BaseScene { }) { return params; } - - @LogToLocal({ level: 'info' }) - public orderBookSwitchDiagnostic(params: { - runtime: 'main' | 'bg'; - event: string; - sequence?: number; - requestId?: number; - mode?: 'perp' | 'spot'; - coin?: string; - nSigFigs?: number | null; - mantissa?: number | null; - hasTickOption?: boolean; - hasBook?: boolean; - bidLevels?: number; - askLevels?: number; - sampleCount?: number; - bestBidPx?: string; - bestBidSz?: string; - bestAskPx?: string; - bestAskSz?: string; - markPrice?: string; - midPrice?: string; - hasCurrentCtx?: boolean; - showSkeleton?: boolean; - cacheAgeMs?: number | null; - tickOptionValue?: string; - tickOptionsCount?: number; - tickOptionSource?: 'market' | 'cache' | 'reference' | 'fallback'; - latest?: boolean; - transport?: 'l2' | 'l2Book' | 'none'; - source?: string; - }) { - return params; - } - - @LogToLocal({ level: 'info' }) - public openOrdersFilterDiagnostic(params: { - runtime: 'main'; - event: string; - sequence?: number; - isMobile?: boolean; - filterByCurrentToken?: boolean; - checkboxValue?: boolean | 'indeterminate'; - nextFilterValue?: boolean; - activeCoin?: string; - activeSubTab?: 'basic' | 'twap'; - currentPage?: number; - filterMode?: 'not-mobile' | 'disabled' | 'missing-active-coin' | 'active'; - unsafeFallback?: boolean; - openOrdersCount?: number; - filteredOrdersCount?: number; - displayRowsCount?: number; - mismatchedVisibleCount?: number; - orderCoins?: string[]; - filteredCoins?: string[]; - displayCoins?: string[]; - rowCoin?: string; - rowIndex?: number; - }) { - return params; - } } export type IHyperLiquidOrderAction = diff --git a/packages/shared/src/utils/hyperliquidDiagnostic.test.ts b/packages/shared/src/utils/hyperliquidDiagnostic.test.ts deleted file mode 100644 index 8636291d221a..000000000000 --- a/packages/shared/src/utils/hyperliquidDiagnostic.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { recordHyperliquidDiagnosticHeartbeat } from './hyperliquidDiagnostic'; - -describe('recordHyperliquidDiagnosticHeartbeat', () => { - it('logs the first sample immediately', () => { - const result = recordHyperliquidDiagnosticHeartbeat({ - state: { pendingCount: 0 }, - now: 1000, - intervalMs: 1000, - }); - - expect(result).toEqual({ - shouldLog: true, - sampleCount: 1, - state: { - lastLoggedAt: 1000, - pendingCount: 0, - }, - }); - }); - - it('aggregates samples until the interval elapses', () => { - const first = recordHyperliquidDiagnosticHeartbeat({ - state: { pendingCount: 0 }, - now: 1000, - intervalMs: 1000, - }); - const second = recordHyperliquidDiagnosticHeartbeat({ - state: first.state, - now: 1200, - intervalMs: 1000, - }); - const third = recordHyperliquidDiagnosticHeartbeat({ - state: second.state, - now: 1500, - intervalMs: 1000, - }); - const fourth = recordHyperliquidDiagnosticHeartbeat({ - state: third.state, - now: 2000, - intervalMs: 1000, - }); - - expect(second.shouldLog).toBe(false); - expect(third.shouldLog).toBe(false); - expect(fourth).toEqual({ - shouldLog: true, - sampleCount: 3, - state: { - lastLoggedAt: 2000, - pendingCount: 0, - }, - }); - }); - - it('logs a forced transition without waiting for the interval', () => { - const result = recordHyperliquidDiagnosticHeartbeat({ - state: { - lastLoggedAt: 1000, - pendingCount: 2, - }, - now: 1100, - intervalMs: 1000, - force: true, - }); - - expect(result).toEqual({ - shouldLog: true, - sampleCount: 3, - state: { - lastLoggedAt: 1100, - pendingCount: 0, - }, - }); - }); -}); diff --git a/packages/shared/src/utils/hyperliquidDiagnostic.ts b/packages/shared/src/utils/hyperliquidDiagnostic.ts deleted file mode 100644 index ff391520a9cb..000000000000 --- a/packages/shared/src/utils/hyperliquidDiagnostic.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type IHyperliquidDiagnosticHeartbeatState = { - lastLoggedAt?: number; - pendingCount: number; -}; - -export const HYPERLIQUID_DIAGNOSTIC_HEARTBEAT_INTERVAL_MS = 1000; - -export function recordHyperliquidDiagnosticHeartbeat({ - state, - now, - intervalMs, - force = false, -}: { - state: IHyperliquidDiagnosticHeartbeatState; - now: number; - intervalMs: number; - force?: boolean; -}): { - shouldLog: boolean; - sampleCount: number; - state: IHyperliquidDiagnosticHeartbeatState; -} { - const sampleCount = state.pendingCount + 1; - const shouldLog = - force || - state.lastLoggedAt === undefined || - now - state.lastLoggedAt >= intervalMs; - - if (!shouldLog) { - return { - shouldLog: false, - sampleCount, - state: { - ...state, - pendingCount: sampleCount, - }, - }; - } - - return { - shouldLog: true, - sampleCount, - state: { - lastLoggedAt: now, - pendingCount: 0, - }, - }; -} From b7a3849beeb017b5a3f96eb87a8a127bba27c180 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 02:46:29 +0800 Subject: [PATCH 14/24] fix: prevent stale mobile open order views OK-57833 Constraint: Preserve PerpOpenOrdersList React state while removing hidden native views on iOS and Android. Rejected: Unmount the entire list component | resets local tab and pagination state. Rejected: Limit the fix to iOS | Android shares the React Native native-view lifecycle risk. Confidence: high Scope-risk: narrow Directive: Keep Web and desktop hidden-panel behavior unchanged unless separately reproduced. Tested: 33 Perp Jest suites with 241 tests; staged and worktree lint. Not-tested: Physical iOS and Android devices; full typecheck remains blocked by the pre-existing AutoSizeInput.native.tsx:103 error. --- .../List/PerpOpenOrdersList.tsx | 29 +++++++++---- .../List/mobileOpenOrdersVisibility.test.ts | 42 +++++++++++++++++++ .../List/mobileOpenOrdersVisibility.ts | 11 +++++ .../views/Perp/layouts/PerpMobileLayout.tsx | 7 +++- 4 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.test.ts create mode 100644 packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.ts diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx index 0fa3339db727..b64409315b48 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx @@ -24,6 +24,7 @@ import { useSpotActiveOpenOrdersAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; import type { IPerpsFrontendOrder } from '@onekeyhq/shared/types/hyperliquid/sdk'; import { usePerpsAccountScopedCacheAddress } from '../../../hooks/usePerpsAccountScopedCacheAddress'; @@ -41,9 +42,11 @@ import { OpenOrdersRow } from '../Components/OpenOrdersRow'; import { OrderInfoSubTabs } from '../Components/OrderInfoSubTabs'; import { CommonTableListView, type IColumnConfig } from './CommonTableListView'; +import { shouldRenderMobileOpenOrdersNativeTree } from './mobileOpenOrdersVisibility'; interface IPerpOpenOrdersListProps { isMobile?: boolean; + isPanelActive?: boolean; useTabsList?: boolean; disableListScroll?: boolean; } @@ -202,6 +205,7 @@ function useOpenOrdersColumnsConfig({ function PerpOpenOrdersList({ isMobile, + isPanelActive, useTabsList, disableListScroll, }: IPerpOpenOrdersListProps) { @@ -527,6 +531,23 @@ function PerpOpenOrdersList({ ) : null; const listEmptyComponent = activeOpenOrdersSubTab === 'twap' ? : undefined; + const listViewDebugRenderTrackerProps = useMemo( + (): IDebugRenderTrackerProps => ({ + name: 'PerpOpenOrdersList', + position: 'top-left', + }), + [], + ); + + if ( + !shouldRenderMobileOpenOrdersNativeTree({ + isNative: Boolean(platformEnv.isNative), + isMobile, + isPanelActive, + }) + ) { + return null; + } return ( ({ - name: 'PerpOpenOrdersList', - position: 'top-left', - }), - [], - )} + listViewDebugRenderTrackerProps={listViewDebugRenderTrackerProps} useTabsList={useTabsList} disableListScroll={disableListScroll} enablePagination diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.test.ts b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.test.ts new file mode 100644 index 000000000000..d30cf2acb6b3 --- /dev/null +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.test.ts @@ -0,0 +1,42 @@ +import { shouldRenderMobileOpenOrdersNativeTree } from './mobileOpenOrdersVisibility'; + +describe('shouldRenderMobileOpenOrdersNativeTree', () => { + it('removes the hidden iOS and Android mobile view tree', () => { + expect( + shouldRenderMobileOpenOrdersNativeTree({ + isNative: true, + isMobile: true, + isPanelActive: false, + }), + ).toBe(false); + }); + + it('renders the active native mobile view tree', () => { + expect( + shouldRenderMobileOpenOrdersNativeTree({ + isNative: true, + isMobile: true, + isPanelActive: true, + }), + ).toBe(true); + }); + + it('preserves the hidden web mobile view tree', () => { + expect( + shouldRenderMobileOpenOrdersNativeTree({ + isNative: false, + isMobile: true, + isPanelActive: false, + }), + ).toBe(true); + }); + + it('preserves existing call sites without panel visibility state', () => { + expect( + shouldRenderMobileOpenOrdersNativeTree({ + isNative: true, + isMobile: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.ts b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.ts new file mode 100644 index 000000000000..fad778ef612f --- /dev/null +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/mobileOpenOrdersVisibility.ts @@ -0,0 +1,11 @@ +export function shouldRenderMobileOpenOrdersNativeTree({ + isNative, + isMobile, + isPanelActive, +}: { + isNative: boolean; + isMobile?: boolean; + isPanelActive?: boolean; +}) { + return !(isNative && isMobile && isPanelActive === false); +} diff --git a/packages/kit/src/views/Perp/layouts/PerpMobileLayout.tsx b/packages/kit/src/views/Perp/layouts/PerpMobileLayout.tsx index 9b1471181348..2701ae1d8490 100644 --- a/packages/kit/src/views/Perp/layouts/PerpMobileLayout.tsx +++ b/packages/kit/src/views/Perp/layouts/PerpMobileLayout.tsx @@ -463,7 +463,12 @@ export function PerpMobileLayout() { flex={1} onLayout={(event) => handleTraceLayout('openOrdersPanel', event)} > - + Date: Thu, 16 Jul 2026 03:06:50 +0800 Subject: [PATCH 15/24] fix: cancel stale fast l2 recovery OK-57953 Invalidate delayed recovery work after a healthy delta or subscription lifecycle change, and recheck its generation immediately before subscription teardown. Constraint: Fast L2 recovery waits may overlap healthy bg-runtime frames for the same target. Rejected: Clear only retry counters | the delayed promise would still destroy a recovered subscription. Confidence: high Scope-risk: narrow Directive: Keep delayed recovery side effects guarded by target and generation at execution time. Tested: FastL2Book Jest suite (9 tests); lint-worktree-ts; lint-staged; git diff --check. Not-tested: Full tsc-staged remains blocked by the release baseline AutoSizeInput.native.tsx:103 type error. --- .../ServiceHyperliquidSubscription.ts | 32 ++++++++++++++----- .../utils/FastL2Book.test.ts | 29 ++++++++++++++++- .../ServiceHyperLiquid/utils/FastL2Book.ts | 20 ++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index c6605ea9b043..2a74bb407a7e 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -78,6 +78,7 @@ import hyperLiquidCache from './hyperLiquidCache'; import { FastL2Book, type IFastL2Frame, + isFastL2RecoveryCurrent, isStaleFastL2TargetError, shouldResetFastL2RecoveryAfterFrame, } from './utils/FastL2Book'; @@ -225,6 +226,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _fastL2RecoveryPromise: Promise | null = null; + private _fastL2RecoveryGeneration = 0; + private static readonly FAST_L2_SNAPSHOT_TIMEOUT_MS = 3000; private static readonly FAST_L2_RECOVERY_DELAYS_MS = [0, 1000, 3000]; @@ -298,10 +301,15 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } private _resetFastL2Recovery(): void { + this._invalidateFastL2RecoveryTask(); this._fastL2TargetKey = null; this._fastL2FallbackTargetKey = null; this._fastL2RecoveryAttempts = 0; this._fastL2ReconnectAttempted = false; + } + + private _invalidateFastL2RecoveryTask(): void { + this._fastL2RecoveryGeneration += 1; this._fastL2RecoveryPromise = null; } @@ -310,6 +318,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ): void { this._resetFastL2Book(); if (this._fastL2TargetKey !== spec.key) { + this._invalidateFastL2RecoveryTask(); this._fastL2TargetKey = spec.key; this._fastL2FallbackTargetKey = null; this._fastL2RecoveryAttempts = 0; @@ -354,6 +363,15 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (!spec) { return; } + const startedGeneration = this._fastL2RecoveryGeneration; + const isRecoveryCurrent = () => + isFastL2RecoveryCurrent({ + startedGeneration, + currentGeneration: this._fastL2RecoveryGeneration, + targetKey, + currentTargetKey: this._fastL2TargetKey, + isTargetPending: Boolean(this.pendingSubSpecsMap[targetKey]), + }); const recoveryPromise = (async () => { const delay = @@ -365,10 +383,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (delay > 0) { await timerUtils.wait(delay); } - if ( - this._fastL2TargetKey !== targetKey || - !this.pendingSubSpecsMap[targetKey] - ) { + if (!isRecoveryCurrent()) { return; } console.warn( @@ -377,14 +392,14 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const resetSucceeded = await this._subscriptionMutationQueue.enqueue( ServiceHyperliquidSubscription.ORDER_BOOK_MUTATION_KEY, async () => { + if (!isRecoveryCurrent()) { + return true; + } const destroyed = await this._destroySubscription(spec); if (!destroyed) { return false; } - if ( - this._fastL2TargetKey === targetKey && - this.pendingSubSpecsMap[targetKey] - ) { + if (isRecoveryCurrent()) { await this._createSubscription(spec); } return true; @@ -2267,6 +2282,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._fastL2SnapshotTimer = null; } if (shouldResetFastL2RecoveryAfterFrame(fastL2Frame, normalizedBook)) { + this._invalidateFastL2RecoveryTask(); this._fastL2RecoveryAttempts = 0; this._fastL2ReconnectAttempted = false; this._fastL2FallbackTargetKey = null; diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts index e95cf02dbf30..73da7b99059a 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.test.ts @@ -1,6 +1,33 @@ import type { IBook } from '@onekeyhq/shared/types/hyperliquid/sdk'; -import { FastL2Book, shouldResetFastL2RecoveryAfterFrame } from './FastL2Book'; +import { + FastL2Book, + isFastL2RecoveryCurrent, + shouldResetFastL2RecoveryAfterFrame, +} from './FastL2Book'; + +describe('isFastL2RecoveryCurrent', () => { + it('invalidates a delayed recovery after a healthy frame advances generation', () => { + expect( + isFastL2RecoveryCurrent({ + startedGeneration: 1, + currentGeneration: 2, + targetKey: 'l2:{\"coin\":\"ETH\"}', + currentTargetKey: 'l2:{\"coin\":\"ETH\"}', + isTargetPending: true, + }), + ).toBe(false); + expect( + isFastL2RecoveryCurrent({ + startedGeneration: 2, + currentGeneration: 2, + targetKey: 'l2:{\"coin\":\"ETH\"}', + currentTargetKey: 'l2:{\"coin\":\"ETH\"}', + isTargetPending: true, + }), + ).toBe(true); + }); +}); describe('shouldResetFastL2RecoveryAfterFrame', () => { const snapshot: IBook = { diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts index 4b162a3c5be6..4a9596bc1a13 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/FastL2Book.ts @@ -32,6 +32,26 @@ export function shouldResetFastL2RecoveryAfterFrame( return normalizedBook !== null && !('s' in frame); } +export function isFastL2RecoveryCurrent({ + startedGeneration, + currentGeneration, + targetKey, + currentTargetKey, + isTargetPending, +}: { + startedGeneration: number; + currentGeneration: number; + targetKey: string; + currentTargetKey: string | null; + isTargetPending: boolean; +}): boolean { + return ( + startedGeneration === currentGeneration && + targetKey === currentTargetKey && + isTargetPending + ); +} + export type IFastL2BookError = Error & { code: 'invalid_stream' | 'stale_target'; }; From cb90bc9894432f0e412ec1d25b025dd319e9ddf4 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 15:59:23 +0800 Subject: [PATCH 16/24] fix: prevent invalid Hyperliquid spot depth options Constrain generated aggregation ticks to Hyperliquid's asset-specific decimal floor and keep small labels in fixed decimal form. Constraint: Spot price decimals cannot exceed 8 minus szDecimals. Rejected: UI-only formatting workaround | it would leave an invalid subscription option available. Confidence: high Scope-risk: narrow Directive: Keep future tick options bounded by both significant figures and asset decimal limits. Tested: yarn jest packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts --runInBand; lint-worktree-ts; lint-staged Not-tested: tsc-staged is blocked by the existing AutoSizeInput.native.tsx mostRecentEventCount baseline error. --- .../OrderBook/tickSizeUtils.test.ts | 28 +++++++++++++++++++ .../components/OrderBook/tickSizeUtils.ts | 10 +++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts index bee2f35c74e2..408542388e2d 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts @@ -119,6 +119,34 @@ describe('buildReferenceTickOptions', () => { }), ).toBeNull(); }); + + it('does not expose spot tick options finer than the protocol decimal limit', () => { + const result = buildReferenceTickOptions({ + symbol: '@591', + price: '0.0000004', + szDecimals: 1, + isSpot: true, + }); + + expect(result?.tickOptions.map((option) => option.value)).toEqual([ + '0.0000001', + ]); + }); + + it('formats small tick options without scientific notation', () => { + const result = buildReferenceTickOptions({ + symbol: 'TEST', + price: '0.002699', + szDecimals: 0, + isSpot: true, + }); + + expect( + result?.tickOptions.every( + (option) => !option.label.includes('e') && !option.value.includes('e'), + ), + ).toBe(true); + }); }); describe('shouldInitializeOrderBookTickOption', () => { diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts index 184a7082e65c..d631a36a9d5d 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts @@ -124,12 +124,18 @@ export function buildReferenceTickOptions({ for (const multiplier of multipliers) { const targetTick = minimumTick.multipliedBy(multiplier).toNumber(); const mapped = mapTickToParams(priceNumber, targetTick); - const apiTickKey = new BigNumber(mapped.apiTick).toFixed(); - if (!seenApiTicks.has(apiTickKey)) { + const apiTick = new BigNumber(mapped.apiTick); + const apiTickKey = apiTick.toFixed(); + if ( + apiTick.isGreaterThanOrEqualTo(minimumTick) && + !seenApiTicks.has(apiTickKey) + ) { seenApiTicks.add(apiTickKey); tickOptions.push({ ...mapped, multiplier, + label: apiTickKey, + value: apiTickKey, }); } } From af71b4edba5909a686d71618831c2d7508bb728e Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 17:58:53 +0800 Subject: [PATCH 17/24] perf: improve Hyperliquid order book startup Constraint: Preserve exact coin and precision guards across native and desktop runtimes. Rejected: One-shot HTTP l2Book bootstrap | connection and cache fixes cover the measured bottlenecks. Confidence: high Scope-risk: moderate Directive: Keep cached order books visual-only until the first live frame. Tested: 58 targeted tests; lint-worktree-ts; lint-staged Not-tested: Physical iOS, Android, and desktop runtime latency --- .../dbs/simple/entity/SimpleDbEntityPerp.ts | 40 +++-- .../ServiceHyperliquidCache.test.ts | 130 ++++++++++++++++- .../ServiceHyperliquidCache.ts | 138 +++++++++++++----- .../ServiceHyperliquidSubscription.ts | 53 ++++--- .../utils/SubscriptionConfig.test.ts | 27 ++++ .../utils/SubscriptionConfig.ts | 13 ++ .../utils/SubscriptionMutationQueue.test.ts | 31 ++++ .../utils/SubscriptionMutationQueue.ts | 12 ++ .../jotai/contexts/hyperliquid/actions.ts | 2 +- .../hyperliquid/utils/l2BookUtils.test.ts | 37 +++++ .../contexts/hyperliquid/utils/l2BookUtils.ts | 19 ++- .../views/Perp/components/PerpOrderBook.tsx | 4 + .../src/views/Perp/hooks/usePerpMarketData.ts | 13 +- .../views/Perp/utils/l2BookFreshness.test.ts | 51 +++++-- .../src/views/Perp/utils/l2BookFreshness.ts | 19 ++- 15 files changed, 490 insertions(+), 99 deletions(-) diff --git a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityPerp.ts b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityPerp.ts index 131cbec91c15..9d56a1eac056 100644 --- a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityPerp.ts +++ b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityPerp.ts @@ -417,24 +417,40 @@ export class SimpleDbEntityPerp extends SimpleDbEntityBase { mantissa?: number | null; data: IBook; }) { - if (!coin || !data) { + await this.setL2BookSnapshotCaches([{ coin, nSigFigs, mantissa, data }]); + } + + @backgroundMethod() + async setL2BookSnapshotCaches( + snapshots: Array<{ + coin: string; + nSigFigs?: number | null; + mantissa?: number | null; + data: IBook; + }>, + ) { + const validSnapshots = snapshots.filter( + (snapshot) => snapshot.coin && snapshot.data, + ); + if (validSnapshots.length === 0) { return; } await this.setPerpData((prev): ISimpleDbPerpData => { - const key = this._getL2BookSnapshotCacheKey({ - coin, - nSigFigs, - mantissa, - }); - const nextCache = { - ...prev?.l2BookSnapshotCache, - [key]: { + const updatedAt = Date.now(); + const nextCache = { ...prev?.l2BookSnapshotCache }; + validSnapshots.forEach(({ coin, nSigFigs, mantissa, data }) => { + const key = this._getL2BookSnapshotCacheKey({ + coin, + nSigFigs, + mantissa, + }); + nextCache[key] = { data, - updatedAt: Date.now(), + updatedAt, nSigFigs, mantissa, - }, - }; + }; + }); return { ...prev, l2BookSnapshotCache: this._limitSnapshotCacheEntries(nextCache), diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts index 5335841f5c3b..394f979f7f7e 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.test.ts @@ -4,7 +4,7 @@ import { } from '@onekeyhq/shared/src/utils/swrCacheUtils'; import type { IBook } from '@onekeyhq/shared/types/hyperliquid/sdk'; -import { +import ServiceHyperliquidCache, { buildL2BookSnapshotCachePayload, getL2BookSnapshotCacheEntryLevelCount, getL2BookSnapshotSwrCache, @@ -69,13 +69,13 @@ describe('ServiceHyperliquidCache L2 book helpers', () => { ).toBe(12); }); - it('selects the newest complete L2 book snapshot from cache candidates', () => { + it('selects the newest two-sided L2 book even when the market is shallow', () => { const simpleDbEntry = buildEntry({ updatedAt: 100, bidLevels: 18, askLevels: 18, }); - const newerIncompleteSwrEntry = buildEntry({ + const newerShallowSwrEntry = buildEntry({ updatedAt: 200, bidLevels: 25, askLevels: 4, @@ -84,9 +84,29 @@ describe('ServiceHyperliquidCache L2 book helpers', () => { expect( selectL2BookSnapshotCacheEntry({ simpleDbEntry, - swrEntry: newerIncompleteSwrEntry, + swrEntry: newerShallowSwrEntry, + }), + ).toBe(newerShallowSwrEntry); + }); + + it('rejects snapshots with an empty side', () => { + const validEntry = buildEntry({ + updatedAt: 100, + bidLevels: 1, + askLevels: 1, + }); + const newerOneSidedEntry = buildEntry({ + updatedAt: 200, + bidLevels: 25, + askLevels: 0, + }); + + expect( + selectL2BookSnapshotCacheEntry({ + simpleDbEntry: validEntry, + swrEntry: newerOneSidedEntry, }), - ).toBe(simpleDbEntry); + ).toBe(validEntry); }); it('builds option-specific payload only for the active book', () => { @@ -177,6 +197,106 @@ describe('ServiceHyperliquidCache L2 book helpers', () => { }); }); +describe('ServiceHyperliquidCache L2 book runtime cache', () => { + afterEach(() => { + delete ( + globalThis as typeof globalThis & { + $onekeyIsInBackground?: boolean; + } + ).$onekeyIsInBackground; + jest.useRealTimers(); + swrCacheUtils.clearAll(); + swrCacheUtils.flushNow(); + }); + + function createService() { + ( + globalThis as typeof globalThis & { + $onekeyIsInBackground?: boolean; + } + ).$onekeyIsInBackground = true; + const getL2BookSnapshotCache = jest.fn().mockResolvedValue(undefined); + const setL2BookSnapshotCaches = jest.fn().mockResolvedValue(undefined); + const service = new ServiceHyperliquidCache({ + backgroundApi: { + simpleDb: { + perp: { + getL2BookSnapshotCache, + setL2BookSnapshotCaches, + }, + }, + }, + }); + return { + service, + getL2BookSnapshotCache, + setL2BookSnapshotCaches, + }; + } + + it('serves the exact hot target without waiting for persisted storage', async () => { + const { service, getL2BookSnapshotCache } = createService(); + const data = Object.assign( + buildBook({ coin: 'ETH', bidLevels: 4, askLevels: 4 }), + { nSigFigs: 5, mantissa: 2 }, + ); + + service.cacheL2BookSnapshot({ + data, + activeBookCoin: 'ETH', + activeOptions: { nSigFigs: 5, mantissa: 2 }, + }); + + await expect( + service.getL2BookSnapshotCache({ + coin: 'ETH', + nSigFigs: 5, + mantissa: 2, + }), + ).resolves.toMatchObject({ + coin: 'ETH', + nSigFigs: 5, + mantissa: 2, + isCachedSnapshot: true, + }); + expect(getL2BookSnapshotCache).not.toHaveBeenCalled(); + }); + + it('keeps one pending snapshot per target during rapid switches', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-16T00:00:00.000Z')); + const { service, setL2BookSnapshotCaches } = createService(); + const cache = (coin: string, bidPrice: string) => { + const data = buildBook({ coin, bidLevels: 4, askLevels: 4 }); + data.levels[0][0] = { px: bidPrice, sz: '1', n: 1 }; + service.cacheL2BookSnapshot({ + data, + activeBookCoin: coin, + activeOptions: { nSigFigs: 5, mantissa: null }, + }); + }; + + cache('BTC', '100'); + cache('ETH', '200'); + cache('SOL', '300'); + cache('ETH', '201'); + service.flushPendingL2BookSnapshotCache(); + + expect(setL2BookSnapshotCaches).toHaveBeenCalledTimes(2); + expect(setL2BookSnapshotCaches.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ + coin: 'ETH', + data: expect.objectContaining({ + levels: expect.arrayContaining([ + expect.arrayContaining([expect.objectContaining({ px: '201' })]), + ]), + }), + }), + expect.objectContaining({ coin: 'SOL' }), + ]); + }); +}); + describe('ServiceHyperliquidCache account display write throttle', () => { it('allows the first write and later writes outside the interval', () => { expect( diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts index 263fb3f19a5a..ba2653108204 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidCache.ts @@ -10,18 +10,20 @@ import { PERPS_ACCOUNT_DISPLAY_SNAPSHOT_MAX_ENTRIES, PERPS_ALL_DEXS_ASSET_CTXS_CACHE_WRITE_INTERVAL_MS, PERPS_COLD_START_MARKET_CACHE_MAX_AGE_MS, - PERPS_L2_BOOK_SNAPSHOT_CACHE_MIN_LEVELS_PER_SIDE, PERPS_L2_BOOK_SNAPSHOT_CACHE_WRITE_INTERVAL_MS, + PERPS_SNAPSHOT_CACHE_MAX_ENTRIES, } from '@onekeyhq/shared/src/consts/perpCache'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { markPerpsColdStartPerf, markPerpsColdStartPerfOnce, } from '@onekeyhq/shared/src/performance/perpsColdStartPerf'; +import cacheUtils from '@onekeyhq/shared/src/utils/cacheUtils'; import perpsUtils from '@onekeyhq/shared/src/utils/perpsUtils'; import { getPerpsL2BookSnapshotCacheKeys, swrCacheUtils, + swrKeys, } from '@onekeyhq/shared/src/utils/swrCacheUtils'; import type { EHyperLiquidAbstractionMode } from '@onekeyhq/shared/types/hyperliquid'; import type { @@ -90,10 +92,7 @@ export function getL2BookSnapshotCacheEntryLevelCount( export function isL2BookSnapshotCacheEntryComplete( entry: IPerpsL2BookSnapshotCacheEntry | undefined, ): entry is IPerpsL2BookSnapshotCacheEntry { - return ( - getL2BookSnapshotCacheEntryLevelCount(entry) >= - PERPS_L2_BOOK_SNAPSHOT_CACHE_MIN_LEVELS_PER_SIDE - ); + return getL2BookSnapshotCacheEntryLevelCount(entry) > 0; } export function selectL2BookSnapshotCacheEntry({ @@ -213,7 +212,6 @@ function setL2BookSnapshotSwrCache({ mantissa, }); keys.forEach((key) => swrCacheUtils.set(key, data)); - swrCacheUtils.flushNow(); return true; } catch (error) { defaultLogger.perp.hyperliquid.cacheSnapshotError({ @@ -224,6 +222,30 @@ function setL2BookSnapshotSwrCache({ } } +function getL2BookSnapshotTargetKey({ + coin, + nSigFigs, + mantissa, +}: { + coin: string; + nSigFigs?: number | null; + mantissa?: number | null; +}) { + return swrKeys.perpsL2BookSnapshot({ coin, nSigFigs, mantissa }); +} + +function buildCachedL2Book( + entry: IPerpsL2BookSnapshotCacheEntry, +): IBook & { localReceivedAt?: number; isCachedSnapshot?: boolean } { + return { + ...entry.data, + nSigFigs: entry.nSigFigs ?? null, + mantissa: entry.mantissa ?? null, + localReceivedAt: entry.updatedAt, + isCachedSnapshot: true, + }; +} + function getPerpsActiveAssetAvailableToTradeDisplay( activeAssetData: IPerpsActiveAssetData | undefined, ) { @@ -269,6 +291,14 @@ export default class ServiceHyperliquidCache extends ServiceBase { private _lastL2BookSnapshotCacheWriteAt = 0; + private _l2BookSnapshotMemoryCache = new cacheUtils.LRUCache< + string, + IPerpsL2BookSnapshotCacheEntry + >({ + max: PERPS_SNAPSHOT_CACHE_MAX_ENTRIES, + ttl: PERPS_COLD_START_MARKET_CACHE_MAX_AGE_MS, + }); + private _lastAccountDisplayCacheWriteAt: Record< string, Partial> @@ -277,9 +307,10 @@ export default class ServiceHyperliquidCache extends ServiceBase { private _l2BookSnapshotCacheTimer: ReturnType | null = null; - private _pendingL2BookSnapshotCache: - | IPerpsL2BookSnapshotCachePayload - | undefined; + private _pendingL2BookSnapshotCache = new Map< + string, + IPerpsL2BookSnapshotCachePayload + >(); @backgroundMethod() async getL2BookSnapshotCache({ @@ -296,6 +327,19 @@ export default class ServiceHyperliquidCache extends ServiceBase { nSigFigs, mantissa, }); + const memoryEntry = this._l2BookSnapshotMemoryCache.get( + getL2BookSnapshotTargetKey({ coin, nSigFigs, mantissa }), + ); + if (isL2BookSnapshotCacheEntryComplete(memoryEntry)) { + markPerpsColdStartPerf('service_l2_book_cache_hit', { + coin, + source: 'memory', + ageMs: Date.now() - memoryEntry.updatedAt, + bidLevels: memoryEntry.data.levels?.[0]?.length ?? 0, + askLevels: memoryEntry.data.levels?.[1]?.length ?? 0, + }); + return buildCachedL2Book(memoryEntry); + } const entry = await this.backgroundApi.simpleDb.perp.getL2BookSnapshotCache( { coin, @@ -331,12 +375,7 @@ export default class ServiceHyperliquidCache extends ServiceBase { simpleDbLevels: getL2BookSnapshotCacheEntryLevelCount(entry), swrLevels: getL2BookSnapshotCacheEntryLevelCount(swrEntry), }); - return { - ...cacheEntry.data, - nSigFigs: cacheEntry.nSigFigs ?? null, - mantissa: cacheEntry.mantissa ?? null, - localReceivedAt: cacheEntry.updatedAt, - } as IBook & { localReceivedAt?: number }; + return buildCachedL2Book(cacheEntry); } writeActiveAssetCtxSnapshotCache(data: IWsActiveAssetCtx) { @@ -433,19 +472,37 @@ export default class ServiceHyperliquidCache extends ServiceBase { }); } - private _writeL2BookSnapshotCache(payload: IPerpsL2BookSnapshotCachePayload) { + private _writeL2BookSnapshotCaches( + payloads: IPerpsL2BookSnapshotCachePayload[], + ) { + if (payloads.length === 0) { + return; + } this._lastL2BookSnapshotCacheWriteAt = Date.now(); - const didWriteSwrCache = setL2BookSnapshotSwrCache(payload); - markPerpsColdStartPerfOnce('service_l2_book_ws_cache_write_first', { - coin: payload.coin, - bidLevels: payload.data.levels?.[0]?.length ?? 0, - askLevels: payload.data.levels?.[1]?.length ?? 0, - nSigFigs: payload.nSigFigs, - mantissa: payload.mantissa, - swr: didWriteSwrCache, + let didWriteSwrCache = false; + payloads.forEach((payload) => { + didWriteSwrCache = setL2BookSnapshotSwrCache(payload) || didWriteSwrCache; + markPerpsColdStartPerfOnce('service_l2_book_ws_cache_write_first', { + coin: payload.coin, + bidLevels: payload.data.levels?.[0]?.length ?? 0, + askLevels: payload.data.levels?.[1]?.length ?? 0, + nSigFigs: payload.nSigFigs, + mantissa: payload.mantissa, + swr: didWriteSwrCache, + }); }); + if (didWriteSwrCache) { + try { + swrCacheUtils.flushNow(); + } catch (error) { + defaultLogger.perp.hyperliquid.cacheSnapshotError({ + type: 'l2_book_swr', + error, + }); + } + } void this.backgroundApi.simpleDb.perp - .setL2BookSnapshotCache(payload) + .setL2BookSnapshotCaches(payloads) .catch((error) => { defaultLogger.perp.hyperliquid.cacheSnapshotError({ type: 'l2_book_simple_db', @@ -459,10 +516,10 @@ export default class ServiceHyperliquidCache extends ServiceBase { clearTimeout(this._l2BookSnapshotCacheTimer); this._l2BookSnapshotCacheTimer = null; } - const pending = this._pendingL2BookSnapshotCache; - this._pendingL2BookSnapshotCache = undefined; - if (pending) { - this._writeL2BookSnapshotCache(pending); + const pending = Array.from(this._pendingL2BookSnapshotCache.values()); + this._pendingL2BookSnapshotCache.clear(); + if (pending.length > 0) { + this._writeL2BookSnapshotCaches(pending); } } @@ -484,26 +541,29 @@ export default class ServiceHyperliquidCache extends ServiceBase { return; } - const now = Date.now(); - const elapsed = now - this._lastL2BookSnapshotCacheWriteAt; + const targetKey = getL2BookSnapshotTargetKey(payload); + const updatedAt = Date.now(); + this._l2BookSnapshotMemoryCache.set(targetKey, { + data: payload.data, + updatedAt, + nSigFigs: payload.nSigFigs, + mantissa: payload.mantissa, + }); + this._pendingL2BookSnapshotCache.set(targetKey, payload); + + const elapsed = updatedAt - this._lastL2BookSnapshotCacheWriteAt; if (elapsed >= PERPS_L2_BOOK_SNAPSHOT_CACHE_WRITE_INTERVAL_MS) { - this._pendingL2BookSnapshotCache = undefined; - this._writeL2BookSnapshotCache(payload); + this.flushPendingL2BookSnapshotCache(); return; } - this._pendingL2BookSnapshotCache = payload; if (this._l2BookSnapshotCacheTimer) { return; } this._l2BookSnapshotCacheTimer = setTimeout(() => { this._l2BookSnapshotCacheTimer = null; - const pending = this._pendingL2BookSnapshotCache; - this._pendingL2BookSnapshotCache = undefined; - if (pending) { - this._writeL2BookSnapshotCache(pending); - } + this.flushPendingL2BookSnapshotCache(); }, PERPS_L2_BOOK_SNAPSHOT_CACHE_WRITE_INTERVAL_MS - elapsed); } diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index 2a74bb407a7e..f45e83ab438c 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -85,12 +85,14 @@ import { import { SUBSCRIPTION_TYPE_INFO, calculateRequiredSubscriptionsMap, + getSubscriptionResumeAction, isOrderBookOptionsTargetReady, normalizeL2BookForSubscriptionSpec, } from './utils/SubscriptionConfig'; import { PerKeyMutationQueue, executeOrderBookSubscriptionTransition, + executeSubscriptionTasksWithOrderBookPriority, } from './utils/SubscriptionMutationQueue'; import { LatestSubscriptionReconcileQueue } from './utils/SubscriptionReconcileQueue'; @@ -601,6 +603,9 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { this._currentState = newState; this._emitConnectionStatus(); await this._executeSubscriptionChanges(); + if (this._activeSubscriptions.size > 0) { + this._startPostOpenDataCheck(); + } this._scheduleCriticalSubscriptionHealthCheck('update_subscriptions'); } @@ -1031,21 +1036,31 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } const client = await this.getWebSocketClient(); - if (client?.transport?.socket?.readyState !== WebSocket.OPEN) { + const readyState = client?.transport?.socket?.readyState; + const action = getSubscriptionResumeAction({ + isOpen: readyState === WebSocket.OPEN, + isClosedOrClosing: + readyState === WebSocket.CLOSED || readyState === WebSocket.CLOSING, + }); + if (action === 'reconnect') { console.log('resumeSubscriptions__force_reconnect_transport'); await this._forceReconnectTransport(); - } else { - // OK-53014: re-install atom watcher since pauseSubscriptions() tore - // it down. The socket is still OPEN here, so socketOpenHandler will - // not fire again to reinstall it for us. - this._watchSubscriptionAtoms(); - await this._reconcileOpenSocketSubscriptionsOnResume({ - forceRebuild: params?.forceRebuild, - reason: params?.forceRebuild - ? 'force_rebuild' - : 'native_resume_stale_data', - }); + return; } + if (action === 'waitForOpen') { + return; + } + + // OK-53014: re-install atom watcher since pauseSubscriptions() tore + // it down. The socket is already OPEN, so socketOpenHandler will not + // fire again to reinstall it for us. + this._watchSubscriptionAtoms(); + await this._reconcileOpenSocketSubscriptionsOnResume({ + forceRebuild: params?.forceRebuild, + reason: params?.forceRebuild + ? 'force_rebuild' + : 'native_resume_stale_data', + }); } @backgroundMethod() @@ -1247,7 +1262,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { ); this._currentState.isConnected = true; this._startPingLoop(); - this._startPostOpenDataCheck(); return; } @@ -1471,8 +1485,6 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { if (wasConnected === false && this._lastMessageAt !== null) { appEventBus.emit(EAppEventBusNames.PerpsWebSocketRecovered, undefined); } - - this._startPostOpenDataCheck(); } catch (error) { defaultLogger.perp.hyperliquid.subscriptionSocketOpenError({ error }); } @@ -1781,13 +1793,13 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { const otherTasks = [ ...toDestroySubscriptions .filter((spec) => !isOrderBookSpec(spec)) - .map((spec) => this._destroySubscription(spec)), + .map((spec) => () => this._destroySubscription(spec)), ...toCreateSubscriptions .filter((spec) => !isOrderBookSpec(spec)) - .map((spec) => this._createSubscription(spec)), + .map((spec) => () => this._createSubscription(spec)), ]; - const orderBookTask = + const orderBookTask = () => orderBookToDestroy.length > 0 || orderBookToCreate.length > 0 ? executeOrderBookSubscriptionTransition({ toDestroy: orderBookToDestroy, @@ -1804,7 +1816,10 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { }) : Promise.resolve(true); - await Promise.all([...otherTasks, orderBookTask]); + await executeSubscriptionTasksWithOrderBookPriority({ + orderBookTask, + otherTasks, + }); } private async _createSubscription( diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts index 486b8bc95e77..647da8fbf5bc 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts @@ -3,12 +3,39 @@ import { ESubscriptionType } from '@onekeyhq/shared/types/hyperliquid/types'; import { calculateRequiredSubscriptions, + getSubscriptionResumeAction, isOrderBookOptionsTargetReady, normalizeL2BookForSubscriptionSpec, } from './SubscriptionConfig'; import type { ISubscriptionSpec } from './SubscriptionConfig'; +describe('getSubscriptionResumeAction', () => { + it('keeps a connecting transport and lets the open handler reconcile', () => { + expect( + getSubscriptionResumeAction({ + isOpen: false, + isClosedOrClosing: false, + }), + ).toBe('waitForOpen'); + }); + + it('reconciles open transports and reconnects closed transports', () => { + expect( + getSubscriptionResumeAction({ + isOpen: true, + isClosedOrClosing: false, + }), + ).toBe('reconcile'); + expect( + getSubscriptionResumeAction({ + isOpen: false, + isClosedOrClosing: true, + }), + ).toBe('reconnect'); + }); +}); + describe('isOrderBookOptionsTargetReady', () => { it('waits for order book options to catch up with the active coin', () => { expect(isOrderBookOptionsTargetReady('ETH', 'BTC')).toBe(false); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts index ab376fc83f60..3c0a06372641 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts @@ -155,6 +155,19 @@ export function isOrderBookOptionsTargetReady( return !currentCoin || !optionsCoin || currentCoin === optionsCoin; } +export function getSubscriptionResumeAction({ + isOpen, + isClosedOrClosing, +}: { + isOpen: boolean; + isClosedOrClosing: boolean; +}): 'reconcile' | 'reconnect' | 'waitForOpen' { + if (isOpen) { + return 'reconcile'; + } + return isClosedOrClosing ? 'reconnect' : 'waitForOpen'; +} + export function generateSubscriptionKey( type: T, params: IPerpsSubscriptionParams[T], diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts index 134801d3426d..1b1a13b65522 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts @@ -1,6 +1,7 @@ import { PerKeyMutationQueue, executeOrderBookSubscriptionTransition, + executeSubscriptionTasksWithOrderBookPriority, } from './SubscriptionMutationQueue'; describe('PerKeyMutationQueue', () => { @@ -49,3 +50,33 @@ describe('executeOrderBookSubscriptionTransition', () => { expect(reconnect).toHaveBeenCalledTimes(1); }); }); + +describe('executeSubscriptionTasksWithOrderBookPriority', () => { + it('starts the order book request first without blocking other requests', async () => { + const events: string[] = []; + let releaseOrderBook: (() => void) | undefined; + const orderBookBlocked = new Promise((resolve) => { + releaseOrderBook = resolve; + }); + + const task = executeSubscriptionTasksWithOrderBookPriority({ + orderBookTask: async () => { + events.push('orderBook:start'); + await orderBookBlocked; + events.push('orderBook:end'); + }, + otherTasks: [ + async () => { + events.push('other'); + }, + ], + }); + + await Promise.resolve(); + expect(events).toEqual(['orderBook:start', 'other']); + + releaseOrderBook?.(); + await task; + expect(events).toEqual(['orderBook:start', 'other', 'orderBook:end']); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts index fa1156d5a361..2ee6f105cc69 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts @@ -51,3 +51,15 @@ export async function executeOrderBookSubscriptionTransition({ } return succeeded; } + +export async function executeSubscriptionTasksWithOrderBookPriority({ + orderBookTask, + otherTasks, +}: { + orderBookTask: () => Promise; + otherTasks: Array<() => Promise>; +}): Promise { + const orderBookPromise = orderBookTask(); + const otherPromises = otherTasks.map((task) => task()); + await Promise.all([orderBookPromise, ...otherPromises]); +} diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 929bc8febb77..47cf264a129c 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -258,7 +258,7 @@ function getFreshL2BookSnapshotFromSwr({ entry?.data?.coin === coin && Date.now() - entry.updatedAt <= PERPS_COLD_START_MARKET_CACHE_MAX_AGE_MS ) { - return withPerpsL2BookLocalReceivedAt(entry.data, entry.updatedAt); + return withPerpsL2BookLocalReceivedAt(entry.data, entry.updatedAt, true); } } return undefined; diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts index 27ece694297e..485bfb7fbd5d 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts @@ -48,6 +48,32 @@ describe('shouldUpdatePerpsL2Book', () => { ).toBe(true); }); + it('replaces a cached snapshot with the first identical live frame', () => { + const cachedBook = Object.assign(buildBook({ time: 1000 }), { + isCachedSnapshot: true, + }); + + expect( + shouldUpdatePerpsL2Book({ + currentBook: cachedBook, + nextBook: buildBook({ time: 1001 }), + }), + ).toBe(true); + }); + + it('does not let a late cached snapshot replace a live book', () => { + const cachedBook = Object.assign(buildBook({ time: 2000, bidPx: '99' }), { + isCachedSnapshot: true, + }); + + expect( + shouldUpdatePerpsL2Book({ + currentBook: buildBook({ time: 1000 }), + nextBook: cachedBook, + }), + ).toBe(false); + }); + it('updates when the incoming book belongs to a different coin', () => { expect( shouldUpdatePerpsL2Book({ @@ -166,6 +192,17 @@ describe('shouldUpdatePerpsL2Book', () => { }); describe('perps market data local receive helpers', () => { + it('marks hydrated snapshots as cached without changing their payload', () => { + expect( + withPerpsL2BookLocalReceivedAt(buildBook({ time: 1000 }), 2000, true), + ).toMatchObject({ + coin: 'ETH', + time: 1000, + localReceivedAt: 2000, + isCachedSnapshot: true, + }); + }); + it('adds local receive timestamps without changing market payload fields', () => { expect( withPerpsL2BookLocalReceivedAt(buildBook({ time: 1000 }), 2000), diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts index 84377b5494c9..ff8ad3573ffa 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts @@ -3,6 +3,7 @@ import type * as HL from '@onekeyhq/shared/types/hyperliquid/sdk'; export type IPerpsL2BookWithLocalReceivedAt = HL.IBook & { localReceivedAt?: number; + isCachedSnapshot?: boolean; }; export type IPerpsBboWithLocalReceivedAt = HL.IWsBbo & { @@ -17,13 +18,13 @@ const PERPS_L2_BOOK_FRESHNESS_REFRESH_MIN_INTERVAL_MS = export function withPerpsL2BookLocalReceivedAt( book: HL.IBook, localReceivedAt?: number, + isCachedSnapshot?: boolean, ): IPerpsL2BookWithLocalReceivedAt { + const current = book as IPerpsL2BookWithLocalReceivedAt; return { ...book, - localReceivedAt: - localReceivedAt ?? - (book as IPerpsL2BookWithLocalReceivedAt).localReceivedAt ?? - Date.now(), + localReceivedAt: localReceivedAt ?? current.localReceivedAt ?? Date.now(), + isCachedSnapshot: isCachedSnapshot ?? current.isCachedSnapshot, }; } @@ -99,6 +100,16 @@ export function shouldUpdatePerpsL2Book({ currentBook: HL.IBook | null; nextBook: HL.IBook; }) { + const isCurrentCached = Boolean( + (currentBook as IPerpsL2BookWithLocalReceivedAt | null)?.isCachedSnapshot, + ); + const isNextCached = Boolean( + (nextBook as IPerpsL2BookWithLocalReceivedAt).isCachedSnapshot, + ); + if (isCurrentCached !== isNextCached) { + return isCurrentCached && !isNextCached; + } + if (!arePerpsL2BookLevelsEqual(currentBook, nextBook)) { return true; } diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index 79b6974fdf6d..ba62a4bf0db0 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -782,6 +782,7 @@ export function PerpOrderBook({ !isPerpsL2BookInteractive({ bookTime: visibleL2Book?.time, bookReceivedAt: visibleL2Book?.localReceivedAt, + isCachedSnapshot: visibleL2Book?.isCachedSnapshot, }) ) { return; @@ -801,6 +802,7 @@ export function PerpOrderBook({ actionsRef, formData.type, visibleL2Book?.localReceivedAt, + visibleL2Book?.isCachedSnapshot, visibleL2Book?.time, ], ); @@ -810,9 +812,11 @@ export function PerpOrderBook({ isPerpsL2BookInteractive({ bookTime: visibleL2Book?.time, bookReceivedAt: visibleL2Book?.localReceivedAt, + isCachedSnapshot: visibleL2Book?.isCachedSnapshot, }), [ isOrderBookInteractive, + visibleL2Book?.isCachedSnapshot, visibleL2Book?.localReceivedAt, visibleL2Book?.time, ], diff --git a/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts b/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts index 3e8a3f7096da..5d63043a5e52 100644 --- a/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts +++ b/packages/kit/src/views/Perp/hooks/usePerpMarketData.ts @@ -57,6 +57,7 @@ export interface IL2BookData extends HL.IBook { bids: HL.IBookLevel[]; asks: HL.IBookLevel[]; localReceivedAt?: number; + isCachedSnapshot?: boolean; } export function getFreshL2BookSnapshotFromSwr({ @@ -80,7 +81,11 @@ export function getFreshL2BookSnapshotFromSwr({ isL2BookForTarget(entry.data, coin, options) && Date.now() - entry.updatedAt <= PERPS_L2_BOOK_SWR_CACHE_MAX_AGE_MS ) { - return withPerpsL2BookLocalReceivedAt(entry.data, entry.updatedAt); + return withPerpsL2BookLocalReceivedAt( + entry.data, + entry.updatedAt, + true, + ); } } return undefined; @@ -114,6 +119,8 @@ export function normalizeL2BookData({ nSigFigs: bookData.nSigFigs, mantissa: bookData.mantissa, localReceivedAt: getPerpsMarketDataLocalReceivedAt(bookData), + isCachedSnapshot: (bookData as HL.IBook & { isCachedSnapshot?: boolean }) + .isCachedSnapshot, bids: bids || [], asks: asks || [], }; @@ -217,12 +224,14 @@ export function useL2Book(options?: IL2BookOptions): { const isOrderBookInteractive = isPerpsL2BookInteractive({ bookTime: l2Book?.time, bookReceivedAt: l2Book?.localReceivedAt, + isCachedSnapshot: l2Book?.isCachedSnapshot, }); useEffect(() => { const refreshDelayMs = getPerpsL2BookInteractiveRefreshDelayMs({ bookTime: l2Book?.time, bookReceivedAt: l2Book?.localReceivedAt, + isCachedSnapshot: l2Book?.isCachedSnapshot, }); if (refreshDelayMs === undefined) { return undefined; @@ -233,7 +242,7 @@ export function useL2Book(options?: IL2BookOptions): { }, refreshDelayMs); return () => clearTimeout(timer); - }, [l2Book?.localReceivedAt, l2Book?.time]); + }, [l2Book?.isCachedSnapshot, l2Book?.localReceivedAt, l2Book?.time]); const getBestBid = (): string | null => { if (!l2Book?.bids || l2Book.bids.length === 0) return null; diff --git a/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts b/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts index 15d3387ad400..d64b9c6888b2 100644 --- a/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts +++ b/packages/kit/src/views/Perp/utils/l2BookFreshness.test.ts @@ -98,21 +98,24 @@ describe('getFreshL2BookSnapshotFromColdCache', () => { mantissa: null, }; - expect( - getFreshL2BookSnapshotFromColdCache({ - coin: 'ETH', - options: { - nSigFigs: 5, - mantissa: null, - }, - cache: { - 'perpsL2Book:v1:ETH:latest': { - data: book, - updatedAt: Date.now(), - }, + const snapshot = getFreshL2BookSnapshotFromColdCache({ + coin: 'ETH', + options: { + nSigFigs: 5, + mantissa: null, + }, + cache: { + 'perpsL2Book:v1:ETH:latest': { + data: book, + updatedAt: Date.now(), }, - }), - ).toBe(book); + }, + }); + + expect(snapshot).toMatchObject(book); + expect( + (snapshot as HL.IBook & { isCachedSnapshot?: boolean })?.isCachedSnapshot, + ).toBe(true); }); it('rejects stale or wrong-coin cold cache entries', () => { @@ -168,6 +171,26 @@ describe('getPerpsL2BookColdCacheGlobalSnapshot', () => { }); describe('isPerpsL2BookInteractive', () => { + it('keeps cached snapshots visual-only even when recently received', () => { + expect( + isPerpsL2BookInteractive({ + bookTime: now, + bookReceivedAt: now, + isCachedSnapshot: true, + now, + }), + ).toBe(false); + + expect( + getPerpsL2BookInteractiveRefreshDelayMs({ + bookTime: now, + bookReceivedAt: now, + isCachedSnapshot: true, + now, + }), + ).toBeUndefined(); + }); + it('allows only fresh order book snapshots to be interactive', () => { expect( isPerpsL2BookInteractive({ diff --git a/packages/kit/src/views/Perp/utils/l2BookFreshness.ts b/packages/kit/src/views/Perp/utils/l2BookFreshness.ts index ebdd7002f04b..b19acacdbaec 100644 --- a/packages/kit/src/views/Perp/utils/l2BookFreshness.ts +++ b/packages/kit/src/views/Perp/utils/l2BookFreshness.ts @@ -14,6 +14,11 @@ export type IPerpsL2BookColdCache = Record< } >; +type IPerpsCachedL2Book = HL.IBook & { + localReceivedAt?: number; + isCachedSnapshot?: boolean; +}; + type IPerpsL2BookColdCacheGlobal = typeof globalThis & { __ONEKEY_PERPS_L2_BOOK_COLD_CACHE__?: IPerpsL2BookColdCache; }; @@ -68,7 +73,11 @@ export function getFreshL2BookSnapshotFromColdCache({ isL2BookForTarget(entry?.data, coin, options) && Date.now() - entry.updatedAt <= maxAgeMs ) { - return entry.data; + return { + ...entry.data, + localReceivedAt: entry.updatedAt, + isCachedSnapshot: true, + } as IPerpsCachedL2Book; } } return undefined; @@ -77,13 +86,15 @@ export function getFreshL2BookSnapshotFromColdCache({ export function isPerpsL2BookInteractive({ bookTime, bookReceivedAt, + isCachedSnapshot, now = Date.now(), }: { bookTime: number | undefined; bookReceivedAt?: number; + isCachedSnapshot?: boolean; now?: number; }) { - if (!bookTime || !Number.isFinite(bookTime)) { + if (isCachedSnapshot || !bookTime || !Number.isFinite(bookTime)) { return false; } const freshnessTime = @@ -98,13 +109,15 @@ export function isPerpsL2BookInteractive({ export function getPerpsL2BookInteractiveRefreshDelayMs({ bookTime, bookReceivedAt, + isCachedSnapshot, now = Date.now(), }: { bookTime: number | undefined; bookReceivedAt?: number; + isCachedSnapshot?: boolean; now?: number; }) { - if (!bookTime || !Number.isFinite(bookTime)) { + if (isCachedSnapshot || !bookTime || !Number.isFinite(bookTime)) { return undefined; } const freshnessTime = From a7c0de8d062d587a60efaeb583ccf9aa9b1746eb Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 20:54:01 +0800 Subject: [PATCH 18/24] perf: reuse L2 cache across instrument switches Constraint: Keep cached books exact-target scoped and visual-only until live data arrives. Rejected: Global cache write throttling | it drops alternate targets during rapid switches. Confidence: high Scope-risk: narrow Directive: Preserve per-target throttling and merge semantics for order book cold-cache writes. Tested: 60 targeted Fast L2 tests; lint-worktree-ts; lint-staged Not-tested: Interactive browser latency after a fresh runtime restart --- .../jotai/contexts/hyperliquid/actions.ts | 58 ++++++++++--------- .../hyperliquid/utils/l2BookUtils.test.ts | 58 +++++++++++++++++++ .../contexts/hyperliquid/utils/l2BookUtils.ts | 53 ++++++++++++++++- 3 files changed, 142 insertions(+), 27 deletions(-) diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 47cf264a129c..8bf1c300fb07 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -126,6 +126,8 @@ import { shouldClearPerpsMarketDataForInstrument, shouldUpdatePerpsBbo, shouldUpdatePerpsL2Book, + shouldWritePerpsL2BookColdCacheTarget, + upsertPerpsL2BookColdCacheTarget, withPerpsBboLocalReceivedAt, withPerpsL2BookLocalReceivedAt, } from './utils/l2BookUtils'; @@ -154,7 +156,6 @@ const TWAP_MAX_DURATION_MINUTES = 1440; const TWAP_MIN_ORDER_NOTIONAL = Number(SCALE_ORDER_MIN_NOTIONAL); const TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS = 30; const TWAP_SLICE_FILLS_MAX_COUNT = 2000; -let lastL2BookColdCacheWriteAt = 0; const setAbstractionWithUserWalletTimeout = makeTimeoutPromise< void, @@ -1378,36 +1379,41 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { const nextBook = withPerpsL2BookLocalReceivedAt(data); set(l2BookAtom(), nextBook); const now = Date.now(); + const storedTickOptions = getPerpsOrderBookTickOptionWithCache({ + coin: data.coin, + options: get(orderBookTickOptionsAtom()), + }); + const keys = getPerpsL2BookSnapshotCacheKeys({ + coin: data.coin, + nSigFigs: nextBook.nSigFigs ?? storedTickOptions?.nSigFigs ?? null, + mantissa: + nextBook.mantissa === undefined && + storedTickOptions?.mantissa === undefined + ? undefined + : (nextBook.mantissa ?? storedTickOptions?.mantissa), + }); + const currentColdCache = get(perpsL2BookColdCacheAtom()); + const targetKey = keys[0]; if ( hasL2BookCacheableLevels(nextBook) && - now - lastL2BookColdCacheWriteAt >= - PERPS_L2_BOOK_SNAPSHOT_CACHE_WRITE_INTERVAL_MS + targetKey && + shouldWritePerpsL2BookColdCacheTarget({ + cache: currentColdCache, + targetKey, + now, + minWriteIntervalMs: PERPS_L2_BOOK_SNAPSHOT_CACHE_WRITE_INTERVAL_MS, + }) ) { - lastL2BookColdCacheWriteAt = now; - const storedTickOptions = getPerpsOrderBookTickOptionWithCache({ - coin: data.coin, - options: get(orderBookTickOptionsAtom()), - }); - const keys = getPerpsL2BookSnapshotCacheKeys({ - coin: data.coin, - nSigFigs: nextBook.nSigFigs ?? storedTickOptions?.nSigFigs ?? null, - mantissa: - nextBook.mantissa === undefined && - storedTickOptions?.mantissa === undefined - ? undefined - : (nextBook.mantissa ?? storedTickOptions?.mantissa), - }); const updatedAt = nextBook.localReceivedAt ?? now; - const nextColdCache = Object.fromEntries( - keys.map((key) => [ - key, - { - data: nextBook, - updatedAt, - }, - ]), + set( + perpsL2BookColdCacheAtom(), + upsertPerpsL2BookColdCacheTarget({ + cache: currentColdCache, + targetKeys: keys, + data: nextBook, + updatedAt, + }), ); - set(perpsL2BookColdCacheAtom(), nextColdCache); } } else { const currentBook = get(l2BookAtom()); diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts index 485bfb7fbd5d..91553849e161 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts @@ -5,6 +5,8 @@ import { shouldClearPerpsMarketDataForInstrument, shouldUpdatePerpsBbo, shouldUpdatePerpsL2Book, + shouldWritePerpsL2BookColdCacheTarget, + upsertPerpsL2BookColdCacheTarget, withPerpsBboLocalReceivedAt, withPerpsL2BookLocalReceivedAt, } from './l2BookUtils'; @@ -192,6 +194,62 @@ describe('shouldUpdatePerpsL2Book', () => { }); describe('perps market data local receive helpers', () => { + it('allows a new target into the cold cache while another target is throttled', () => { + const cache = { + eth: { + data: withPerpsL2BookLocalReceivedAt( + buildBook({ time: 1000, coin: 'ETH' }), + 1000, + ), + updatedAt: 1000, + }, + }; + + expect( + shouldWritePerpsL2BookColdCacheTarget({ + cache, + targetKey: 'btc', + now: 1001, + minWriteIntervalMs: 30_000, + }), + ).toBe(true); + expect( + shouldWritePerpsL2BookColdCacheTarget({ + cache, + targetKey: 'eth', + now: 1001, + minWriteIntervalMs: 30_000, + }), + ).toBe(false); + }); + + it('keeps an existing target when another target enters the cold cache', () => { + const ethBook = withPerpsL2BookLocalReceivedAt( + buildBook({ time: 1000, coin: 'ETH' }), + 1000, + ); + const btcBook = withPerpsL2BookLocalReceivedAt( + buildBook({ time: 2000, coin: 'BTC' }), + 2000, + ); + const cache = { + eth: { data: ethBook, updatedAt: 1000 }, + }; + + expect( + upsertPerpsL2BookColdCacheTarget({ + cache, + targetKeys: ['btc', 'btc-latest'], + data: btcBook, + updatedAt: 2000, + }), + ).toEqual({ + eth: { data: ethBook, updatedAt: 1000 }, + btc: { data: btcBook, updatedAt: 2000 }, + 'btc-latest': { data: btcBook, updatedAt: 2000 }, + }); + }); + it('marks hydrated snapshots as cached without changing their payload', () => { expect( withPerpsL2BookLocalReceivedAt(buildBook({ time: 1000 }), 2000, true), diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts index ff8ad3573ffa..9cd8d643f78d 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts @@ -1,4 +1,7 @@ -import { PERPS_L2_BOOK_INTERACTIVE_MAX_AGE_MS } from '@onekeyhq/shared/src/consts/perpCache'; +import { + PERPS_L2_BOOK_INTERACTIVE_MAX_AGE_MS, + PERPS_SNAPSHOT_CACHE_MAX_ENTRIES, +} from '@onekeyhq/shared/src/consts/perpCache'; import type * as HL from '@onekeyhq/shared/types/hyperliquid/sdk'; export type IPerpsL2BookWithLocalReceivedAt = HL.IBook & { @@ -10,11 +13,59 @@ export type IPerpsBboWithLocalReceivedAt = HL.IWsBbo & { localReceivedAt?: number; }; +export type IPerpsL2BookColdCache = Record< + string, + { + data: IPerpsL2BookWithLocalReceivedAt; + updatedAt: number; + } +>; + // Keep this below the interactive TTL so a live-but-unchanged book refreshes // its local timestamp before the trading interaction gate can expire it. const PERPS_L2_BOOK_FRESHNESS_REFRESH_MIN_INTERVAL_MS = PERPS_L2_BOOK_INTERACTIVE_MAX_AGE_MS / 2; +export function shouldWritePerpsL2BookColdCacheTarget({ + cache, + targetKey, + now, + minWriteIntervalMs, +}: { + cache: IPerpsL2BookColdCache; + targetKey: string; + now: number; + minWriteIntervalMs: number; +}) { + const entry = cache[targetKey]; + return !entry || now - entry.updatedAt >= minWriteIntervalMs; +} + +export function upsertPerpsL2BookColdCacheTarget({ + cache, + targetKeys, + data, + updatedAt, +}: { + cache: IPerpsL2BookColdCache; + targetKeys: string[]; + data: IPerpsL2BookWithLocalReceivedAt; + updatedAt: number; +}): IPerpsL2BookColdCache { + const nextCache = { ...cache }; + targetKeys.forEach((key) => { + const current = nextCache[key]; + if (!current || current.updatedAt < updatedAt) { + nextCache[key] = { data, updatedAt }; + } + }); + return Object.fromEntries( + Object.entries(nextCache) + .toSorted((a, b) => b[1].updatedAt - a[1].updatedAt) + .slice(0, PERPS_SNAPSHOT_CACHE_MAX_ENTRIES * 2), + ); +} + export function withPerpsL2BookLocalReceivedAt( book: HL.IBook, localReceivedAt?: number, From 787caa80f771e85aec4255ac369b539a7b2e153c Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 20:56:57 +0800 Subject: [PATCH 19/24] fix: allow cached L2 hydration from empty state Constraint: Reject cached snapshots only after a live book is already active. Rejected: Blanket cache-to-live source ordering | it blocks valid cold-start hydration when no current book exists. Confidence: high Scope-risk: narrow Directive: Keep the empty, cached-to-live, and live-to-cached transitions covered together. Tested: 61 targeted Fast L2 tests; lint-worktree-ts; lint-staged Not-tested: Interactive cold start on physical native devices --- .../contexts/hyperliquid/utils/l2BookUtils.test.ts | 13 +++++++++++++ .../jotai/contexts/hyperliquid/utils/l2BookUtils.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts index 91553849e161..78e9fb13b042 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts @@ -50,6 +50,19 @@ describe('shouldUpdatePerpsL2Book', () => { ).toBe(true); }); + it('hydrates a cached snapshot when there is no current book', () => { + const cachedBook = Object.assign(buildBook({ time: 1000 }), { + isCachedSnapshot: true, + }); + + expect( + shouldUpdatePerpsL2Book({ + currentBook: null, + nextBook: cachedBook, + }), + ).toBe(true); + }); + it('replaces a cached snapshot with the first identical live frame', () => { const cachedBook = Object.assign(buildBook({ time: 1000 }), { isCachedSnapshot: true, diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts index 9cd8d643f78d..20477496e910 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts @@ -151,8 +151,11 @@ export function shouldUpdatePerpsL2Book({ currentBook: HL.IBook | null; nextBook: HL.IBook; }) { + if (!currentBook) { + return true; + } const isCurrentCached = Boolean( - (currentBook as IPerpsL2BookWithLocalReceivedAt | null)?.isCachedSnapshot, + (currentBook as IPerpsL2BookWithLocalReceivedAt).isCachedSnapshot, ); const isNextCached = Boolean( (nextBook as IPerpsL2BookWithLocalReceivedAt).isCachedSnapshot, From 2398a02c3d7b66b9ec13a31764d07e2d454da319 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 16 Jul 2026 22:39:50 +0800 Subject: [PATCH 20/24] fix: keep fast l2 transitions on the current target Invalidate delayed recovery when transport lifecycle changes, and compare target identity before applying live-over-cache source ordering. Constraint: Preserve recovery retry budgets across transport replacement and live-over-cache ordering within one target. Rejected: Reset all Fast L2 recovery state on close | would erase failure progression before fallback. Confidence: high Scope-risk: narrow Directive: Treat transport epoch and coin/precision identity as independent freshness boundaries. Tested: 12 targeted Jest suites (106 tests); lint-worktree-ts; lint-staged Not-tested: Physical iOS reconnect timing; full tsc-staged is blocked by existing AutoSizeInput.native.tsx:103 baseline error --- .../ServiceHyperliquidSubscription.test.ts | 57 +++++++++++++++++++ .../ServiceHyperliquidSubscription.ts | 2 + .../hyperliquid/utils/l2BookUtils.test.ts | 33 +++++++++++ .../contexts/hyperliquid/utils/l2BookUtils.ts | 14 ++--- 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts new file mode 100644 index 000000000000..c27241e9bf2e --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts @@ -0,0 +1,57 @@ +import ServiceHyperliquidSubscription from './ServiceHyperliquidSubscription'; + +import type { IBackgroundApi } from '../../apis/IBackgroundApi'; + +jest.mock('@nktkas/hyperliquid', () => ({ + SubscriptionClient: jest.fn(), + WebSocketTransport: jest.fn(), +})); +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => { + const actual = jest.requireActual< + typeof import('@onekeyhq/shared/src/background/backgroundDecorators') + >('@onekeyhq/shared/src/background/backgroundDecorators'); + return { + ...actual, + backgroundClass: + () => + unknown>(ClassType: T) => + ClassType, + backgroundMethod: + () => + (_target: object, _propertyKey: string, descriptor: PropertyDescriptor) => + descriptor, + }; +}); + +describe('ServiceHyperliquidSubscription Fast L2 lifecycle', () => { + it('invalidates delayed recovery when the socket closes', () => { + const service = new ServiceHyperliquidSubscription({ + backgroundApi: {} as IBackgroundApi, + }); + const internals = service as unknown as { + _fastL2RecoveryGeneration: number; + }; + internals._fastL2RecoveryGeneration = 4; + + service.socketCloseHandler({ + target: { readyState: 3 }, + } as unknown as WebSocketEventMap['close']); + + expect(internals._fastL2RecoveryGeneration).toBe(5); + }); + + it('invalidates delayed recovery when the client closes explicitly', async () => { + const service = new ServiceHyperliquidSubscription({ + backgroundApi: {} as IBackgroundApi, + }); + const internals = service as unknown as { + _closeClient: () => Promise; + _fastL2RecoveryGeneration: number; + }; + internals._fastL2RecoveryGeneration = 4; + + await internals._closeClient(); + + expect(internals._fastL2RecoveryGeneration).toBe(5); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index f45e83ab438c..e731baf1de9a 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -1414,6 +1414,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { void perpsWebSocketReadyStateAtom.set({ readyState }); // WS close event — readyState tracked via perpsWebSocketReadyStateAtom this._activeSubscriptions.clear(); + this._invalidateFastL2RecoveryTask(); this._resetFastL2Book(); this._clearPostOpenDataCheck(); this._stopPingLoop(); @@ -1726,6 +1727,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } private async _closeClient(): Promise { + this._invalidateFastL2RecoveryTask(); this._unwatchSubscriptionAtoms(); this._clearActiveL2BookSpec(); if (this._client) { diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts index 78e9fb13b042..fbf8ac002fe7 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.test.ts @@ -89,6 +89,39 @@ describe('shouldUpdatePerpsL2Book', () => { ).toBe(false); }); + it.each([ + [ + 'coin', + buildBook({ + coin: 'BTC', + time: 2000, + nSigFigs: 5, + mantissa: 2, + }), + ], + ['nSigFigs', buildBook({ time: 2000, nSigFigs: 4, mantissa: 2 })], + ['mantissa', buildBook({ time: 2000, nSigFigs: 5, mantissa: 5 })], + ])( + 'hydrates a cached snapshot when the %s target identity changes', + (_identityField, nextBook) => { + const currentBook = buildBook({ + time: 1000, + nSigFigs: 5, + mantissa: 2, + }); + const cachedBook = Object.assign(nextBook, { + isCachedSnapshot: true, + }); + + expect( + shouldUpdatePerpsL2Book({ + currentBook, + nextBook: cachedBook, + }), + ).toBe(true); + }, + ); + it('updates when the incoming book belongs to a different coin', () => { expect( shouldUpdatePerpsL2Book({ diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts index 20477496e910..e1fb2deaf424 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/utils/l2BookUtils.ts @@ -154,6 +154,13 @@ export function shouldUpdatePerpsL2Book({ if (!currentBook) { return true; } + if ( + currentBook.coin !== nextBook.coin || + (currentBook.nSigFigs ?? null) !== (nextBook.nSigFigs ?? null) || + (currentBook.mantissa ?? null) !== (nextBook.mantissa ?? null) + ) { + return true; + } const isCurrentCached = Boolean( (currentBook as IPerpsL2BookWithLocalReceivedAt).isCachedSnapshot, ); @@ -168,13 +175,6 @@ export function shouldUpdatePerpsL2Book({ return true; } - if ( - (currentBook?.nSigFigs ?? null) !== (nextBook.nSigFigs ?? null) || - (currentBook?.mantissa ?? null) !== (nextBook.mantissa ?? null) - ) { - return true; - } - const currentTime = currentBook?.time; const nextTime = nextBook.time; const hasCurrentTime = From c9b65fabc891af2157ba8c5f893aa726d38e44f7 Mon Sep 17 00:00:00 2001 From: Zen Date: Fri, 17 Jul 2026 04:16:17 +0800 Subject: [PATCH 21/24] fix: stabilize desktop order book loading Constraint: Keep Fast L2 subscriptions and mobile order book paths unchanged. Rejected: Separate fixed-row loading skeleton | It diverges from the measured desktop layout. Confidence: high Scope-risk: narrow Directive: Keep the desktop empty state on the measured OrderBook layout. Tested: 3 targeted Jest suites (5 tests), worktree/staged lint, and diff checks. Not-tested: Full TypeScript check is blocked by the pre-existing AutoSizeInput.native.tsx TS2322 error. --- .../OrderBook/OrderBook.empty.test.tsx | 122 ++++++++++++++++ .../views/Perp/components/OrderBook/index.tsx | 65 +++++++-- .../views/Perp/components/PerpOrderBook.tsx | 130 ++++++------------ 3 files changed, 211 insertions(+), 106 deletions(-) create mode 100644 packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx diff --git a/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx b/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx new file mode 100644 index 000000000000..733247c5bb80 --- /dev/null +++ b/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx @@ -0,0 +1,122 @@ +/** @jest-environment jsdom */ + +import type { ReactNode } from 'react'; + +import { OrderBook } from '.'; +import { render } from '@testing-library/react'; + +import { getVerticalOrderBookLayout } from '../../layouts/perpLayoutUtils'; + + +jest.mock('react-intl', () => ({ + useIntl: () => ({ + formatMessage: ({ id }: { id: string }) => id, + }), +})); + +jest.mock('@tamagui/themes', () => ({ + colorTokens: { + light: { + green: { green3: '#efe' }, + red: { red3: '#fee' }, + }, + }, +})); + +jest.mock('react-native', () => ({ + Pressable: ({ + children, + }: { + children?: ReactNode | ((state: { pressed: boolean }) => ReactNode); + }) => ( +
+ {typeof children === 'function' ? children({ pressed: false }) : children} +
+ ), + StyleSheet: { + create: (styles: T) => styles, + }, + Text: ({ children }: { children?: ReactNode }) => {children}, + TouchableOpacity: ({ children }: { children?: ReactNode }) => ( + + ), + View: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +jest.mock('@onekeyhq/components', () => ({ + DashText: () => --, + DebugRenderTracker: ({ children }: { children?: ReactNode }) => ( + <>{children} + ), + Haptics: { selection: jest.fn() }, + Icon: () => null, + Popover: () => null, + Select: () => null, + SizableText: ({ children }: { children?: ReactNode }) => ( + {children} + ), + TABULAR_NUMS: ['tabular-nums'], + YStack: ({ children }: { children?: ReactNode }) =>
{children}
, + useTheme: () => ({ + bgAccent: { val: '#0a0' }, + bgCriticalStrong: { val: '#f00' }, + bgSubdued: { val: '#eee' }, + text: { val: '#111' }, + textSubdued: { val: '#666' }, + }), + useThemeName: () => 'light', +})); + +jest.mock('@onekeyhq/kit/src/states/jotai/contexts/hyperliquid', () => ({ + useActiveTradeInstrumentAtom: () => [{ coin: 'BTC', mode: 'perp' }], +})); + +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ + useSpotActiveAssetCtxAtom: () => [null], +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { isNative: false }, +})); + +jest.mock('./AnimatedDepthBlock', () => ({ + DepthBar: () => null, + DepthBarColumn: () => null, + SideRatioSegments: () => null, +})); + +jest.mock('../../hooks/usePerpsActiveAssetCtxDisplay', () => ({ + usePerpsActiveAssetCtxDisplay: () => ({ assetCtx: undefined }), +})); + +jest.mock('../../hooks/useTradingPrice', () => ({ + useTradingPrice: () => ({ midPrice: undefined }), +})); + +describe('OrderBook empty vertical state', () => { + it('fills the measured desktop layout with placeholder levels', () => { + const containerHeight = 640; + const maxLevelsPerSide = 18; + const { levelsPerSide } = getVerticalOrderBookLayout( + containerHeight, + maxLevelsPerSide, + ); + + const { getAllByText, getByText } = render( + , + ); + + expect(getAllByText('--')).toHaveLength(levelsPerSide * 2 * 3 + 1); + expect(getByText('B 50%')).toBeTruthy(); + expect(getByText('50% S')).toBeTruthy(); + }); +}); diff --git a/packages/kit/src/views/Perp/components/OrderBook/index.tsx b/packages/kit/src/views/Perp/components/OrderBook/index.tsx index 72ed6ccffce4..b3cd427f80ae 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/index.tsx +++ b/packages/kit/src/views/Perp/components/OrderBook/index.tsx @@ -103,6 +103,15 @@ export const defaultMidPriceNode = (midPrice: string) => ( {midPrice} ); +const EMPTY_FORMATTED_ORDER_BOOK_LEVEL: IFormattedOBLevel = { + price: '', + size: '', + cumSize: '', + displayPrice: '--', + displaySize: '--', + displayCumSize: '--', +}; + // Helper function to calculate percentage with BigNumber precision function calculatePercentage(cumSize: string, totalDepth: BigNumber): number { if (totalDepth.isZero()) return 0; @@ -747,6 +756,24 @@ export function OrderBook({ sizeDecimals, ); const isEmpty = !aggregatedData.bids.length && !aggregatedData.asks.length; + const verticalEmptyLevels = useMemo( + () => + !horizontal && isEmpty + ? Array.from( + { length: resolvedMaxLevelsPerSide }, + () => EMPTY_FORMATTED_ORDER_BOOK_LEVEL, + ) + : [], + [horizontal, isEmpty, resolvedMaxLevelsPerSide], + ); + let verticalAsks: IFormattedOBLevel[] = []; + let verticalBids: IFormattedOBLevel[] = []; + if (!horizontal) { + verticalAsks = isEmpty + ? verticalEmptyLevels + : aggregatedData.asks.toReversed(); + verticalBids = isEmpty ? verticalEmptyLevels : aggregatedData.bids; + } const depthEpoch = useOrderBookEpoch( _symbol, selectedTickOption?.value, @@ -1170,19 +1197,21 @@ export function OrderBook({ /> - {aggregatedData.asks.toReversed().map((itemData, index) => { + {verticalAsks.map((itemData, index) => { const originalIndex = aggregatedData.asks.length - 1 - index; return ( - handleSelectLevel('ask', itemData, originalIndex) - } + disabled={isEmpty || !isInteractive} + onPress={() => { + if (!isEmpty) { + handleSelectLevel('ask', itemData, originalIndex); + } + }} style={() => [ styles.blockRow, { height: verticalRowHeight }, - isInteractive && !platformEnv.isNative + !isEmpty && isInteractive && !platformEnv.isNative ? styles.pointer : null, ]} @@ -1191,8 +1220,8 @@ export function OrderBook({ )} @@ -1260,27 +1289,33 @@ export function OrderBook({ /> ) : null} - {spreadPercentage} + {isEmpty ? '--' : spreadPercentage} - {aggregatedData.bids.map((itemData, index) => ( + {verticalBids.map((itemData, index) => ( handleSelectLevel('bid', itemData, index)} + disabled={isEmpty || !isInteractive} + onPress={() => { + if (!isEmpty) { + handleSelectLevel('bid', itemData, index); + } + }} style={() => [ styles.blockRow, { height: verticalRowHeight }, - isInteractive && !platformEnv.isNative ? styles.pointer : null, + !isEmpty && isInteractive && !platformEnv.isNative + ? styles.pointer + : null, ]} > {(state) => ( )} diff --git a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx index ba62a4bf0db0..fe8c4f9ac199 100644 --- a/packages/kit/src/views/Perp/components/PerpOrderBook.tsx +++ b/packages/kit/src/views/Perp/components/PerpOrderBook.tsx @@ -38,7 +38,6 @@ import { useL2Book, } from '../hooks/usePerpMarketData'; import { usePerpsActiveAssetCtxDisplay } from '../hooks/usePerpsActiveAssetCtxDisplay'; -import { useTradingPrice } from '../hooks/useTradingPrice'; import { getFreshL2BookSnapshotFromColdCache, getPerpsL2BookColdCacheGlobalSnapshot, @@ -66,7 +65,6 @@ import { useTickOptions } from './OrderBook/useTickOptions'; import { PerpOrderBookMobileVerticalShell } from './PerpOrderBookMobileVerticalShell'; import type { ITickParam } from './OrderBook/tickSizeUtils'; -import type { IOrderBookVariant } from './OrderBook/types'; import type { LayoutChangeEvent } from 'react-native'; function MobileHeader() { @@ -358,32 +356,6 @@ function MobileHeader() { const MobileHeaderMemo = memo(MobileHeader); const MOBILE_SPOT_MAX_LEVELS_PER_SIDE = 4; -function DefaultOrderBookLoadingNode({ - isSpot, - maxLevelsPerSide, - spotUniverse, - symbol, - variant, -}: { - isSpot?: boolean; - maxLevelsPerSide?: number; - spotUniverse?: Parameters[0]['spotUniverse']; - symbol?: string; - variant: IOrderBookVariant; -}) { - const { midPrice } = useTradingPrice(); - return ( - - ); -} - function usePublishVisualL2BookSnapshot({ book, enabled, @@ -1038,76 +1010,52 @@ export function PerpOrderBook({ ); } - if (!hasRenderOrderBook || !visibleL2Book) { - let loadingVariant = 'desktop'; - if (!gtMd) { - loadingVariant = - entry === 'perpMobileMarket' ? 'mobileHorizontal' : 'mobileVertical'; - } - if (!gtMd && loadingVariant === 'mobileHorizontal') { - return ( - <> - {dataBridge} - - handleTraceLayout('mobileHorizontalLoading', event) - } - > - - } - variant="mobileHorizontal" - /> - - - ); - } + if ((!hasRenderOrderBook || !visibleL2Book) && !gtMd) { return ( <> {dataBridge} - - + handleTraceLayout('mobileHorizontalLoading', event) + } + > + } + variant="mobileHorizontal" /> ); } + const desktopOrderBookData: Pick = + hasRenderOrderBook && visibleL2Book + ? visibleL2Book + : { + coin: activeTradeInstrument.coin, + bids: [], + asks: [], + }; + const content = ( {gtMd ? ( Date: Fri, 17 Jul 2026 10:26:37 +0800 Subject: [PATCH 22/24] fix: keep order book tick parameters synchronized Persist the fully resolved tick option once option data is ready so the UI selection and background subscription use the same wire precision. Constraint: Preserve the previous selection while tick options are still transitioning. Rejected: Derive subscription parameters directly in PerpOrderBook | would duplicate the persisted preference owner and leave stale state behind. Confidence: high Scope-risk: narrow Directive: Compare value, nSigFigs, and mantissa together when synchronizing tick options. Tested: targeted OrderBook Jest suites (25 tests); targeted Oxlint; agent:check commit profile Not-tested: physical iOS price-magnitude transition --- .../OrderBook/OrderBook.empty.test.tsx | 2 +- .../OrderBook/tickSizeUtils.test.ts | 35 ++++++++++++++----- .../components/OrderBook/tickSizeUtils.ts | 18 +++++++--- .../components/OrderBook/useTickOptions.ts | 7 ++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx b/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx index 733247c5bb80..97f2e7f2d9de 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx +++ b/packages/kit/src/views/Perp/components/OrderBook/OrderBook.empty.test.tsx @@ -3,11 +3,11 @@ import type { ReactNode } from 'react'; import { OrderBook } from '.'; + import { render } from '@testing-library/react'; import { getVerticalOrderBookLayout } from '../../layouts/perpLayoutUtils'; - jest.mock('react-intl', () => ({ useIntl: () => ({ formatMessage: ({ id }: { id: string }) => id, diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts index 408542388e2d..4d7e9c7e481d 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.test.ts @@ -6,7 +6,7 @@ import { buildReferenceTickOptions, buildTickOptions, getTickOptionsDataDuringTransition, - shouldInitializeOrderBookTickOption, + shouldPersistOrderBookTickOption, } from './tickSizeUtils'; describe('getTickOptionsDataDuringTransition', () => { @@ -149,30 +149,47 @@ describe('buildReferenceTickOptions', () => { }); }); -describe('shouldInitializeOrderBookTickOption', () => { +describe('shouldPersistOrderBookTickOption', () => { it('does not replace precision while order book data is transitioning', () => { expect( - shouldInitializeOrderBookTickOption({ - hasMarketData: false, + shouldPersistOrderBookTickOption({ + isReady: false, persisted: { value: '0.2', nSigFigs: 5, mantissa: 2 }, + next: { value: '0.1', nSigFigs: 5, mantissa: null }, }), ).toBe(false); }); - it('does not overwrite a persisted selection from a live market frame', () => { + it('does not rewrite a matching persisted selection', () => { expect( - shouldInitializeOrderBookTickOption({ - hasMarketData: true, + shouldPersistOrderBookTickOption({ + isReady: true, persisted: { value: '0.1', nSigFigs: 5, mantissa: null }, + next: { value: '0.1', nSigFigs: 5, mantissa: null }, }), ).toBe(false); }); it('initializes a missing selection after market data arrives', () => { expect( - shouldInitializeOrderBookTickOption({ - hasMarketData: true, + shouldPersistOrderBookTickOption({ + isReady: true, persisted: undefined, + next: { value: '0.1', nSigFigs: 5, mantissa: null }, + }), + ).toBe(true); + }); + + it.each([ + ['value', { value: '0.2', nSigFigs: 5 as const, mantissa: 2 as const }], + ['nSigFigs', { value: '0.1', nSigFigs: 4 as const, mantissa: null }], + ['mantissa', { value: '0.1', nSigFigs: 5 as const, mantissa: 2 as const }], + ])('syncs a selection whose %s changed', (_field, persisted) => { + expect( + shouldPersistOrderBookTickOption({ + isReady: true, + persisted, + next: { value: '0.1', nSigFigs: 5, mantissa: null }, }), ).toBe(true); }); diff --git a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts index d631a36a9d5d..4d7d8e2a3554 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/tickSizeUtils.ts @@ -58,14 +58,24 @@ export function getTickOptionsDataDuringTransition< return reference?.symbol === symbol ? reference : null; } -export function shouldInitializeOrderBookTickOption({ - hasMarketData, +export function shouldPersistOrderBookTickOption({ + isReady, persisted, + next, }: { - hasMarketData: boolean; + isReady: boolean; persisted: IOrderBookTickOptionState | undefined; + next: IOrderBookTickOptionState; }) { - return hasMarketData && !persisted; + if (!isReady) { + return false; + } + + return ( + persisted?.value !== next.value || + (persisted?.nSigFigs ?? null) !== (next.nSigFigs ?? null) || + (persisted?.mantissa ?? null) !== (next.mantissa ?? null) + ); } function floorLog10(x: number): number { diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index dd6e32b6341d..6cde1c8278b1 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -20,7 +20,7 @@ import { buildTickOptions, getDefaultTickOption, getTickOptionsDataDuringTransition, - shouldInitializeOrderBookTickOption, + shouldPersistOrderBookTickOption, } from './tickSizeUtils'; interface ITickOptionsResult { @@ -221,9 +221,10 @@ export function useTickOptions({ }; if ( - shouldInitializeOrderBookTickOption({ - hasMarketData: Boolean(tickOptionsData), + shouldPersistOrderBookTickOption({ + isReady: Boolean(tickOptionsData), persisted, + next: currentPersist, }) ) { void actions.current.setOrderBookTickOption({ From bc3d230e1fe6a7070cdae9b0674560eb01522f89 Mon Sep 17 00:00:00 2001 From: Zen Date: Fri, 17 Jul 2026 18:27:46 +0800 Subject: [PATCH 23/24] fix: subscribe new order book coin before unsubscribing the old one --- .../ServiceHyperliquidSubscription.ts | 2 + .../utils/SubscriptionConfig.test.ts | 22 +++++ .../utils/SubscriptionConfig.ts | 9 ++ .../utils/SubscriptionMutationQueue.test.ts | 96 ++++++++++++++++++- .../utils/SubscriptionMutationQueue.ts | 47 +++++++-- 5 files changed, 163 insertions(+), 13 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index e731baf1de9a..beee307b0f86 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -85,6 +85,7 @@ import { import { SUBSCRIPTION_TYPE_INFO, calculateRequiredSubscriptionsMap, + getOrderBookSubscriptionCoin, getSubscriptionResumeAction, isOrderBookOptionsTargetReady, normalizeL2BookForSubscriptionSpec, @@ -1809,6 +1810,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { destroy: (spec) => this._destroySubscription(spec), create: (spec) => this._createSubscription(spec), isPending: (spec) => this._isSubscriptionSpecPending(spec), + getConflictKey: (spec) => getOrderBookSubscriptionCoin(spec), runExclusive: (task) => this._subscriptionMutationQueue.enqueue( ServiceHyperliquidSubscription.ORDER_BOOK_MUTATION_KEY, diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts index 647da8fbf5bc..4eb8e564b760 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.test.ts @@ -3,6 +3,7 @@ import { ESubscriptionType } from '@onekeyhq/shared/types/hyperliquid/types'; import { calculateRequiredSubscriptions, + getOrderBookSubscriptionCoin, getSubscriptionResumeAction, isOrderBookOptionsTargetReady, normalizeL2BookForSubscriptionSpec, @@ -86,6 +87,27 @@ describe('normalizeL2BookForSubscriptionSpec', () => { }); }); +describe('getOrderBookSubscriptionCoin', () => { + it('extracts the coin from L2 and L2_BOOK params', () => { + expect( + getOrderBookSubscriptionCoin({ + type: ESubscriptionType.L2, + key: 'l2-key', + params: { c: 'BTC', s: 4 }, + priority: 3, + } as ISubscriptionSpec), + ).toBe('BTC'); + expect( + getOrderBookSubscriptionCoin({ + type: ESubscriptionType.L2_BOOK, + key: 'l2book-key', + params: { coin: 'ETH', nSigFigs: null, mantissa: null }, + priority: 3, + } as ISubscriptionSpec), + ).toBe('ETH'); + }); +}); + describe('calculateRequiredSubscriptions', () => { it('always subscribes to all dex asset contexts for token selector prices', () => { const specs = calculateRequiredSubscriptions({ diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts index 3c0a06372641..31203a067079 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionConfig.ts @@ -155,6 +155,15 @@ export function isOrderBookOptionsTargetReady( return !currentCoin || !optionsCoin || currentCoin === optionsCoin; } +// Order-book transitions overlap-check by coin: L2 params use `c`, +// L2_BOOK params use `coin`; fall back to the key for non-book specs. +export function getOrderBookSubscriptionCoin( + spec: ISubscriptionSpec, +): string { + const params = spec.params as { c?: string; coin?: string }; + return params.c ?? params.coin ?? spec.key; +} + export function getSubscriptionResumeAction({ isOpen, isClosedOrClosing, diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts index 1b1a13b65522..0d1cde1b3a4c 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.test.ts @@ -31,24 +31,112 @@ describe('PerKeyMutationQueue', () => { }); describe('executeOrderBookSubscriptionTransition', () => { - it('reconnects and skips replacement creation when unsubscribe fails', async () => { - const create = jest.fn, [string]>(() => Promise.resolve()); + type ITestSpec = { key: string; coin: string }; + const getConflictKey = (spec: ITestSpec) => spec.coin; + + it('subscribes the new target before unsubscribing the old one when coins differ', async () => { + const events: string[] = []; + + const result = await executeOrderBookSubscriptionTransition({ + toDestroy: [{ key: 'l2:BTC', coin: 'BTC' }], + toCreate: [{ key: 'l2:ETH', coin: 'ETH' }], + destroy: async (spec) => { + events.push(`destroy:${spec.key}`); + return true; + }, + create: async (spec) => { + events.push(`create:${spec.key}`); + }, + isPending: () => true, + getConflictKey, + runExclusive: (task) => task(), + reconnect: () => Promise.resolve(), + }); + + expect(result).toBe(true); + expect(events).toEqual(['create:l2:ETH', 'destroy:l2:BTC']); + }); + + it('still reconnects when the old unsubscribe fails after the new target is live', async () => { + const create = jest.fn, [ITestSpec]>(() => Promise.resolve()); const reconnect = jest.fn, []>(() => Promise.resolve()); const result = await executeOrderBookSubscriptionTransition({ - toDestroy: ['old'], - toCreate: ['new'], + toDestroy: [{ key: 'l2:BTC', coin: 'BTC' }], + toCreate: [{ key: 'l2:ETH', coin: 'ETH' }], destroy: () => Promise.resolve(false), create, isPending: () => true, + getConflictKey, runExclusive: (task) => task(), reconnect, }); expect(result).toBe(false); + expect(create).toHaveBeenCalledTimes(1); + expect(reconnect).toHaveBeenCalledTimes(1); + }); + + it('keeps destroy-first order for same-coin grouping changes', async () => { + const events: string[] = []; + + const result = await executeOrderBookSubscriptionTransition({ + toDestroy: [{ key: 'l2:BTC:sig2', coin: 'BTC' }], + toCreate: [{ key: 'l2:BTC:sig5', coin: 'BTC' }], + destroy: async (spec) => { + events.push(`destroy:${spec.key}`); + return true; + }, + create: async (spec) => { + events.push(`create:${spec.key}`); + }, + isPending: () => true, + getConflictKey, + runExclusive: (task) => task(), + reconnect: () => Promise.resolve(), + }); + + expect(result).toBe(true); + expect(events).toEqual(['destroy:l2:BTC:sig2', 'create:l2:BTC:sig5']); + }); + + it('keeps destroy-first order and skips creation on failure for overlapping targets', async () => { + const reconnect = jest.fn, []>(() => Promise.resolve()); + const create = jest.fn, [ITestSpec]>(() => Promise.resolve()); + + const failedResult = await executeOrderBookSubscriptionTransition({ + toDestroy: [{ key: 'l2:BTC', coin: 'BTC' }], + toCreate: [{ key: 'l2:BTC', coin: 'BTC' }], + destroy: () => Promise.resolve(false), + create, + isPending: () => true, + getConflictKey, + runExclusive: (task) => task(), + reconnect, + }); + + expect(failedResult).toBe(false); expect(create).not.toHaveBeenCalled(); expect(reconnect).toHaveBeenCalledTimes(1); }); + + it('skips creation for specs that are no longer pending', async () => { + const create = jest.fn, [ITestSpec]>(() => Promise.resolve()); + + const result = await executeOrderBookSubscriptionTransition({ + toDestroy: [{ key: 'l2:BTC', coin: 'BTC' }], + toCreate: [{ key: 'l2:ETH', coin: 'ETH' }], + destroy: () => Promise.resolve(true), + create, + isPending: () => false, + getConflictKey, + runExclusive: (task) => task(), + reconnect: () => Promise.resolve(), + }); + + expect(result).toBe(true); + expect(create).not.toHaveBeenCalled(); + }); }); describe('executeSubscriptionTasksWithOrderBookPriority', () => { diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts index 2ee6f105cc69..21d8802b4587 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/utils/SubscriptionMutationQueue.ts @@ -15,12 +15,15 @@ export class PerKeyMutationQueue { } } -export async function executeOrderBookSubscriptionTransition({ +export async function executeOrderBookSubscriptionTransition< + T extends { key: string }, +>({ toDestroy, toCreate, destroy, create, isPending, + getConflictKey, runExclusive, reconnect, }: { @@ -29,21 +32,47 @@ export async function executeOrderBookSubscriptionTransition({ destroy: (spec: T) => Promise; create: (spec: T) => Promise; isPending: (spec: T) => boolean; + getConflictKey: (spec: T) => string; runExclusive: (task: () => Promise) => Promise; reconnect: () => Promise; }): Promise { + // Overlapping targets (same coin, e.g. a grouping change or same-key + // re-create) must stay destroy-first: frames carry only the coin, so the + // old stream is indistinguishable and would corrupt the new book. Distinct + // coins subscribe first to save one server RTT; stale-coin frames are + // dropped by coin checks. + const destroyConflictKeys = new Set(toDestroy.map(getConflictKey)); + const hasTargetOverlap = toCreate.some((spec) => + destroyConflictKeys.has(getConflictKey(spec)), + ); + const succeeded = await runExclusive(async () => { - for (const spec of toDestroy) { - if (!(await destroy(spec))) { - return false; + const destroyAll = async () => { + for (const spec of toDestroy) { + if (!(await destroy(spec))) { + return false; + } } - } - for (const spec of toCreate) { - if (isPending(spec)) { - await create(spec); + return true; + }; + const createAllPending = async () => { + for (const spec of toCreate) { + if (isPending(spec)) { + await create(spec); + } } + }; + + if (hasTargetOverlap) { + if (!(await destroyAll())) { + return false; + } + await createAllPending(); + return true; } - return true; + + await createAllPending(); + return destroyAll(); }); if (!succeeded) { From ae6eccf6d466c98f104697e4e8dc4b6e74711990 Mon Sep 17 00:00:00 2001 From: Zen Date: Tue, 21 Jul 2026 06:36:49 +0800 Subject: [PATCH 24/24] fix: keep order book size precision stable Constraint: Hyperliquid L2 size strings may omit trailing zeroes Rejected: infer precision from each live L2 sample | changes display precision as levels update Confidence: high Scope-risk: narrow Directive: keep szDecimals metadata authoritative for size and cumulative size display Tested: yarn jest packages/shared/src/utils/perpsUtils.test.ts --runInBand; yarn agent:check --profile commit Not-tested: manual cross-platform runtime verification --- .../components/OrderBook/useTickOptions.ts | 11 ++++----- packages/shared/src/utils/perpsUtils.test.ts | 24 +++++++++++++++++++ packages/shared/src/utils/perpsUtils.ts | 22 +++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts index 6cde1c8278b1..4575e455fd6c 100644 --- a/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts +++ b/packages/kit/src/views/Perp/components/OrderBook/useTickOptions.ts @@ -8,8 +8,8 @@ import { } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; import { getPerpsOrderBookTickOptionsWithCache } from '@onekeyhq/shared/src/utils/perpsOrderBookTickOptionsCache'; import { - analyzeOrderBookPrecision, getDisplayPriceScaleDecimals, + resolveOrderBookSizeDecimals, } from '@onekeyhq/shared/src/utils/perpsUtils'; import type { IBookLevel } from '@onekeyhq/shared/types/hyperliquid/sdk'; import type { IPerpOrderBookTickOptionPersist } from '@onekeyhq/shared/types/hyperliquid/types'; @@ -163,14 +163,13 @@ export function useTickOptions({ topBidPrice, ]); - // Calculate size decimals separately as it may need to update more frequently const sizeDecimals = useMemo(() => { - const { sizeDecimals: calculatedSizeDecimals } = analyzeOrderBookPrecision( + return resolveOrderBookSizeDecimals({ bids, asks, - ); - return calculatedSizeDecimals; - }, [bids, asks]); + szDecimals, + }); + }, [asks, bids, szDecimals]); const baseTickOptionsData = useMemo(() => { // Fallback when no data available diff --git a/packages/shared/src/utils/perpsUtils.test.ts b/packages/shared/src/utils/perpsUtils.test.ts index 7f17ebb5e6f8..fe55328b555c 100644 --- a/packages/shared/src/utils/perpsUtils.test.ts +++ b/packages/shared/src/utils/perpsUtils.test.ts @@ -34,6 +34,7 @@ import { getValidPriceDecimals, isHyperLiquidAbstractionModeEnabled, isPredictionMarketInstrument, + resolveOrderBookSizeDecimals, resolveTradingSizeBN, } from './perpsUtils'; @@ -448,6 +449,29 @@ describe('analyzeOrderBookPrecision', () => { }); }); +describe('resolveOrderBookSizeDecimals', () => { + const bids = [ + { px: '63857', sz: '0.78' }, + { px: '63856', sz: '0.79' }, + ]; + const asks = [ + { px: '63858', sz: '0.46' }, + { px: '63859', sz: '0.02' }, + ]; + + test('uses metadata precision instead of inferring it from live L2 values', () => { + expect(resolveOrderBookSizeDecimals({ bids, asks, szDecimals: 5 })).toBe(5); + }); + + test('accepts integer-only metadata precision', () => { + expect(resolveOrderBookSizeDecimals({ bids, asks, szDecimals: 0 })).toBe(0); + }); + + test('falls back to L2 inference when metadata is unavailable', () => { + expect(resolveOrderBookSizeDecimals({ bids, asks })).toBe(2); + }); +}); + describe('formatWithPrecision', () => { test('formats numbers with specified precision', () => { expect(formatWithPrecision('123.456789', 2)).toBe('123.46'); diff --git a/packages/shared/src/utils/perpsUtils.ts b/packages/shared/src/utils/perpsUtils.ts index c06660c7850e..5c22e3fe8413 100644 --- a/packages/shared/src/utils/perpsUtils.ts +++ b/packages/shared/src/utils/perpsUtils.ts @@ -348,6 +348,26 @@ function analyzeOrderBookPrecision( }; } +function resolveOrderBookSizeDecimals({ + bids, + asks, + szDecimals, +}: { + bids: Array<{ px: string; sz: string }>; + asks: Array<{ px: string; sz: string }>; + szDecimals?: number; +}): number { + if ( + szDecimals !== undefined && + Number.isInteger(szDecimals) && + szDecimals >= 0 + ) { + return szDecimals; + } + + return analyzeOrderBookPrecision(bids, asks).sizeDecimals; +} + /** * Calculate bid-ask spread percentage * @@ -2115,6 +2135,7 @@ export { calculateDisplayPriceScale, formatPriceToValid, analyzeOrderBookPrecision, + resolveOrderBookSizeDecimals, formatWithPrecision, countDecimalPlaces, getMostFrequentDecimalPlaces, @@ -2171,6 +2192,7 @@ export default { calculateDisplayPriceScale, formatPriceToValid, analyzeOrderBookPrecision, + resolveOrderBookSizeDecimals, formatWithPrecision, countDecimalPlaces, getMostFrequentDecimalPlaces,