diff --git a/package.json b/package.json index 3a8386b6c91b..e44c0868b865 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "packages/*" ], "engines": { - "node": ">=22" + "node": ">=22.12.0" }, "scripts": { "setup:env": "node -e \"const fs=require('fs');if(!fs.existsSync('.env')){if(fs.existsSync('.env.example')){fs.copyFileSync('.env.example','.env')}else{fs.writeFileSync('.env','')}}\"", @@ -152,7 +152,7 @@ "@metamask/eth-sig-util": "5.1.0", "@mysten/sui": "2.17.0", "@ngraveio/bc-ur": "^1.1.13", - "@nktkas/hyperliquid": "0.32.2", + "@nktkas/hyperliquid": "0.33.3", "@onekeyfe/cross-inpage-provider-core": "2.2.73", "@onekeyfe/cross-inpage-provider-errors": "2.2.73", "@onekeyfe/cross-inpage-provider-injected": "2.2.73", @@ -401,6 +401,7 @@ "@alephium/walletconnect-provider": "3.0.4", "@alephium/web3-wallet": "3.0.4", "@noble/curves": "1.9.7", + "@noble/hashes@^2.2.0": "2.2.0", "@ledgerhq/devices": "8.14.0", "@ledgerhq/hw-transport": "6.35.0", "@ledgerhq/device-signer-kit-solana": "1.7.1", diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts index c73557b01527..3b3340b4efdc 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts @@ -94,6 +94,7 @@ import type { IPerpsActiveAssetDataRaw, IPerpsUniverse, IRecentTrade, + ISpotBalance, ISpotMetaAndAssetCtxsResponse, ISpotUniverse, ITwapHistoryParameters, @@ -2089,7 +2090,20 @@ export default class ServiceHyperliquid extends ServiceBase { // Active-account alignment: only process data for current account if (!activeAddress || activeAddress !== dataUser) return; - const balances = spotStateData?.spotState?.balances || []; + const allBalances = spotStateData?.spotState?.balances || []; + const balances = allBalances.filter( + (balance): balance is ISpotBalance => 'token' in balance, + ); + // Outcome-market balances have no token id or price feed, so they cannot + // be valued or rendered; the warn keeps the omission diagnosable. + const droppedPositiveCount = allBalances.filter( + (balance) => !('token' in balance) && new BigNumber(balance.total).gt(0), + ).length; + if (droppedPositiveCount > 0) { + console.warn( + `[updateSpotBalances] excluded ${droppedPositiveCount} unsupported outcome balance(s)`, + ); + } await spotBalancesAtom.set({ balances, isLoaded: true }); @@ -3324,7 +3338,8 @@ export default class ServiceHyperliquid extends ServiceBase { }); return null; } - if (agent.validUntil <= validThreshold) { + const validUntil = agent.validUntil ?? Number.MAX_SAFE_INTEGER; + if (validUntil <= validThreshold) { defaultLogger.perp.agentLifeCycle.trackReason({ reason: 'agent_near_expiry', accountAddress, @@ -3334,7 +3349,7 @@ export default class ServiceHyperliquid extends ServiceBase { ...statusDetails, agentName: agent.name, agentAddress: agent.address, - validUntil: agent.validUntil, + validUntil: agent.validUntil ?? undefined, }, }); return null; @@ -3349,7 +3364,7 @@ export default class ServiceHyperliquid extends ServiceBase { ...statusDetails, agentName: agent.name, agentAddress: agent.address, - validUntil: agent.validUntil, + validUntil: agent.validUntil ?? undefined, }, }); return null; @@ -3368,12 +3383,12 @@ export default class ServiceHyperliquid extends ServiceBase { agentName: agent.name, chainAgentAddress: agent.address, localAgentAddress: credential.agentAddress, - validUntil: agent.validUntil, + validUntil: agent.validUntil ?? undefined, }, }); return null; } - credential.validUntil = agent.validUntil; + credential.validUntil = validUntil; return credential; }), ) @@ -3421,7 +3436,11 @@ export default class ServiceHyperliquid extends ServiceBase { ); const agentToRemove = ( nonOneKeyAgents.length ? nonOneKeyAgents : extraAgents - ).toSorted((a, b) => a.validUntil - b.validUntil)?.[0]; + ).toSorted( + (a, b) => + (a.validUntil ?? Number.MAX_SAFE_INTEGER) - + (b.validUntil ?? Number.MAX_SAFE_INTEGER), + )?.[0]; const agentNameToRemove = agentToRemove?.name as | EHyperLiquidAgentName | undefined; diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts index 1305d5e297b6..3283a1234d9e 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -35,6 +35,10 @@ import { assertValidScaleOrderLegs, buildScaleOrderLegs, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; +import { + formatTwapPriceForOrder, + isTwapStopPriceValid, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { normalizeDexCoin } from '@onekeyhq/shared/src/utils/perpsDexUtils'; import { MAX_DECIMALS_PERP, @@ -1388,12 +1392,76 @@ export default class ServiceHyperliquidExchange extends ServiceBase { const szDecimals = params.szDecimals ?? precision?.szDecimals ?? 2; const size = formatHlSize(params.size, szDecimals); if (!size) { - throw new OneKeyLocalError('TWAP size is too small for HL lot size'); + throw new OneKeyLocalError( + appLocale.intl.formatMessage({ + id: ETranslations.perp_scale_order_size_too_small__msg, + }), + ); } const assetType = precision?.type; const reduceOnly = assetType === 'spot' ? false : Boolean(params.reduceOnly); + const formatOptionalTwapPrice = ( + price: string | undefined, + fieldName: 'trigger' | 'stop', + ) => { + if (!price) { + return undefined; + } + const formattedPrice = formatTwapPriceForOrder({ + price, + szDecimals, + assetType: assetType ?? 'perp', + }); + if (!formattedPrice) { + let messageId = params.isBuy + ? ETranslations.perp_scale_upper_price_placeholder__desc + : ETranslations.perp_scale_lower_price_placeholder__desc; + if (fieldName === 'trigger') { + messageId = ETranslations.perps_input_trigger_price; + } + throw new OneKeyLocalError( + appLocale.intl.formatMessage({ + id: messageId, + }), + ); + } + return formattedPrice; + }; + const triggerPrice = formatOptionalTwapPrice( + params.triggerPrice, + 'trigger', + ); + const stopPrice = formatOptionalTwapPrice(params.stopPrice, 'stop'); + if (triggerPrice && typeof params.triggerAbove !== 'boolean') { + throw new OneKeyLocalError( + appLocale.intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + ); + } + if ( + stopPrice && + !isTwapStopPriceValid({ + isBuy: params.isBuy, + stopPrice, + referencePrice: params.referencePrice, + triggerPrice, + }) + ) { + throw new OneKeyLocalError( + `${appLocale.intl.formatMessage({ + id: params.isBuy + ? ETranslations.perp_scale_upper_price_label__title + : ETranslations.perp_scale_lower_price_label__title, + })} ${params.isBuy ? '>' : '<'} ${appLocale.intl.formatMessage({ + id: ETranslations.perp_market_price, + })} / ${appLocale.intl.formatMessage({ + id: ETranslations.dexmarket_pro_trigger_price, + })}`, + ); + } const twap = { a: params.assetId, b: params.isBuy, @@ -1402,6 +1470,16 @@ export default class ServiceHyperliquidExchange extends ServiceBase { m: params.minutes, t: params.randomize, }; + // Plain TWAPs must keep the pre-0.33 wire shape production has validated. + const details = + triggerPrice || stopPrice + ? { + t: triggerPrice + ? { p: triggerPrice, a: params.triggerAbove as boolean } + : null, + s: stopPrice ?? null, + } + : undefined; const client = await this.getExchangeClientForTrading(); const context = await this._buildLogContext(); const requestPayload = { @@ -1412,13 +1490,16 @@ export default class ServiceHyperliquidExchange extends ServiceBase { reduceOnly, minutes: params.minutes, randomize: params.randomize, + referencePrice: params.referencePrice, }, + details, }; try { const response = await convertHyperLiquidResponse(() => client.twapOrder({ twap, + ...(details ? { details } : {}), }), ); defaultLogger.perp.hyperliquid.twapOrder({ @@ -1706,11 +1787,7 @@ export default class ServiceHyperliquidExchange extends ServiceBase { async setAbstractionWithUserWallet(params: { userAccountId: string; userAddress: string; - abstraction: - | 'disabled' - | 'unifiedAccount' - | 'portfolioMargin' - | 'dexAbstraction'; + abstraction: 'disabled' | 'unifiedAccount' | 'portfolioMargin'; }): Promise { await this.checkAccountCanTrade(); const wallet = diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts index 800ebb748019..9586a5e920f8 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.test.ts @@ -64,6 +64,29 @@ describe('ServiceHyperliquidSubscription Fast L2 lifecycle', () => { }); }); +describe('ServiceHyperliquidSubscription ping measurement', () => { + it('keeps one ping in flight per client', async () => { + const service = createService(); + const internals = service as unknown as { + _client: { ping: () => Promise } | null; + _measurePing: () => Promise; + }; + let releasePing: (() => void) | undefined; + const pendingPing = new Promise((resolve) => { + releasePing = resolve; + }); + const ping = jest.fn(() => pendingPing); + internals._client = { ping }; + + const first = internals._measurePing(); + const second = internals._measurePing(); + + expect(ping).toHaveBeenCalledTimes(1); + releasePing?.(); + await Promise.all([first, second]); + }); +}); + describe('ServiceHyperliquidSubscription resume stream liveness', () => { type IResumeInternals = { _lastMessageAt: number | null; diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index da1ff4d7eda3..e0d718ad3270 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -121,9 +121,7 @@ type IHyperliquidWsClient = { transport: WebSocketTransport; dispose: () => Promise; hlEventTarget: IHyperliquidEventTarget; - wsRequester: { - request: (method: string, payload: any) => Promise; - }; + ping: () => Promise; subscribe: ( type: T, params: IPerpsSubscriptionParams[T], @@ -186,6 +184,8 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private _pingIntervalTimer: ReturnType | null = null; + private _pingMeasurementClient: IHyperliquidWsClient | null = null; + private _lastMessageAt: number | null = null; // Raw pipe liveness, unlike _lastMessageAt which freezes while the handler @@ -1636,9 +1636,18 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { transport.socket.addEventListener('open', this.socketOpenHandler); // transport.socket.addEventListener('message', this.socketMessageHandler); const innerClient = new SubscriptionClient({ transport }); - const innerTransport = transport; - // @ts-ignore - const hlEventTarget = innerTransport._hlEvents; + // OneKey reconciles subscriptions itself, so it needs the SDK's parsed + // events and raw dispatcher without delegating ownership to the client. + const { _hlEvents: hlEventTarget, _dispatcher: subscriptionDispatcher } = + transport as unknown as { + _hlEvents: IHyperliquidEventTarget; + _dispatcher: { + request: ( + method: 'subscribe' | 'unsubscribe', + payload: unknown, + ) => Promise; + }; + }; const registerSubscriptionHandler = (type: ESubscriptionType) => { if (!this.subscriptionHandlerByType[type]) { @@ -1707,15 +1716,11 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { registerSubscriptionHandler(type); }); - // @ts-ignore - const wsRequester = innerTransport._postRequest as { - request: (method: string, payload: any) => Promise; - }; const subscribe = async ( type: T, params: IPerpsSubscriptionParams[T], ) => { - return wsRequester.request('subscribe', { + return subscriptionDispatcher.request('subscribe', { type, ...params, }); @@ -1724,35 +1729,50 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { type: T, params: IPerpsSubscriptionParams[T], ) => { - return wsRequester.request('unsubscribe', { + return subscriptionDispatcher.request('unsubscribe', { type, ...params, }); }; + const ping = () => + new Promise((resolve, reject) => { + const listenerController = new AbortController(); + const timeout = setTimeout(() => { + listenerController.abort(); + reject(new Error('Hyperliquid WebSocket ping timed out')); + }, transport.timeout ?? 10_000); + hlEventTarget.addEventListener( + 'pong', + () => { + clearTimeout(timeout); + listenerController.abort(); + resolve(); + }, + { once: true, signal: listenerController.signal }, + ); + try { + transport.socket.send('{"method":"ping"}'); + } catch (error) { + clearTimeout(timeout); + listenerController.abort(); + reject(error); + } + }); this._client = { clientId, transport, hlEventTarget, - wsRequester, + ping, subscribe, unsubscribe, dispose: async () => { - // OneKey: dispose order matters for orphan-timer cleanup. We must - // close the underlying socket BEFORE removing OUR listeners — the - // close() triggers rews's internal `cleanup` listener (registered - // with { once: true } on close/error/open) which calls clearTimeout - // on its connection-timeout timer. If we removed listeners first, - // any in-flight close event might be dropped before rews can clean - // up its 5s setTimeout, leaving an orphan timer that could fire - // after dispose and re-trigger the dispatchEvent path (now caught - // defensively by the rews patch, but harmless cleanup is preferred). + // Closing first lets the socket release reconnect timers before our + // listeners are detached. defaultLogger.perp.hyperliquid.subscriptionTransportDispose({ clientId, }); try { - // Close socket first so rews's internal close listener fires and - // clears its connection-timeout setTimeout. - transport.socket.close(); + transport.close(); } catch (error) { console.error('dispose__transport.socket.close__error', error); } @@ -2522,12 +2542,13 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { private async _measurePing(): Promise { const client = this._client; - if (!client) { + if (!client || this._pingMeasurementClient === client) { return; } + this._pingMeasurementClient = client; try { const start = Date.now(); - await client.wsRequester.request('ping', undefined); + await client.ping(); // Guard: client may have been replaced/closed during await if (this._client !== client) return; const pingMs = Date.now() - start; @@ -2535,10 +2556,15 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { (prev): IPerpsNetworkStatus => ({ ...prev, pingMs }), ); } catch { + if (this._client !== client) return; // Ping failed — clear displayed value without marking disconnected void perpsNetworkStatusAtom.set( (prev): IPerpsNetworkStatus => ({ ...prev, pingMs: null }), ); + } finally { + if (this._pingMeasurementClient === client) { + this._pingMeasurementClient = null; + } } } diff --git a/packages/kit-bg/src/states/jotai/atoms/perps.ts b/packages/kit-bg/src/states/jotai/atoms/perps.ts index 1dde98e0ccd2..5d4a5d86989d 100644 --- a/packages/kit-bg/src/states/jotai/atoms/perps.ts +++ b/packages/kit-bg/src/states/jotai/atoms/perps.ts @@ -733,6 +733,13 @@ export const { read: (get) => get(perpsActiveAssetCtxAtom.atom())?.ctx?.midPrice, }); +export const { + target: perpsActiveAssetCtxMarkPriceAtom, + use: usePerpsActiveAssetCtxMarkPriceAtom, +} = globalAtomComputedR({ + read: (get) => get(perpsActiveAssetCtxAtom.atom())?.ctx?.markPrice, +}); + export type IPerpsActiveAssetCtxMidPriceSource = | 'live' | 'display' diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 95b8582dd6be..38081743f5ef 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -49,13 +49,23 @@ import { EModalPerpRoutes } from '@onekeyhq/shared/src/routes/perp'; import { getCurrentVisibilityState } from '@onekeyhq/shared/src/utils/appVisibility'; import { memoFn } from '@onekeyhq/shared/src/utils/cacheUtils'; import { - SCALE_ORDER_MIN_NOTIONAL, getReduceOnlyOrderGuardError, getReduceOnlyPositionMaxSize, getReduceOnlyPositionSnapshotError, getScaleOrderReferencePrice, getScaleOrderSizeSkew, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; +import { + TWAP_MAX_DURATION_MINUTES, + TWAP_MIN_DURATION_MINUTES, + TWAP_MIN_ORDER_NOTIONAL, + formatTwapPriceForOrder, + getTwapTriggerAbove, + getTwapTriggerReferencePrice, + isTwapStopPriceValid, + isTwapTotalNotionalValid, + isValidTwapDuration, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { getPerpsOrderBookTickOptionWithCache, getPerpsOrderBookTickOptionsWithCache, @@ -164,10 +174,6 @@ const MAX_LEDGER_UPDATES = 200; const ACCOUNT_MODE_USER_WALLET_TIMEOUT_MS = platformEnv.isNative ? 60_000 : 15_000; -const TWAP_MIN_DURATION_MINUTES = 5; -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; const setAbstractionWithUserWalletTimeout = makeTimeoutPromise< @@ -1676,6 +1682,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { triggerPrice: '', executionPrice: '', triggerReduceOnly: true, + twapTriggerPrice: '', + twapStopPrice: '', }; // update limit price once using current atom snapshot. @@ -1852,6 +1860,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { sizePercent: 0, triggerPrice: '', executionPrice: '', + twapTriggerPrice: '', + twapStopPrice: '', }; // Spot doesn't have margin mode -- force to usd if currently set to margin const currentPrefs = await perpsTradingPreferencesAtom.get(); @@ -2390,14 +2400,22 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { updateTradingForm = contextAtomMethod( (get, set, updates: Partial) => { const current = get(tradingFormAtom()); - const updateKeys = Object.keys(updates) as Array; + const nextUpdates = + updates.side !== undefined && + updates.side !== current.side && + updates.twapStopPrice === undefined + ? { ...updates, twapStopPrice: '' } + : updates; + const updateKeys = Object.keys(nextUpdates) as Array< + keyof ITradingFormData + >; if ( updateKeys.length === 0 || - updateKeys.every((key) => current[key] === updates[key]) + updateKeys.every((key) => current[key] === nextUpdates[key]) ) { return; } - set(tradingFormAtom(), { ...current, ...updates }); + set(tradingFormAtom(), { ...current, ...nextUpdates }); }, ); @@ -2419,6 +2437,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { slValue: '', triggerPrice: '', executionPrice: '', + twapTriggerPrice: '', + twapStopPrice: '', }); }); @@ -2876,20 +2896,21 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { } const minutes = Number(formData.twapDurationMinutes ?? 0); - if ( - !Number.isInteger(minutes) || - minutes < TWAP_MIN_DURATION_MINUTES || - minutes > TWAP_MAX_DURATION_MINUTES - ) { + if (!isValidTwapDuration(minutes)) { throw new OneKeyLocalError( `TWAP duration must be ${TWAP_MIN_DURATION_MINUTES}-${TWAP_MAX_DURATION_MINUTES} minutes`, ); } - const markPrice = isSpot - ? (params.price ?? env.markPrice ?? '') - : (activeAssetCtxValue?.ctx?.markPrice ?? env.markPrice ?? ''); - const markPriceBN = new BigNumber(markPrice); + const markPriceBN = getTwapTriggerReferencePrice({ + isSpot, + midPrice: params.price ?? env.markPrice ?? '', + markPrice: + activeAssetCtxValue?.ctx?.markPrice ?? + activeAssetDataValue?.markPx ?? + env.markPrice ?? + '', + }); if (!markPriceBN.isFinite() || markPriceBN.lte(0)) { throw new OneKeyLocalError( 'Market price unavailable, please try again', @@ -2901,6 +2922,56 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { env.szDecimals ?? 2) : (activeAssetValue?.universe?.szDecimals ?? env.szDecimals ?? 2); + const rawTriggerPrice = formData.twapTriggerPrice?.trim(); + const triggerPrice = formatTwapPriceForOrder({ + price: rawTriggerPrice, + szDecimals, + assetType: isSpot ? 'spot' : 'perp', + }); + const rawStopPrice = formData.twapStopPrice?.trim(); + const stopPrice = formatTwapPriceForOrder({ + price: rawStopPrice, + szDecimals, + assetType: isSpot ? 'spot' : 'perp', + }); + let triggerAbove: boolean | undefined; + if (rawTriggerPrice && !triggerPrice) { + throw new OneKeyLocalError( + 'TWAP trigger price is too small for HL tick size', + ); + } + if (triggerPrice) { + triggerAbove = getTwapTriggerAbove({ + triggerPrice, + markPrice: markPriceBN, + }); + if (typeof triggerAbove !== 'boolean') { + throw new OneKeyLocalError( + 'TWAP trigger price must be positive and differ from the market price', + ); + } + } + if (rawStopPrice && !stopPrice) { + throw new OneKeyLocalError( + 'TWAP stop price is too small for HL tick size', + ); + } + if ( + stopPrice && + !isTwapStopPriceValid({ + isBuy: formData.side === 'long', + stopPrice, + referencePrice: markPriceBN, + triggerPrice, + }) + ) { + throw new OneKeyLocalError( + formData.side === 'long' + ? 'TWAP maximum price must be above the market and trigger price' + : 'TWAP minimum price must be below the market and trigger price', + ); + } + const resolvedSize = resolveTradingSize({ sizeInputMode: formData.sizeInputMode, manualSize: formData.size, @@ -2922,19 +2993,14 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { throw new OneKeyLocalError('Order size is required'); } - const totalNotional = resolvedSizeBN.multipliedBy(markPriceBN); - const estimatedSlices = Math.max( - 1, - Math.ceil((minutes * 60) / TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS), - ); - const averageSliceNotional = - totalNotional.dividedBy(estimatedSlices); if ( - !averageSliceNotional.isFinite() || - averageSliceNotional.lt(TWAP_MIN_ORDER_NOTIONAL) + !isTwapTotalNotionalValid({ + size: resolvedSizeBN, + price: markPriceBN, + }) ) { throw new OneKeyLocalError( - 'TWAP order size is too small for this duration', + `TWAP total notional must be at least ${TWAP_MIN_ORDER_NOTIONAL} USDC`, ); } @@ -2977,6 +3043,10 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { reduceOnly, minutes, randomize: formData.twapRandomize ?? true, + triggerPrice, + triggerAbove, + stopPrice, + referencePrice: markPriceBN.toFixed(), szDecimals, }, ); diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/atoms.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/atoms.ts index c0debfeefd91..43edc54fd129 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/atoms.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/atoms.ts @@ -309,6 +309,8 @@ export interface ITradingFormData { twapDurationMinutes?: string; twapRandomize?: boolean; twapReduceOnly?: boolean; + twapTriggerPrice?: string; + twapStopPrice?: string; } export const { atom: tradingFormAtom, use: useTradingFormAtom } = @@ -347,6 +349,8 @@ export const { atom: tradingFormAtom, use: useTradingFormAtom } = twapDurationMinutes: '10', twapRandomize: true, twapReduceOnly: false, + twapTriggerPrice: '', + twapStopPrice: '', }); export type ITradingFormOrderPriceParams = Pick< diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileTwapOpenOrdersRow.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileTwapOpenOrdersRow.tsx index 0f6a189dd7c9..b48aefad5417 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileTwapOpenOrdersRow.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/MobileTwapOpenOrdersRow.tsx @@ -12,12 +12,17 @@ import { } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; +import { + formatTwapPriceForDisplay, + getTwapElapsedMs, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { formatLocalizedNumberString, numberFormat, } from '@onekeyhq/shared/src/utils/numberUtils'; import { getValidPriceDecimals } from '@onekeyhq/shared/src/utils/perpsUtils'; +import type { ITwapHistoryRecord } from '@onekeyhq/shared/types/hyperliquid/sdk'; import { PerpTestIDs } from '../../../testIDs'; import { getOrderAssetDisplayName } from '../utils'; @@ -35,6 +40,8 @@ const valueFormatter: INumberFormatProps = { interface IMobileTwapOpenOrdersRowProps { order: IPerpsActiveTwapOrder; + status: ITwapHistoryRecord['status']['status']; + activatedAt?: number; onCancelOrder: () => void; } @@ -80,7 +87,12 @@ function MobileTwapInfoRow({ label, value }: { label: string; value: string }) { } const MobileTwapOpenOrdersRow = memo( - ({ order, onCancelOrder }: IMobileTwapOpenOrdersRowProps) => { + ({ + order, + status, + activatedAt, + onCancelOrder, + }: IMobileTwapOpenOrdersRowProps) => { const intl = useIntl(); const { twapId, state } = order; const [now, setNow] = useState(Date.now()); @@ -88,9 +100,12 @@ const MobileTwapOpenOrdersRow = memo( const [spotPairDisplayNameMap] = useSpotPairDisplayNameMapAtom(); useEffect(() => { + if (status === 'waitingForTrigger') { + return undefined; + } const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); - }, []); + }, [status]); const assetSymbol = useMemo( () => @@ -129,15 +144,19 @@ const MobileTwapOpenOrdersRow = memo( .multipliedBy(100) .toFixed(0) : undefined; - const progressText = - totalSize.gt(0) && executedSize.gte(0) - ? `${numberFormat( - executedSize.toFixed(), - balanceFormatter, - )} / ${numberFormat(totalSize.toFixed(), balanceFormatter)}${ - progressPercent ? ` · ${progressPercent}%` : '' - }` - : `${state.executedSz} / ${state.sz}`; + const isWaitingForTrigger = status === 'waitingForTrigger'; + let progressText = '--'; + if (!isWaitingForTrigger) { + progressText = + totalSize.gt(0) && executedSize.gte(0) + ? `${numberFormat( + executedSize.toFixed(), + balanceFormatter, + )} / ${numberFormat(totalSize.toFixed(), balanceFormatter)}${ + progressPercent ? ` · ${progressPercent}%` : '' + }` + : `${state.executedSz} / ${state.sz}`; + } const minuteUnit = intl .formatMessage({ id: ETranslations.Limit_expire_minutes }) .toLowerCase(); @@ -146,25 +165,32 @@ const MobileTwapOpenOrdersRow = memo( ? ` · ${intl.formatMessage({ id: ETranslations.global_randomized })}` : '' }`; - const elapsedMs = Math.min( - Math.max(now - state.timestamp, 0), - state.minutes * 60_000, - ); + const elapsedMs = getTwapElapsedMs({ + status, + timestamp: state.timestamp, + activatedAt, + now, + minutes: state.minutes, + }); return { progressText, avgPriceFormatted: avgPriceValue ? formatLocalizedNumberString(avgPriceValue) : '--', executedValueFormatted: numberFormat(state.executedNtl, valueFormatter), + triggerPriceFormatted: formatTwapPriceForDisplay(state.trigger?.px), + stopPriceFormatted: formatTwapPriceForDisplay(state.stopPx), execution, - runningTimeText: `${formatElapsedDuration( - elapsedMs, - )} / ${formatTotalDuration(state.minutes)}`, + runningTimeText: isWaitingForTrigger + ? '--' + : `${formatElapsedDuration(elapsedMs)} / ${formatTotalDuration( + state.minutes, + )}`, reduceOnlyText: state.reduceOnly ? intl.formatMessage({ id: ETranslations.perp_yes__title }) : intl.formatMessage({ id: ETranslations.perp_no__title }), }; - }, [intl, now, state]); + }, [activatedAt, intl, now, state, status]); const sideText = useMemo(() => { if (state.side === 'B') { @@ -177,6 +203,12 @@ const MobileTwapOpenOrdersRow = memo( : intl.formatMessage({ id: ETranslations.perp_short }); }, [intl, state.reduceOnly, state.side]); const typeColor = state.side === 'B' ? '$green11' : '$red11'; + const statusText = intl.formatMessage({ + id: + status === 'waitingForTrigger' + ? ETranslations.global_pending + : ETranslations.perp_twap_status_activated__title, + }); return ( + + + { - const tpslChildren = (order.children ?? []) as IPerpsFrontendOrder[]; + const tpslChildren = order.children ?? []; let tpPrice = '--'; let slPrice = '--'; if (tpslChildren && tpslChildren.length > 0) { 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 b64409315b48..667eefb79e1c 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx @@ -18,6 +18,7 @@ import { useOrderFilterByCurrentTokenAtom, usePerpsActiveOpenOrdersAtom, usePerpsActiveTwapOrdersAtom, + usePerpsTwapHistoryAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid/atoms'; import { usePerpsActiveAccountAtom, @@ -25,6 +26,11 @@ import { } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { + buildActiveTwapRuntimeInfoByKey, + getActiveTwapRuntimeStatus, + getTwapRuntimeInfoKey, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { IPerpsFrontendOrder } from '@onekeyhq/shared/types/hyperliquid/sdk'; import { usePerpsAccountScopedCacheAddress } from '../../../hooks/usePerpsAccountScopedCacheAddress'; @@ -215,6 +221,7 @@ function PerpOpenOrdersList({ const [perpOpenOrdersState] = usePerpsActiveOpenOrdersAtom(); const [spotOpenOrdersState] = useSpotActiveOpenOrdersAtom(); const [twapOrdersState] = usePerpsActiveTwapOrdersAtom(); + const [twapHistoryState] = usePerpsTwapHistoryAtom(); const [currentUser] = usePerpsActiveAccountAtom(); const accountScopedAddress = usePerpsAccountScopedCacheAddress(); const [filterByCurrentToken] = useOrderFilterByCurrentTokenAtom(); @@ -264,6 +271,23 @@ function PerpOpenOrdersList({ twapOrdersState.twapOrders, ], ); + const scopedTwapHistory = useMemo( + () => + getPerpsAccountScopedListData({ + activeAccountAddress: accountScopedAddress, + dataAccountAddress: twapHistoryState.accountAddress, + data: twapHistoryState.history, + }), + [ + accountScopedAddress, + twapHistoryState.accountAddress, + twapHistoryState.history, + ], + ); + const activeTwapRuntimeInfoByKey = useMemo( + () => buildActiveTwapRuntimeInfoByKey(scopedTwapHistory), + [scopedTwapHistory], + ); const openOrders = useMemo( () => [...scopedPerpOpenOrders, ...scopedSpotOpenOrders].toSorted( @@ -483,9 +507,21 @@ function PerpOpenOrdersList({ onHoverChange?: (index: number | null) => void, ) => { if (item.type === 'twap') { + const runtimeInfo = activeTwapRuntimeInfoByKey.get( + getTwapRuntimeInfoKey(item.order.state), + ); + const status = getActiveTwapRuntimeStatus({ + reportedStatus: runtimeInfo?.reportedStatus, + triggerPrice: item.order.state.trigger?.px, + executedSize: item.order.state.executedSz, + }); return ( void handleCancelTwapOrder(item.order)} /> ); diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx index f89e0147bb97..ebbd57b7dbc1 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx @@ -34,6 +34,13 @@ import { } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; +import { + buildActiveTwapRuntimeInfoByKey, + formatTwapPriceForDisplay, + getActiveTwapRuntimeStatus, + getTwapElapsedMs, + getTwapRuntimeInfoKey, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { formatLocalizedNumberString, @@ -165,7 +172,9 @@ function getTwapHistoryStatusText( activated: ETranslations.perp_twap_status_activated__title, error: ETranslations.perp_twap_status_error__title, finished: ETranslations.perp_twap_status_finished__title, + stopped: ETranslations.perp_twap_status_terminated__title, terminated: ETranslations.perp_twap_status_terminated__title, + waitingForTrigger: ETranslations.global_pending, }; return intl.formatMessage({ id: statusTextMap[status] }); } @@ -204,6 +213,8 @@ function getTwapBaseInfo({ state, now, endTime, + activatedAt, + status, spotDisplayMap, spotPairDisplayNameMap, intl, @@ -211,6 +222,8 @@ function getTwapBaseInfo({ state: ITwapState; now: number; endTime?: number; + activatedAt?: number; + status?: ITwapHistoryRecord['status']['status']; spotDisplayMap: Record; spotPairDisplayNameMap: Record; intl: IntlShape; @@ -236,11 +249,14 @@ function getTwapBaseInfo({ executedSize.toFixed(), balanceFormatter, ); - const totalMs = state.minutes * 60_000; - const elapsedMs = Math.min( - Math.max((endTime ?? now) - state.timestamp, 0), - totalMs, - ); + const elapsedMs = getTwapElapsedMs({ + status, + timestamp: state.timestamp, + activatedAt, + now, + endTime, + minutes: state.minutes, + }); return { assetSymbol, @@ -251,10 +267,15 @@ function getTwapBaseInfo({ avgPriceFormatted: avgPriceValue ? formatLocalizedNumberString(avgPriceValue) : '--', - runningTimeText: `${formatElapsedDuration(elapsedMs)} / ${formatTotalDuration( - state.minutes, - intl, - )}`, + triggerPriceFormatted: formatTwapPriceForDisplay(state.trigger?.px), + stopPriceFormatted: formatTwapPriceForDisplay(state.stopPx), + runningTimeText: + status === 'waitingForTrigger' + ? '--' + : `${formatElapsedDuration(elapsedMs)} / ${formatTotalDuration( + state.minutes, + intl, + )}`, reduceOnlyText: state.reduceOnly ? intl.formatMessage({ id: ETranslations.perp_yes__title }) : intl.formatMessage({ id: ETranslations.perp_no__title }), @@ -433,6 +454,8 @@ function TwapEmptyState({ function TwapActiveRow({ order, + status, + activatedAt, now, cellMinWidth, columnConfigs, @@ -445,6 +468,8 @@ function TwapActiveRow({ spotPairDisplayNameMap, }: { order: IPerpsActiveTwapOrder; + status: ITwapHistoryRecord['status']['status']; + activatedAt?: number; now: number; cellMinWidth: number; columnConfigs: IColumnConfig[]; @@ -463,12 +488,22 @@ function TwapActiveRow({ () => getTwapBaseInfo({ state, + status, + activatedAt, now, spotDisplayMap, spotPairDisplayNameMap, intl, }), - [intl, now, spotDisplayMap, spotPairDisplayNameMap, state], + [ + activatedAt, + intl, + now, + spotDisplayMap, + spotPairDisplayNameMap, + state, + status, + ], ); const creationTime = useMemo( () => formatTwapDateTime(state.timestamp), @@ -531,29 +566,56 @@ function TwapActiveRow({ - {baseInfo.runningTimeText} + + {baseInfo.triggerPriceFormatted} + - + + {baseInfo.stopPriceFormatted} + + + + {baseInfo.runningTimeText} + + + + {getTwapHistoryStatusText(status, intl)} + + + {baseInfo.reduceOnlyText} {baseInfo.randomizeText} {creationTime.inline} @@ -561,8 +623,8 @@ function TwapActiveRow({ ) : null} {shouldRenderRight ? ( @@ -611,19 +673,32 @@ function TwapHistoryRow({ const intl = useIntl(); const { state } = record; const isActivated = record.status.status === 'activated'; - const endTime = isActivated ? undefined : normalizeEpochMs(record.time); + const isWaitingForTrigger = record.status.status === 'waitingForTrigger'; + const endTime = + isActivated || isWaitingForTrigger + ? undefined + : normalizeEpochMs(record.time); const sideInfo = useMemo(() => getTwapSideInfo(state, intl), [intl, state]); const baseInfo = useMemo( () => getTwapBaseInfo({ state, + status: record.status.status, now, endTime, spotDisplayMap, spotPairDisplayNameMap, intl, }), - [endTime, intl, now, spotDisplayMap, spotPairDisplayNameMap, state], + [ + endTime, + intl, + now, + record.status.status, + spotDisplayMap, + spotPairDisplayNameMap, + state, + ], ); const historyTime = useMemo( () => formatTwapDateTime(getTwapHistoryEventTimeMs(record)), @@ -631,15 +706,22 @@ function TwapHistoryRow({ ); const historyDisplayInfo = useMemo( () => ({ - executedSize: isActivated ? '--' : baseInfo.executedSizeWithSymbol, - averagePrice: isActivated ? '--' : baseInfo.avgPriceFormatted, - totalRuntime: formatTotalDuration(state.minutes, intl), + executedSize: + isActivated || isWaitingForTrigger + ? '--' + : baseInfo.executedSizeWithSymbol, + averagePrice: + isActivated || isWaitingForTrigger ? '--' : baseInfo.avgPriceFormatted, + totalRuntime: isWaitingForTrigger + ? '--' + : formatTotalDuration(state.minutes, intl), }), [ baseInfo.avgPriceFormatted, baseInfo.executedSizeWithSymbol, intl, isActivated, + isWaitingForTrigger, state.minutes, ], ); @@ -752,6 +834,21 @@ function TwapHistoryRow({ })} value={historyDisplayInfo.averagePrice} /> + + + + {baseInfo.triggerPriceFormatted} + + + + + {baseInfo.stopPriceFormatted} + + + {historyDisplayInfo.totalRuntime} {baseInfo.reduceOnlyText} {baseInfo.randomizeText} @@ -864,8 +979,8 @@ function TwapHistoryRow({ ) : null} {shouldRenderRight ? ( buildActiveTwapRuntimeInfoByKey(historyRows), + [historyRows], + ); + const sliceFills = useMemo(() => { if ( !currentAccountAddress || @@ -1331,6 +1451,26 @@ function PerpTwapList({ flex: 1, align: 'left', }, + { + key: 'triggerPrice', + title: intl.formatMessage({ + id: ETranslations.dexmarket_pro_trigger_price, + }), + minWidth: 130, + flex: 1, + align: 'left', + }, + { + key: 'stopPrice', + title: `${intl.formatMessage({ + id: ETranslations.perp_scale_upper_price__title, + })} / ${intl.formatMessage({ + id: ETranslations.perp_scale_lower_price__title, + })}`, + minWidth: 130, + flex: 1, + align: 'left', + }, { key: 'runningTime', title: intl.formatMessage({ @@ -1340,6 +1480,13 @@ function PerpTwapList({ flex: 1, align: 'left', }, + { + key: 'status', + title: intl.formatMessage({ id: ETranslations.global_status }), + minWidth: 130, + flex: 1, + align: 'left', + }, { key: 'reduceOnly', title: intl.formatMessage({ @@ -1443,6 +1590,26 @@ function PerpTwapList({ flex: 1, align: 'left', }, + { + key: 'triggerPrice', + title: intl.formatMessage({ + id: ETranslations.dexmarket_pro_trigger_price, + }), + minWidth: 130, + flex: 1, + align: 'left', + }, + { + key: 'stopPrice', + title: `${intl.formatMessage({ + id: ETranslations.perp_scale_upper_price__title, + })} / ${intl.formatMessage({ + id: ETranslations.perp_scale_lower_price__title, + })}`, + minWidth: 130, + flex: 1, + align: 'left', + }, { key: 'totalRuntime', title: intl.formatMessage({ @@ -1635,23 +1802,38 @@ function PerpTwapList({ renderMode?: IRenderMode, isHovered?: boolean, onHoverChange?: (index: number | null) => void, - ) => ( - void handleTerminate(item)} - index={index} - renderMode={renderMode} - isHovered={isHovered} - onHoverChange={onHoverChange} - spotDisplayMap={spotDisplayMap} - spotPairDisplayNameMap={spotPairDisplayNameMap} - /> - ), + ) => { + const runtimeInfo = activeRuntimeInfoByKey.get( + getTwapRuntimeInfoKey(item.state), + ); + const status = getActiveTwapRuntimeStatus({ + reportedStatus: runtimeInfo?.reportedStatus, + triggerPrice: item.state.trigger?.px, + executedSize: item.state.executedSz, + }); + return ( + void handleTerminate(item)} + index={index} + renderMode={renderMode} + isHovered={isHovered} + onHoverChange={onHoverChange} + spotDisplayMap={spotDisplayMap} + spotPairDisplayNameMap={spotPairDisplayNameMap} + /> + ); + }, [ activeColumns, + activeRuntimeInfoByKey, activeMinWidth, handleTerminate, now, diff --git a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx index 84ef8af73214..54fd916a81a2 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx @@ -61,7 +61,6 @@ import type { import { SCALE_ORDER_MAX_COUNT, SCALE_ORDER_MIN_COUNT, - SCALE_ORDER_MIN_NOTIONAL, buildScaleOrderLegs, getReduceOnlyOrderGuardError, getReduceOnlyPositionSnapshotError, @@ -69,6 +68,15 @@ import { normalizeScaleOrderCount, validateScaleOrderLegs, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; +import { + TWAP_MAX_DURATION_MINUTES, + TWAP_MIN_DURATION_MINUTES, + formatTwapPriceForOrder, + getTwapTriggerAbove, + isTwapStopPriceValid, + isTwapTotalNotionalValid, + isValidTwapDuration, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { getSpotTokenDisplayName, parseDexCoin, @@ -91,6 +99,7 @@ import { useLiquidationPrice } from '../../hooks/useLiquidationPrice'; import { useShowDepositWithdrawModal } from '../../hooks/useShowDepositWithdrawModal'; import { useTradingCalculationsForSide } from '../../hooks/useTradingCalculationsForSide'; import { useTradingPrice } from '../../hooks/useTradingPrice'; +import { useTwapReferencePrice } from '../../hooks/useTwapReferencePrice'; import { PerpTestIDs } from '../../testIDs'; import { shouldPreserveColdStartButtonVisualState } from '../../utils/accountScopedData'; import { getEnableTradingDialogConfirmDecision } from '../../utils/enableTradingDialogConfirm'; @@ -122,10 +131,6 @@ import { showOrderConfirmDialog } from './modals/OrderConfirmModal'; import type { LayoutChangeEvent } from 'react-native'; -const TWAP_MIN_DURATION_MINUTES = 5; -const TWAP_MAX_DURATION_MINUTES = 1440; -const TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS = 30; - interface ITradingButtonGroupProps { isMobile: boolean; isLiveStatusPending?: boolean; @@ -457,6 +462,7 @@ function SideButtonInternal({ const [isSubmitting] = useTradingLoadingAtom(); const { midPriceBN } = useTradingPrice(); + const twapTriggerReferencePriceBN = useTwapReferencePrice({ midPriceBN }); const shouldBlockForMarketData = shouldBlockPerpsTradingForMarketData(marketDataFreshness); const confirmHyperliquidTerms = useConfirmHyperliquidTerms(); @@ -791,6 +797,7 @@ function SideButtonInternal({ side, shouldBlockForMarketData, szDecimals, + twapTriggerReferencePriceBN, }); latestOrderPanelStateRef.current = { activeAsset, @@ -817,6 +824,7 @@ function SideButtonInternal({ side, shouldBlockForMarketData, szDecimals, + twapTriggerReferencePriceBN, }; type ILatestOrderPanelState = typeof latestOrderPanelStateRef.current; @@ -849,6 +857,7 @@ function SideButtonInternal({ resolvedSizeInputUnit: latestResolvedSizeInputUnit, shouldBlockForMarketData: latestShouldBlockForMarketData, szDecimals: latestSzDecimals, + twapTriggerReferencePriceBN: latestTwapTriggerReferencePriceBN, } = orderPanelState; if ( @@ -973,13 +982,77 @@ function SideButtonInternal({ if (latestIsTwapMode) { const duration = Number(latestFormData.twapDurationMinutes ?? 0); + if (!isValidTwapDuration(duration)) { + Toast.message({ + title: intl.formatMessage( + { id: ETranslations.perp_twap_duration_range__msg }, + { + min: TWAP_MIN_DURATION_MINUTES, + max: TWAP_MAX_DURATION_MINUTES, + }, + ), + }); + return 'invalidTwapConfig' as const; + } + if ( + !latestTwapTriggerReferencePriceBN.isFinite() || + latestTwapTriggerReferencePriceBN.lte(0) + ) { + Toast.error({ + title: intl.formatMessage({ + id: ETranslations.provider_unavailable, + }), + }); + return 'marketDataUnavailable' as const; + } + const rawTriggerPrice = latestFormData.twapTriggerPrice?.trim(); + const triggerPrice = formatTwapPriceForOrder({ + price: rawTriggerPrice, + szDecimals: latestSzDecimals, + assetType: latestIsSpot ? 'spot' : 'perp', + }); if ( - !Number.isInteger(duration) || - duration < TWAP_MIN_DURATION_MINUTES || - duration > TWAP_MAX_DURATION_MINUTES + rawTriggerPrice && + (!triggerPrice || + typeof getTwapTriggerAbove({ + triggerPrice, + markPrice: latestTwapTriggerReferencePriceBN, + }) !== 'boolean') ) { Toast.message({ - title: `TWAP duration must be ${TWAP_MIN_DURATION_MINUTES}-${TWAP_MAX_DURATION_MINUTES} minutes`, + title: intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + }); + return 'invalidTwapConfig' as const; + } + const rawStopPrice = latestFormData.twapStopPrice?.trim(); + const stopPrice = formatTwapPriceForOrder({ + price: rawStopPrice, + szDecimals: latestSzDecimals, + assetType: latestIsSpot ? 'spot' : 'perp', + }); + if ( + rawStopPrice && + (!stopPrice || + !isTwapStopPriceValid({ + isBuy: validationSide === 'long', + stopPrice, + referencePrice: latestTwapTriggerReferencePriceBN, + triggerPrice, + })) + ) { + Toast.message({ + title: `${intl.formatMessage({ + id: + validationSide === 'long' + ? ETranslations.perp_scale_upper_price_label__title + : ETranslations.perp_scale_lower_price_label__title, + })} ${validationSide === 'long' ? '>' : '<'} ${intl.formatMessage({ + id: ETranslations.perp_market_price, + })} / ${intl.formatMessage({ + id: ETranslations.dexmarket_pro_trigger_price, + })}`, }); return 'invalidTwapConfig' as const; } @@ -1073,22 +1146,15 @@ function SideButtonInternal({ } if (latestIsTwapMode) { - const duration = Number(latestFormData.twapDurationMinutes ?? 0); - const estimatedSlices = Math.max( - 1, - Math.ceil((duration * 60) / TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS), - ); - const totalNotional = latestComputedSizeForSide.multipliedBy( - latestEffectivePriceBN, - ); - const averageSliceNotional = totalNotional.dividedBy(estimatedSlices); if ( - !averageSliceNotional.isFinite() || - averageSliceNotional.lt(SCALE_ORDER_MIN_NOTIONAL) + !isTwapTotalNotionalValid({ + size: latestComputedSizeForSide, + price: latestTwapTriggerReferencePriceBN, + }) ) { Toast.message({ title: intl.formatMessage({ - id: ETranslations.perp_twap_small_slice__msg, + id: ETranslations.perp_scale_order_size_too_small__msg, }), }); return 'invalidTwapConfig' as const; diff --git a/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx b/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx index 197ce29f5997..1cdd530dcf21 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx @@ -25,6 +25,7 @@ import { usePerpsCustomSettingsAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { formatTwapPriceForOrder } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { formatLocalizedNumberString, numberFormat, @@ -254,10 +255,34 @@ function OrderConfirmContent({ if (!isTwapMode) { return null; } + // The user must confirm the exact wire prices the submit path sends. + const triggerPrice = formatTwapPriceForOrder({ + price: formData.twapTriggerPrice, + szDecimals, + assetType: isSpot ? 'spot' : 'perp', + }); + const stopPrice = formatTwapPriceForOrder({ + price: formData.twapStopPrice, + szDecimals, + assetType: isSpot ? 'spot' : 'perp', + }); return { minutes: Number(formData.twapDurationMinutes ?? 0), + triggerPrice: triggerPrice + ? `$${formatLocalizedNumberString(triggerPrice)}` + : undefined, + stopPrice: stopPrice + ? `$${formatLocalizedNumberString(stopPrice)}` + : undefined, }; - }, [formData.twapDurationMinutes, isTwapMode]); + }, [ + formData.twapDurationMinutes, + formData.twapStopPrice, + formData.twapTriggerPrice, + isSpot, + isTwapMode, + szDecimals, + ]); const _inferredTpslBadge = useMemo(() => { if (!isTriggerMode || !formData.triggerPrice) return null; @@ -723,6 +748,33 @@ function OrderConfirmContent({ {twapPreview.minutes} {minuteUnit} + {twapPreview.triggerPrice ? ( + + + {intl.formatMessage({ + id: ETranslations.dexmarket_pro_trigger_price, + })} + + + {twapPreview.triggerPrice} + + + ) : null} + {twapPreview.stopPrice ? ( + + + {intl.formatMessage({ + id: + effectiveSide === 'long' + ? ETranslations.perp_scale_upper_price_label__title + : ETranslations.perp_scale_lower_price_label__title, + })} + + + {twapPreview.stopPrice} + + + ) : null} ) : null} diff --git a/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx b/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx index cd0ba6fd5e16..31bd5b1b9247 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx @@ -65,6 +65,11 @@ import { getScaleOrderSizeSkew, validateScaleOrderLegs, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; +import { + TWAP_MAX_DURATION_MINUTES, + TWAP_MIN_DURATION_MINUTES, + isValidTwapDuration, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { numberFormat } from '@onekeyhq/shared/src/utils/numberUtils'; import { openUrlExternal } from '@onekeyhq/shared/src/utils/openUrlUtils'; import { @@ -88,6 +93,7 @@ import { usePerpsAccountScopedActivePositions } from '../../../hooks/usePerpsAcc import { useShowDepositWithdrawModal } from '../../../hooks/useShowDepositWithdrawModal'; import { useSpotMetaMaps } from '../../../hooks/useSpotMetaMaps'; import { useTradingPrice } from '../../../hooks/useTradingPrice'; +import { useTwapReferencePrice } from '../../../hooks/useTwapReferencePrice'; import { PerpTestIDs } from '../../../testIDs'; import { isHyperLiquidUnifiedAccountMode } from '../../../utils/accountMode'; import { getPerpsFormLeverage } from '../../../utils/leverageDisplay'; @@ -100,10 +106,7 @@ import { PerpsSlider } from '../../PerpsSlider'; import { PerpIpRestrictionNotice } from '../components/PerpIpRestrictionNotice'; import { PerpsAccountNumberValue } from '../components/PerpsAccountNumberValue'; import { PriceInput } from '../inputs/PriceInput'; -import { - type ISizeInputDisplayValueChangePayload, - SizeInput, -} from '../inputs/SizeInput'; +import { SizeInput } from '../inputs/SizeInput'; import { TpSlFormInput } from '../inputs/TpSlFormInput'; import { TradingFormInput } from '../inputs/TradingFormInput'; import { LeverageAdjustModal } from '../modals/LeverageAdjustModal'; @@ -124,7 +127,6 @@ type IPrimaryOrderType = 'market' | 'limit' | 'trigger'; type ITriggerDropdownValue = ETriggerOrderType | 'scale' | 'twap'; type ITwapDurationInputField = 'hours' | 'minutes'; type IOrderTypeInfoValue = IPrimaryOrderType | ITriggerDropdownValue; -type ISizeInputDraft = ISizeInputDisplayValueChangePayload; type IOrderTypeInfoItem = { description: string; helpUrl?: string; @@ -175,10 +177,6 @@ const TRIGGER_MODE_TPSL_RESET: Partial = { slValue: '', }; const USDC_TOKEN_SYMBOL = 'USDC'; -const TWAP_MIN_DURATION_MINUTES = 5; -const TWAP_MAX_DURATION_MINUTES = 1440; -const TWAP_ESTIMATED_SLICE_INTERVAL_MINUTES = 0.5; -const TWAP_MIN_SLICE_NOTIONAL_HINT = 10; export const ORDER_TYPE_HELP_CENTER_URL = 'https://help.onekey.so/articles/15442238'; const TWAP_DURATION_PRESET_OPTIONS = [ @@ -186,6 +184,7 @@ const TWAP_DURATION_PRESET_OPTIONS = [ { label: '6h', minutes: 360 }, { label: '12h', minutes: 720 }, { label: '24h', minutes: 1440 }, + { label: '7d', minutes: 10_080 }, ] as const; function clampTwapDurationMinutes(minutes: number) { @@ -478,9 +477,6 @@ function PerpTradingForm({ const [, setTradingFormEnv] = useTradingFormEnvAtom(); const tradingComputed = useTradingFormSizeInputComputed(); const advancedComputedSizeBN = useTradingFormComputedSize(); - const [sizeInputDraft, setSizeInputDraft] = useState< - ISizeInputDraft | undefined - >(); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const isSpot = activeTradeInstrument.mode === 'spot'; const shouldUseLiveTradingPrice = Boolean( @@ -502,6 +498,7 @@ function PerpTradingForm({ const { midPrice, midPriceBN } = useTradingPrice({ source: tradingPriceSource, }); + const twapReferencePriceBN = useTwapReferencePrice({ midPriceBN }); const { price: orderPriceBN } = useOrderPrice(formData.side, { priceSource: tradingPriceSource, }); @@ -580,28 +577,6 @@ function PerpTradingForm({ const isSelectedTradeAssetCtxReady = isSpot ? isSpotActiveAssetCtxReady : isPerpsActiveAssetCtxReady; - const handleSizeInputDisplayValueChange = useCallback( - (payload: ISizeInputDraft) => { - setSizeInputDraft(payload.displayValue.trim() ? payload : undefined); - }, - [], - ); - - useEffect(() => { - setSizeInputDraft(undefined); - }, [ - activeTradeInstrument.assetId, - activeTradeInstrument.mode, - formData.side, - selectedTradeAsset?.coin, - ]); - - useEffect(() => { - if (formData.sizeInputMode === EPerpsSizeInputMode.SLIDER) { - setSizeInputDraft(undefined); - } - }, [formData.sizeInputMode]); - const spotAvailableBaseBN = useMemo(() => { if (!spotUniverse?.baseName) { return new BigNumber(0); @@ -672,7 +647,7 @@ function PerpTradingForm({ () => `${intl.formatMessage({ id: ETranslations.perp_twap_duration__title, - })} (${TWAP_MIN_DURATION_MINUTES}m - ${TWAP_MAX_DURATION_MINUTES / 60}h)`, + })} (${TWAP_MIN_DURATION_MINUTES}m - ${TWAP_MAX_DURATION_MINUTES / 1440}d)`, [intl], ); const twapHelperText = useMemo( @@ -682,13 +657,6 @@ function PerpTradingForm({ }), [intl], ); - const twapSmallSliceHelperText = useMemo( - () => - intl.formatMessage({ - id: ETranslations.perp_twap_small_slice__msg, - }), - [intl], - ); const scaleAmountDistributionHelperText = useMemo( () => intl.formatMessage({ @@ -928,7 +896,9 @@ function PerpTradingForm({ // Reference Price: Get the effective trading price (limit price, market price, or trigger effective price) const [, referencePriceString] = useMemo(() => { let price = new BigNumber(0); - if (formData.orderMode === 'trigger' && formData.triggerOrderType) { + if (formData.orderMode === 'twap') { + price = twapReferencePriceBN; + } else if (formData.orderMode === 'trigger' && formData.triggerOrderType) { price = getTriggerEffectivePrice({ triggerOrderType: formData.triggerOrderType, triggerPrice: formData.triggerPrice, @@ -963,6 +933,7 @@ function PerpTradingForm({ formData.executionPrice, formData.scaleLowerPrice, formData.scaleUpperPrice, + twapReferencePriceBN, isSpot, midPriceBN, sizeSzDecimals, @@ -1076,11 +1047,7 @@ function PerpTradingForm({ } const duration = Number(rawDuration); - if ( - !Number.isInteger(duration) || - duration < TWAP_MIN_DURATION_MINUTES || - duration > TWAP_MAX_DURATION_MINUTES - ) { + if (!isValidTwapDuration(duration)) { return { text: intl.formatMessage( { id: ETranslations.perp_twap_duration_range__msg }, @@ -1094,97 +1061,12 @@ function PerpTradingForm({ } }, [formData.twapDurationMinutes, intl, isTwapMode]); - const twapEstimatedOrderNotional = useMemo(() => { - if (!isTwapMode) { - return undefined; - } - if (!midPriceBN.isFinite() || midPriceBN.lte(0)) { - return undefined; - } - - const draft = sizeInputDraft; - const draftDisplayValue = draft?.displayValue?.trim(); - if (draft && draftDisplayValue) { - const draftDisplayValueBN = new BigNumber(draftDisplayValue); - if (draftDisplayValueBN.isFinite() && draftDisplayValueBN.gt(0)) { - if (draft.inputMode === 'usd') { - return draftDisplayValueBN; - } - if (draft.inputMode === 'margin') { - const leverageBN = new BigNumber(formData.leverage ?? 1); - return draftDisplayValueBN.multipliedBy( - leverageBN.isFinite() && leverageBN.gt(0) ? leverageBN : 1, - ); - } - return draftDisplayValueBN.multipliedBy(midPriceBN); - } - } - - if (!advancedComputedSizeBN.isFinite() || advancedComputedSizeBN.lte(0)) { - return undefined; - } - - return advancedComputedSizeBN.multipliedBy(midPriceBN); - }, [ - advancedComputedSizeBN, - formData.leverage, - isTwapMode, - midPriceBN, - sizeInputDraft, - ]); - - const twapEstimatedSliceNotional = useMemo(() => { - if (!isTwapMode) { - return undefined; - } - const duration = Number(formData.twapDurationMinutes ?? 0); - if ( - !Number.isInteger(duration) || - duration < TWAP_MIN_DURATION_MINUTES || - duration > TWAP_MAX_DURATION_MINUTES || - !twapEstimatedOrderNotional || - !twapEstimatedOrderNotional.isFinite() || - twapEstimatedOrderNotional.lte(0) - ) { - return undefined; - } - - const estimatedSlices = Math.max( - 1, - Math.ceil(duration / TWAP_ESTIMATED_SLICE_INTERVAL_MINUTES), - ); - const estimatedSliceNotional = - twapEstimatedOrderNotional.dividedBy(estimatedSlices); - if (!estimatedSliceNotional.isFinite() || estimatedSliceNotional.lte(0)) { - return undefined; - } - - return estimatedSliceNotional; - }, [formData.twapDurationMinutes, isTwapMode, twapEstimatedOrderNotional]); - - const twapEstimatedSliceNotionalDisplay = useMemo(() => { - if (!twapEstimatedSliceNotional) { - return undefined; - } - - return `${numberFormat(twapEstimatedSliceNotional.toFixed(), { - formatter: 'balance', - })} ${USDC_TOKEN_SYMBOL}`; - }, [twapEstimatedSliceNotional]); - - const twapHelperMessage = useMemo(() => { - if ( - twapEstimatedSliceNotional && - twapEstimatedSliceNotional.lt(TWAP_MIN_SLICE_NOTIONAL_HINT) - ) { - return twapSmallSliceHelperText; - } - - return undefined; - }, [twapEstimatedSliceNotional, twapSmallSliceHelperText]); - const [twapDurationHoursInput, setTwapDurationHoursInput] = useState(''); const [twapDurationMinutesInput, setTwapDurationMinutesInput] = useState(''); + // Restored drafts with a trigger/stop price must keep their inputs visible. + const [isTwapAdvancedVisible, setIsTwapAdvancedVisible] = useState(() => + Boolean(formData.twapTriggerPrice || formData.twapStopPrice), + ); const [focusedTwapDurationInput, setFocusedTwapDurationInput] = useState(null); const focusedTwapDurationInputRef = useRef( @@ -2257,26 +2139,56 @@ function PerpTradingForm({ ); }; + const renderTwapDetailsSection = () => { + if (!isTwapMode || !isTwapAdvancedVisible) { + return null; + } + + const isBuy = formData.side === 'long'; + return ( + + updateForm({ twapTriggerPrice: value })} + szDecimals={sizeSzDecimals} + isSpot={isSpot} + isMobile={isMobile} + disabled={isSubmitting} + /> + updateForm({ twapStopPrice: value })} + szDecimals={sizeSzDecimals} + isSpot={isSpot} + isMobile={isMobile} + disabled={isSubmitting} + /> + + ); + }; + const renderTwapDurationSection = () => { if (!isTwapMode) { return null; } const quickOptionHeight = isMobile ? 28 : 26; - const renderTwapHelperMessage = () => { - if (!twapHelperMessage) { - return null; - } - - return ( - - - {twapHelperMessage} - - - ); - }; - if (isMobile) { return ( @@ -2323,7 +2235,6 @@ function PerpTradingForm({ ); })} - {renderTwapHelperMessage()} {twapDurationInputMessage ? ( {twapDurationInputMessage.text} @@ -2472,7 +2383,6 @@ function PerpTradingForm({ ); })} - {renderTwapHelperMessage()} {twapDurationInputMessage ? ( {twapDurationInputMessage.text} @@ -2591,33 +2501,39 @@ function PerpTradingForm({ } /> - {twapEstimatedSliceNotionalDisplay ? ( - + { + const next = !!checked; + setIsTwapAdvancedVisible(next); + // Hidden inputs must never submit stale trigger/stop prices. + if (!next) { + updateForm({ twapTriggerPrice: '', twapStopPrice: '' }); + } + }} + disabled={isSubmitting} + containerProps={{ + p: 0, + alignItems: 'center', + ...(!isMobile && { cursor: 'pointer' }), + }} + width={checkboxSizeVal} + height={checkboxSizeVal} + {...(isMobile && { p: '$0' })} + /> + - - {intl.formatMessage({ - id: ETranslations.perp_twap_child_order_size__title, - })} - - - {twapEstimatedSliceNotionalDisplay} - - - ) : null} + {intl.formatMessage({ + id: ETranslations.global_advanced_settings, + })} + + + {renderTwapDetailsSection()} ); } @@ -3150,7 +3066,6 @@ function PerpTradingForm({ symbol={activeBaseName || perpsSelectedDisplayName} value={formData.size} onChange={handleManualSizeChange} - onDisplayValueChange={handleSizeInputDisplayValueChange} sizeInputMode={tradingComputed.sizeInputMode} sliderPercent={tradingComputed.sizePercent} onRequestManualMode={switchToManual} diff --git a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts index b8a3868e26af..e5704f927d33 100644 --- a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts +++ b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts @@ -16,7 +16,6 @@ import { ETranslations } from '@onekeyhq/shared/src/locale'; import { SCALE_ORDER_MAX_COUNT, SCALE_ORDER_MIN_COUNT, - SCALE_ORDER_MIN_NOTIONAL, buildScaleOrderLegs, getReduceOnlyOrderGuardError, getReduceOnlyPositionSnapshotError, @@ -25,6 +24,15 @@ import { normalizeScaleOrderCount, validateScaleOrderLegs, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; +import { + TWAP_MAX_DURATION_MINUTES, + TWAP_MIN_DURATION_MINUTES, + formatTwapPriceForOrder, + getTwapTriggerAbove, + isTwapStopPriceValid, + isTwapTotalNotionalValid, + isValidTwapDuration, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { formatPriceToSignificantDigits, formatSpotPriceToValid, @@ -42,11 +50,7 @@ import { useOrderPrice } from './useOrderPrice'; import { usePerpsMarketDataFreshness } from './usePerpsMarketDataFreshness'; import { useTradingCalculationsForSide } from './useTradingCalculationsForSide'; import { useTradingPrice } from './useTradingPrice'; - -const TWAP_MIN_DURATION_MINUTES = 5; -const TWAP_MAX_DURATION_MINUTES = 1440; -const TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS = 30; -const TWAP_MIN_ORDER_NOTIONAL = Number(SCALE_ORDER_MIN_NOTIONAL); +import { useTwapReferencePrice } from './useTwapReferencePrice'; interface IUseOrderConfirmOptions { onSuccess?: () => void; @@ -72,6 +76,7 @@ function useOrderConfirmWithMarketDataFreshness({ const hyperliquidActions = useHyperliquidActions(); const [isSubmitting] = useTradingLoadingAtom(); const { midPrice, midPriceBN } = useTradingPrice(); + const twapReferencePriceBN = useTwapReferencePrice({ midPriceBN }); const shouldBlockForMarketData = shouldBlockPerpsTradingForMarketData(marketDataFreshness); @@ -322,51 +327,126 @@ function useOrderConfirmWithMarketDataFreshness({ if (formDataSnapshot.orderMode === 'twap') { const duration = Number(formDataSnapshot.twapDurationMinutes ?? 0); - if ( - !Number.isInteger(duration) || - duration < TWAP_MIN_DURATION_MINUTES || - duration > TWAP_MAX_DURATION_MINUTES - ) { + if (!isValidTwapDuration(duration)) { Toast.error({ - title: 'Order Failed', - message: `TWAP duration must be ${TWAP_MIN_DURATION_MINUTES}-${TWAP_MAX_DURATION_MINUTES} minutes`, + title: intl.formatMessage({ id: ETranslations.global_failed }), + message: intl.formatMessage( + { id: ETranslations.perp_twap_duration_range__msg }, + { + min: TWAP_MIN_DURATION_MINUTES, + max: TWAP_MAX_DURATION_MINUTES, + }, + ), }); return; } const isSpotOrder = activeTradeInstrument.mode === 'spot'; + const szDecimals = isSpotOrder + ? (activeTradeInstrument.universe?.baseSzDecimals ?? 2) + : (activeTradeInstrument.universe?.szDecimals ?? 2); const twapSize = side === 'long' ? longCalculations.computedSizeForSide : shortCalculations.computedSizeForSide; if (!twapSize.isFinite() || twapSize.lte(0)) { Toast.error({ - title: 'Order Failed', - message: 'Order size is required', + title: intl.formatMessage({ id: ETranslations.global_failed }), + message: intl.formatMessage({ + id: ETranslations.perp_scale_order_size_too_small__msg, + }), }); return; } - if (!midPriceBN.isFinite() || midPriceBN.lte(0)) { + if (!twapReferencePriceBN.isFinite() || twapReferencePriceBN.lte(0)) { Toast.error({ - title: 'Order Failed', - message: 'Market price is not available. Please try again.', + title: intl.formatMessage({ + id: ETranslations.provider_unavailable, + }), + message: intl.formatMessage({ + id: ETranslations.global_an_error_occurred_desc, + }), }); return; } - const estimatedSlices = Math.max( - 1, - Math.ceil((duration * 60) / TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS), - ); - const averageSliceNotional = twapSize - .multipliedBy(midPriceBN) - .dividedBy(estimatedSlices); + const rawTriggerPrice = formDataSnapshot.twapTriggerPrice?.trim(); + const triggerPrice = formatTwapPriceForOrder({ + price: rawTriggerPrice, + szDecimals, + assetType: isSpotOrder ? 'spot' : 'perp', + }); + if (rawTriggerPrice) { + if (!triggerPrice) { + Toast.error({ + title: intl.formatMessage({ id: ETranslations.global_failed }), + message: intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + }); + return; + } + const triggerAbove = getTwapTriggerAbove({ + triggerPrice, + markPrice: twapReferencePriceBN, + }); + if (typeof triggerAbove !== 'boolean') { + const triggerPriceBN = new BigNumber(triggerPrice); + Toast.error({ + title: intl.formatMessage({ id: ETranslations.global_failed }), + message: + triggerPriceBN.isFinite() && + triggerPriceBN.eq(twapReferencePriceBN) + ? intl.formatMessage({ + id: ETranslations.perps_trigger_price_equal_current, + }) + : intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + }); + return; + } + } + const rawStopPrice = formDataSnapshot.twapStopPrice?.trim(); + const stopPrice = formatTwapPriceForOrder({ + price: rawStopPrice, + szDecimals, + assetType: isSpotOrder ? 'spot' : 'perp', + }); + if (rawStopPrice) { + if ( + !stopPrice || + !isTwapStopPriceValid({ + isBuy: side === 'long', + stopPrice, + referencePrice: twapReferencePriceBN, + triggerPrice, + }) + ) { + Toast.error({ + title: intl.formatMessage({ id: ETranslations.global_failed }), + message: `${intl.formatMessage({ + id: + side === 'long' + ? ETranslations.perp_scale_upper_price_label__title + : ETranslations.perp_scale_lower_price_label__title, + })} ${side === 'long' ? '>' : '<'} ${intl.formatMessage({ + id: ETranslations.perp_market_price, + })} / ${intl.formatMessage({ + id: ETranslations.dexmarket_pro_trigger_price, + })}`, + }); + return; + } + } if ( - !averageSliceNotional.isFinite() || - averageSliceNotional.lt(TWAP_MIN_ORDER_NOTIONAL) + !isTwapTotalNotionalValid({ + size: twapSize, + price: twapReferencePriceBN, + }) ) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: intl.formatMessage({ - id: ETranslations.perp_twap_small_slice__msg, + id: ETranslations.perp_scale_order_size_too_small__msg, }), }); return; @@ -379,7 +459,7 @@ function useOrderConfirmWithMarketDataFreshness({ }); if (snapshotError) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: snapshotError, }); return; @@ -401,7 +481,7 @@ function useOrderConfirmWithMarketDataFreshness({ }); if (reduceOnlyError) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: reduceOnlyError, }); return; @@ -414,6 +494,8 @@ function useOrderConfirmWithMarketDataFreshness({ price: '', bboPriceMode: null, hasTpsl: false, + twapTriggerPrice: triggerPrice ?? '', + twapStopPrice: stopPrice ?? '', twapReduceOnly: isSpotOrder ? false : formDataSnapshot.twapReduceOnly, }; @@ -422,7 +504,7 @@ function useOrderConfirmWithMarketDataFreshness({ await hyperliquidActions.current.submitOrder({ assetId: activeTradeInstrument.assetId, formData: effectiveFormData, - price: midPrice || '0', + price: twapReferencePriceBN.toFixed(), }); options?.onSuccess?.(); } catch (error) { @@ -527,6 +609,7 @@ function useOrderConfirmWithMarketDataFreshness({ shortOrderPrice, intl, shouldBlockForMarketData, + twapReferencePriceBN, ], ); diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts new file mode 100644 index 000000000000..d6a32b15452f --- /dev/null +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts @@ -0,0 +1,86 @@ +/* eslint-disable import/first */ + +import { renderHook } from '@testing-library/react-native'; +import { BigNumber } from 'bignumber.js'; + +import { EPerpsSizeInputMode } from '@onekeyhq/shared/types/hyperliquid/types'; + +import { useTradingCalculationsForSide } from './useTradingCalculationsForSide'; + +const mockFormData = { + orderMode: 'twap' as const, + sizeInputMode: EPerpsSizeInputMode.MANUAL, + size: '2', + sizePercent: 0, + scaleReduceOnly: false, + twapReduceOnly: false, +}; +let mockActiveAssetCtx: { ctx: { markPrice: string } } | undefined = { + ctx: { markPrice: '100' }, +}; +let mockActiveAssetData: + | { leverage: { value: number }; markPx: string } + | undefined; + +jest.mock('@onekeyhq/kit/src/states/jotai/contexts/hyperliquid', () => ({ + useActiveTradeInstrumentAtom: () => [{ coin: 'ETH', mode: 'perp' }], + useTradingFormCalculationParams: () => mockFormData, +})); + +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ + usePerpsActiveAssetAtom: () => [ + { + coin: 'ETH', + universe: { maxLeverage: 20, szDecimals: 4 }, + }, + ], + usePerpsActiveAssetCtxAtom: () => [mockActiveAssetCtx], + usePerpsActiveAssetDataAtom: () => [mockActiveAssetData], + useSpotBalancesAtom: () => [{ balances: [] }], +})); + +jest.mock('./useOrderPrice', () => ({ + useOrderPrice: () => ({ price: new BigNumber(90), error: undefined }), +})); + +jest.mock('./usePerpsAccountScopedActivePositions', () => ({ + usePerpsAccountScopedActivePositions: () => [], +})); + +jest.mock('./useTradingPrice', () => ({ + useTradingPrice: () => ({ midPriceBN: new BigNumber(95) }), +})); + +describe('useTradingCalculationsForSide', () => { + beforeEach(() => { + mockActiveAssetCtx = { ctx: { markPrice: '100' } }; + mockActiveAssetData = undefined; + }); + + it('uses market-wide mark price for TWAP while account data is loading', () => { + const { result } = renderHook(() => useTradingCalculationsForSide('long')); + + expect(result.current.computedSizeForSide.toFixed()).toBe('2'); + expect(result.current.orderValue.toFixed()).toBe('200'); + }); + + it('uses the account mark for all TWAP calculations while market context is loading', () => { + mockActiveAssetCtx = undefined; + mockActiveAssetData = { leverage: { value: 2 }, markPx: '100' }; + + const { result } = renderHook(() => useTradingCalculationsForSide('long')); + + expect(result.current.computedSizeForSide.toFixed()).toBe('2'); + expect(result.current.orderValue.toFixed()).toBe('200'); + expect(result.current.marginRequired.toFixed()).toBe('100'); + }); + + it('does not report insufficient margin while account data is still loading', () => { + mockActiveAssetCtx = { ctx: { markPrice: '100' } }; + mockActiveAssetData = undefined; + + const { result } = renderHook(() => useTradingCalculationsForSide('long')); + + expect(result.current.isNoEnoughMargin).toBe(false); + }); +}); diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts index 4941e0b51bd7..a3b08a6c91aa 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts @@ -8,6 +8,7 @@ import { } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; import { usePerpsActiveAssetAtom, + usePerpsActiveAssetCtxAtom, usePerpsActiveAssetDataAtom, useSpotBalancesAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; @@ -27,6 +28,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { const formData = useTradingFormCalculationParams(); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const [activeAsset] = usePerpsActiveAssetAtom(); + const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [activeAssetData] = usePerpsActiveAssetDataAtom(); const [{ balances: spotBalances }] = useSpotBalancesAtom(); const perpsPositions = usePerpsAccountScopedActivePositions(); @@ -168,11 +170,30 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { }, [effectiveMaxTradeSzs, side]); const markPxBN = useMemo(() => { - const markPx = isSpot - ? effectiveSpotPriceBN.toFixed() - : activeAssetData?.markPx; + let markPx = activeAssetData?.markPx; + if (isSpot) { + markPx = effectiveSpotPriceBN.toFixed(); + } else if (formData.orderMode === 'twap') { + markPx = activeAssetCtx?.ctx?.markPrice ?? activeAssetData?.markPx; + } return new BigNumber(markPx ?? 0); - }, [activeAssetData?.markPx, effectiveSpotPriceBN, isSpot]); + }, [ + activeAssetCtx?.ctx?.markPrice, + activeAssetData?.markPx, + effectiveSpotPriceBN, + formData.orderMode, + isSpot, + ]); + + const calculationMarkPrice = + formData.orderMode === 'twap' && markPxBN.gt(0) + ? markPxBN.toFixed() + : activeAssetData?.markPx; + + const calculationPriceBN = useMemo( + () => (formData.orderMode === 'twap' ? markPxBN : effectivePriceBN), + [effectivePriceBN, formData.orderMode, markPxBN], + ); const availableMarginBN = useMemo(() => { if (isSpot) { @@ -247,8 +268,8 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { } return computeMaxTradeSize({ side, - price: effectivePriceBN.isFinite() ? effectivePriceBN.toFixed() : '', - markPrice: activeAssetData?.markPx, + price: calculationPriceBN.isFinite() ? calculationPriceBN.toFixed() : '', + markPrice: calculationMarkPrice, maxSize: scaleReduceOnlyMaxSizeBN, maxTradeSzs: effectiveMaxTradeSzs, leverageValue: activeAssetData?.leverage?.value, @@ -257,8 +278,8 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { }); }, [ side, - effectivePriceBN, - activeAssetData?.markPx, + calculationPriceBN, + calculationMarkPrice, scaleReduceOnlyMaxSizeBN, effectiveMaxTradeSzs, activeAssetData?.leverage?.value, @@ -298,8 +319,8 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { manualSize: formData.size, sizePercent: formData.sizePercent, side, - price: effectivePriceBN.isFinite() ? effectivePriceBN.toFixed() : '', - markPrice: activeAssetData?.markPx, + price: calculationPriceBN.isFinite() ? calculationPriceBN.toFixed() : '', + markPrice: calculationMarkPrice, maxSize: scaleReduceOnlyMaxSizeBN, maxTradeSzs: effectiveMaxTradeSzs, leverageValue: activeAssetData?.leverage?.value, @@ -311,8 +332,8 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { formData.size, formData.sizePercent, side, - effectivePriceBN, - activeAssetData?.markPx, + calculationPriceBN, + calculationMarkPrice, scaleReduceOnlyMaxSizeBN, effectiveMaxTradeSzs, activeAssetData?.leverage?.value, @@ -324,8 +345,8 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { ]); const orderValue = useMemo( - () => computedSizeForSide.multipliedBy(effectivePriceBN), - [computedSizeForSide, effectivePriceBN], + () => computedSizeForSide.multipliedBy(calculationPriceBN), + [calculationPriceBN, computedSizeForSide], ); const marginRequired = useMemo( @@ -371,6 +392,12 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { return false; } + // Margin conclusions need the account feed: in TWAP mode markPxBN can come + // from the market-wide ctx feed before activeAssetData has (re)loaded. + if (!activeAssetData) { + return false; + } + // No margin for this side (guard on markPxBN to skip initial loading) if (markPxBN.gt(0) && availableMarginBN.lte(0)) { return true; @@ -391,19 +418,21 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { if ( !computedSizeForSide.isFinite() || computedSizeForSide.lte(0) || - !effectivePriceBN.isFinite() || - effectivePriceBN.lte(0) + !calculationPriceBN.isFinite() || + calculationPriceBN.lte(0) ) { return false; } const requiredMargin = computedSizeForSide - .multipliedBy(effectivePriceBN) + .multipliedBy(calculationPriceBN) .dividedBy(leverage || 1); return requiredMargin.isFinite() && requiredMargin.gt(availableMarginBN); }, [ + activeAssetData, computedSizeForSide, + calculationPriceBN, effectivePriceBN, availableMarginBN, leverage, diff --git a/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.test.ts b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.test.ts new file mode 100644 index 000000000000..fc658fbe94c9 --- /dev/null +++ b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.test.ts @@ -0,0 +1,71 @@ +/* eslint-disable import/first */ + +import { renderHook } from '@testing-library/react-native'; +import { BigNumber } from 'bignumber.js'; + +import { useTwapReferencePrice } from './useTwapReferencePrice'; + +let mockInstrumentMode: 'perp' | 'spot' = 'perp'; +let mockCtxMarkPrice: string | undefined = '100'; +let mockActiveAssetData: { markPx: string } | undefined; + +jest.mock('@onekeyhq/kit/src/states/jotai/contexts/hyperliquid', () => ({ + useActiveTradeInstrumentAtom: () => [ + { coin: 'ETH', mode: mockInstrumentMode }, + ], +})); + +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ + usePerpsActiveAssetCtxMarkPriceAtom: () => [mockCtxMarkPrice], + usePerpsActiveAssetDataAtom: () => [mockActiveAssetData], +})); + +describe('useTwapReferencePrice', () => { + beforeEach(() => { + mockInstrumentMode = 'perp'; + mockCtxMarkPrice = '100'; + mockActiveAssetData = undefined; + }); + + it('uses the market-wide mark price when available', () => { + mockActiveAssetData = { markPx: '99' }; + + const { result } = renderHook(() => + useTwapReferencePrice({ midPriceBN: new BigNumber(95) }), + ); + + expect(result.current.toFixed()).toBe('100'); + }); + + it('falls back to the account mark while market context is loading', () => { + mockCtxMarkPrice = undefined; + mockActiveAssetData = { markPx: '99' }; + + const { result } = renderHook(() => + useTwapReferencePrice({ midPriceBN: new BigNumber(95) }), + ); + + expect(result.current.toFixed()).toBe('99'); + }); + + it('is not finite while both mark price feeds are loading', () => { + mockCtxMarkPrice = undefined; + mockActiveAssetData = undefined; + + const { result } = renderHook(() => + useTwapReferencePrice({ midPriceBN: new BigNumber(95) }), + ); + + expect(result.current.isFinite()).toBe(false); + }); + + it('uses the mid price for spot', () => { + mockInstrumentMode = 'spot'; + + const { result } = renderHook(() => + useTwapReferencePrice({ midPriceBN: new BigNumber(95) }), + ); + + expect(result.current.toFixed()).toBe('95'); + }); +}); diff --git a/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts new file mode 100644 index 000000000000..9e52ff25b318 --- /dev/null +++ b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; + +import { useActiveTradeInstrumentAtom } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; +import { + usePerpsActiveAssetCtxMarkPriceAtom, + usePerpsActiveAssetDataAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import { getTwapTriggerReferencePrice } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; + +import type { BigNumber } from 'bignumber.js'; + +// Single source so form, CTA validation, and submit agree on the TWAP +// reference price even while activeAssetCtx lags on cold start/reconnect. +// midPriceBN is caller-supplied to avoid adding a live mid subscription. +export function useTwapReferencePrice({ + midPriceBN, +}: { + midPriceBN: BigNumber; +}): BigNumber { + const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); + const [ctxMarkPrice] = usePerpsActiveAssetCtxMarkPriceAtom(); + const [activeAssetData] = usePerpsActiveAssetDataAtom(); + const isSpot = activeTradeInstrument.mode === 'spot'; + const markPrice = ctxMarkPrice ?? activeAssetData?.markPx; + return useMemo( + () => + getTwapTriggerReferencePrice({ + isSpot, + midPrice: midPriceBN, + markPrice, + }), + [isSpot, markPrice, midPriceBN], + ); +} diff --git a/packages/shared/src/utils/hyperliquidPortfolioUtils.test.ts b/packages/shared/src/utils/hyperliquidPortfolioUtils.test.ts index bacfac9ec8fa..db3d49d7c1a4 100644 --- a/packages/shared/src/utils/hyperliquidPortfolioUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidPortfolioUtils.test.ts @@ -21,6 +21,7 @@ import type { IHyperliquidPerpPositionSnapshot, IHyperliquidSpotBalanceSnapshot, } from '../../types/hyperliquid/portfolio'; +import type { ISpotClearinghouseStateResponse } from '../../types/hyperliquid/sdk'; const meta = { universe: [ @@ -224,6 +225,11 @@ describe('spotHasPositiveBalance', () => { ], } as any), ).toBe(false); + expect( + spotHasPositiveBalance({ + balances: [{ coin: '+1', total: '1', hold: '0', entryNtl: '1' }], + }), + ).toBe(true); expect(spotHasPositiveBalance({ balances: [] } as any)).toBe(false); }); }); @@ -664,6 +670,24 @@ describe('assembleHyperliquidSnapshot', () => { snap.spotBalances.find((b) => b.coin === 'HYPE')?.spotUniverseName, ).toBe('HYPE/USDC'); }); + it('omits outcome balances that have no spot token id', () => { + const snap = assembleHyperliquidSnapshot({ + address: '0x1', + clearinghouse: clearing as any, + spot: { + balances: [ + { coin: 'USDC', token: 0, total: '10', hold: '0', entryNtl: '10' }, + { coin: '+1', total: '5', hold: '0', entryNtl: '5' }, + ], + } satisfies ISpotClearinghouseStateResponse, + priceMap: {}, + now: 1, + }); + + expect(snap.spotBalances.map((balance) => balance.coin)).toEqual(['USDC']); + expect(snap.spotTotalUsd).toBe('10'); + expect(snap.isDegraded).toBe(true); + }); it('uses spot-side account value and withdrawable for unified accounts', () => { const snap = assembleHyperliquidSnapshot({ address: '0x1', diff --git a/packages/shared/src/utils/hyperliquidPortfolioUtils.ts b/packages/shared/src/utils/hyperliquidPortfolioUtils.ts index 7ce6fa6cb652..1c0081f5ad19 100644 --- a/packages/shared/src/utils/hyperliquidPortfolioUtils.ts +++ b/packages/shared/src/utils/hyperliquidPortfolioUtils.ts @@ -18,6 +18,7 @@ import type { } from '../../types/hyperliquid/portfolio'; import type { IClearinghouseStateResponse, + ISpotBalance, ISpotClearinghouseStateResponse, ISpotMetaAndAssetCtxsResponse, } from '../../types/hyperliquid/sdk'; @@ -32,7 +33,13 @@ const CLEARINGHOUSE_SUMMARY_FIELDS = [ type IClearinghouseSummary = IClearinghouseStateResponse['marginSummary']; type IPerpAssetPosition = IClearinghouseStateResponse['assetPositions'][number]; -type ISpotBalance = ISpotClearinghouseStateResponse['balances'][number]; +type IRawSpotBalance = ISpotClearinghouseStateResponse['balances'][number]; + +function isSupportedSpotBalance( + balance: IRawSpotBalance, +): balance is ISpotBalance { + return 'token' in balance; +} export interface IAggregateClearinghouseStateInput { dex?: string; @@ -178,6 +185,7 @@ export function spotNeedsPrices( return Boolean( spot?.balances?.some( (b) => + isSupportedSpotBalance(b) && b.token !== 0 && !isHyperliquidSpotStableCoin(b.coin) && safeBN(b.total).gt(0), @@ -348,7 +356,16 @@ export function assembleHyperliquidSnapshot(args: { const getMarkPrice = (coin: string) => getSpotMarkPrice?.(coin) ?? priceMap[coin]; const isUnified = isUnifiedPortfolioMode(args.abstractionMode); - const rawSpotBalances = spot?.balances ?? []; + const allSpotBalances = spot?.balances ?? []; + const rawSpotBalances = allSpotBalances.filter(isSupportedSpotBalance); + if ( + allSpotBalances.some( + (balance) => + !isSupportedSpotBalance(balance) && safeBN(balance.total).gt(0), + ) + ) { + degraded = true; + } // Keep raw spot balances for totals; the merged USDC row is display-only // because non-unified clearinghouse accountValue already includes perps USDC. const displaySpotBalances = isUnified diff --git a/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts b/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts new file mode 100644 index 000000000000..508bdfe9598e --- /dev/null +++ b/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts @@ -0,0 +1,68 @@ +import { execFileSync } from 'node:child_process'; + +describe('Hyperliquid TWAP SDK patch', () => { + it('accepts 7-day TWAP details without stripping trigger and stop prices', () => { + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import * as v from './node_modules/@nktkas/hyperliquid/node_modules/valibot/dist/index.mjs'; + import { TwapOrderRequest } from '@nktkas/hyperliquid/api/exchange'; + const result = v.safeParse(TwapOrderRequest, { + action: { + type: 'twapOrder', + twap: { a: 0, b: true, s: '1', r: false, m: 10080, t: true }, + details: { t: { p: '100', a: true }, s: '110' }, + }, + nonce: 1, + signature: { + r: \`0x\${'0'.repeat(64)}\`, + s: \`0x\${'0'.repeat(64)}\`, + v: 27, + }, + }); + process.stdout.write(JSON.stringify({ + success: result.success, + details: result.success ? result.output.action.details : undefined, + })); + `, + ], + { cwd: process.cwd(), encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + success: true, + details: { + t: { p: '100', a: true }, + s: '110', + }, + }); + }); + + it('retains the transport hooks used by subscription reconciliation', () => { + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import { WebSocketTransport } from '@nktkas/hyperliquid'; + const transport = new WebSocketTransport({ url: 'ws://127.0.0.1:1' }); + process.stdout.write(JSON.stringify({ + dispatcher: typeof transport._dispatcher?.request, + events: typeof transport._hlEvents?.addEventListener, + })); + transport.close(); + `, + ], + { cwd: process.cwd(), encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + dispatcher: 'function', + events: 'function', + }); + }); +}); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts new file mode 100644 index 000000000000..e3d81ee5b842 --- /dev/null +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -0,0 +1,292 @@ +import { + TWAP_MAX_DURATION_MINUTES, + TWAP_MIN_DURATION_MINUTES, + TWAP_MIN_ORDER_NOTIONAL, + buildActiveTwapRuntimeInfoByKey, + formatTwapPriceForDisplay, + formatTwapPriceForOrder, + getActiveTwapRuntimeStatus, + getTwapElapsedMs, + getTwapTriggerAbove, + getTwapTriggerReferencePrice, + isTwapStopPriceValid, + isTwapTotalNotionalValid, + isValidTwapDuration, +} from './hyperliquidTwapUtils'; + +describe('hyperliquidTwapUtils', () => { + it('accepts integer durations from 5 minutes through 7 days', () => { + expect(TWAP_MIN_DURATION_MINUTES).toBe(5); + expect(TWAP_MAX_DURATION_MINUTES).toBe(10_080); + expect(isValidTwapDuration(5)).toBe(true); + expect(isValidTwapDuration(10_080)).toBe(true); + expect(isValidTwapDuration(4)).toBe(false); + expect(isValidTwapDuration(10_081)).toBe(false); + expect(isValidTwapDuration(5.5)).toBe(false); + }); + + it('validates the total order notional instead of estimated slices', () => { + expect(TWAP_MIN_ORDER_NOTIONAL).toBe(100); + expect(isTwapTotalNotionalValid({ size: '0.01', price: '10000' })).toBe( + true, + ); + expect(isTwapTotalNotionalValid({ size: '0.009999', price: '10000' })).toBe( + false, + ); + expect(isTwapTotalNotionalValid({ size: 'invalid', price: '10000' })).toBe( + false, + ); + }); + + it('derives whether the trigger is above the current mark price', () => { + expect(getTwapTriggerAbove({ triggerPrice: '101', markPrice: '100' })).toBe( + true, + ); + expect(getTwapTriggerAbove({ triggerPrice: '99', markPrice: '100' })).toBe( + false, + ); + expect( + getTwapTriggerAbove({ triggerPrice: '100', markPrice: '100' }), + ).toBeUndefined(); + expect( + getTwapTriggerAbove({ triggerPrice: 'invalid', markPrice: '100' }), + ).toBeUndefined(); + }); + + it('uses mark price for perp triggers and mid price for spot triggers', () => { + expect( + getTwapTriggerReferencePrice({ + isSpot: false, + midPrice: '100', + markPrice: '102', + }).toFixed(), + ).toBe('102'); + expect( + getTwapTriggerReferencePrice({ + isSpot: true, + midPrice: '100', + markPrice: '102', + }).toFixed(), + ).toBe('100'); + }); + + it('does not infer a perp trigger direction without a mark price', () => { + expect( + getTwapTriggerReferencePrice({ + isSpot: false, + midPrice: '100', + }).isFinite(), + ).toBe(false); + }); + + it('keeps stop prices beyond the activation boundary', () => { + expect( + isTwapStopPriceValid({ + isBuy: true, + stopPrice: '101', + referencePrice: '100', + }), + ).toBe(true); + expect( + isTwapStopPriceValid({ + isBuy: false, + stopPrice: '99', + referencePrice: '100', + }), + ).toBe(true); + expect( + isTwapStopPriceValid({ + isBuy: true, + stopPrice: '111', + referencePrice: '100', + triggerPrice: '110', + }), + ).toBe(true); + expect( + isTwapStopPriceValid({ + isBuy: true, + stopPrice: '105', + referencePrice: '100', + triggerPrice: '110', + }), + ).toBe(false); + expect( + isTwapStopPriceValid({ + isBuy: false, + stopPrice: '89', + referencePrice: '100', + triggerPrice: '90', + }), + ).toBe(true); + expect( + isTwapStopPriceValid({ + isBuy: false, + stopPrice: '95', + referencePrice: '100', + triggerPrice: '90', + }), + ).toBe(false); + expect( + isTwapStopPriceValid({ + isBuy: true, + stopPrice: '101', + referencePrice: '100', + triggerPrice: 0, + }), + ).toBe(false); + }); + + it('uses the trigger price as the stop boundary after activation', () => { + expect( + isTwapStopPriceValid({ + isBuy: true, + stopPrice: '95', + referencePrice: '100', + triggerPrice: '90', + }), + ).toBe(true); + expect( + isTwapStopPriceValid({ + isBuy: false, + stopPrice: '105', + referencePrice: '100', + triggerPrice: '110', + }), + ).toBe(true); + }); + + it('preserves the wire precision of TWAP prices for display', () => { + expect(formatTwapPriceForDisplay('0.000012345')).toBe('0.000012345'); + expect(formatTwapPriceForDisplay('12345.678')).toBe('12,345.678'); + expect(formatTwapPriceForDisplay('invalid')).toBe('--'); + }); + + it('uses Hyperliquid wire precision before validating TWAP boundaries', () => { + expect( + formatTwapPriceForOrder({ + price: '123450.5', + szDecimals: 5, + assetType: 'perp', + }), + ).toBe('123450'); + expect( + formatTwapPriceForOrder({ + price: '0.123456', + szDecimals: 2, + assetType: 'spot', + }), + ).toBe('0.12345'); + }); + + it('does not advance running time while waiting for a trigger', () => { + const timestamp = 1000; + expect( + getTwapElapsedMs({ + status: 'waitingForTrigger', + timestamp, + now: 61_000, + minutes: 10, + }), + ).toBe(0); + expect( + getTwapElapsedMs({ + status: 'activated', + timestamp, + now: 61_000, + minutes: 10, + }), + ).toBe(60_000); + expect( + getTwapElapsedMs({ + status: 'activated', + timestamp, + activatedAt: 31_000, + now: 61_000, + minutes: 10, + }), + ).toBe(30_000); + expect( + getTwapElapsedMs({ + status: 'finished', + timestamp, + now: 601_000, + endTime: 121_000, + minutes: 10, + }), + ).toBe(120_000); + }); + + it('keeps a triggered TWAP pending until history reports activation', () => { + expect( + getActiveTwapRuntimeStatus({ + triggerPrice: '101', + executedSize: '0', + }), + ).toBe('waitingForTrigger'); + expect( + getActiveTwapRuntimeStatus({ + reportedStatus: 'activated', + triggerPrice: '101', + executedSize: '0', + }), + ).toBe('activated'); + expect( + getActiveTwapRuntimeStatus({ + reportedStatus: 'waitingForTrigger', + triggerPrice: '101', + executedSize: '0.01', + }), + ).toBe('activated'); + expect( + getActiveTwapRuntimeStatus({ + triggerPrice: null, + executedSize: '0', + }), + ).toBe('activated'); + }); + + it('keeps the latest reported status and activation time for each TWAP', () => { + expect( + buildActiveTwapRuntimeInfoByKey?.([ + { + time: 1_718_000_000, + state: { coin: 'ETH', timestamp: 1_717_999_900_000 }, + status: { status: 'waitingForTrigger' }, + }, + { + time: 1_718_000_120, + state: { coin: 'ETH', timestamp: 1_717_999_900_000 }, + status: { status: 'activated' }, + }, + ]).get('ETH:1717999900000'), + ).toEqual({ + reportedStatus: 'activated', + activatedAt: 1_718_000_120_000, + }); + }); + + it('correlates runtime status when history omits twapId', () => { + const records = [ + { + time: 1_718_000_120, + state: { + coin: 'ETH', + timestamp: 1_718_000_000_000, + }, + status: { status: 'activated' as const }, + }, + ]; + + expect( + Array.from(buildActiveTwapRuntimeInfoByKey(records).entries()), + ).toEqual([ + [ + 'ETH:1718000000000', + { + reportedStatus: 'activated', + activatedAt: 1_718_000_120_000, + }, + ], + ]); + }); +}); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts new file mode 100644 index 000000000000..62a51b0ca11c --- /dev/null +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -0,0 +1,233 @@ +import BigNumber from 'bignumber.js'; + +import { formatLocalizedNumberString } from './numberUtils'; +import { formatHlPrice } from './perpsUtils'; + +export const TWAP_MIN_DURATION_MINUTES = 5; +export const TWAP_MAX_DURATION_MINUTES = 7 * 24 * 60; +export const TWAP_MIN_ORDER_NOTIONAL = 100; + +export type ITwapRuntimeStatus = + | 'activated' + | 'error' + | 'finished' + | 'stopped' + | 'terminated' + | 'waitingForTrigger'; + +export type IActiveTwapRuntimeInfo = { + reportedStatus: 'activated' | 'waitingForTrigger'; + activatedAt?: number; +}; + +export function getTwapRuntimeInfoKey(state: { + coin: string; + timestamp: number; +}): string { + return `${state.coin}:${state.timestamp}`; +} + +function normalizeTwapHistoryTimeMs(time: number): number { + return time > 1_000_000_000_000 ? time : time * 1000; +} + +export function buildActiveTwapRuntimeInfoByKey( + records: readonly { + time: number; + state: { + coin: string; + timestamp: number; + }; + status: { status: ITwapRuntimeStatus }; + }[], +): Map { + const latestRecordByKey = new Map(); + records.forEach((record) => { + const key = getTwapRuntimeInfoKey(record.state); + const previous = latestRecordByKey.get(key); + if (!previous || record.time > previous.time) { + latestRecordByKey.set(key, record); + } + }); + return new Map( + Array.from(latestRecordByKey.entries()).map(([key, record]) => [ + key, + { + reportedStatus: + record.status.status === 'waitingForTrigger' + ? 'waitingForTrigger' + : 'activated', + activatedAt: + record.status.status === 'activated' + ? normalizeTwapHistoryTimeMs(record.time) + : undefined, + }, + ]), + ); +} + +export function getActiveTwapRuntimeStatus({ + reportedStatus, + triggerPrice, + executedSize, +}: { + reportedStatus?: 'activated' | 'waitingForTrigger'; + triggerPrice?: string | null; + executedSize: BigNumber.Value; +}): 'activated' | 'waitingForTrigger' { + const executedSizeBN = new BigNumber(executedSize); + if (executedSizeBN.isFinite() && executedSizeBN.gt(0)) { + return 'activated'; + } + if (reportedStatus) { + return reportedStatus; + } + return triggerPrice && executedSizeBN.isFinite() && executedSizeBN.isZero() + ? 'waitingForTrigger' + : 'activated'; +} + +export function getTwapTriggerReferencePrice({ + isSpot, + midPrice, + markPrice, +}: { + isSpot: boolean; + midPrice: BigNumber.Value; + markPrice?: BigNumber.Value; +}): BigNumber { + if (isSpot) { + return new BigNumber(midPrice); + } + return new BigNumber(markPrice ?? ''); +} + +export function formatTwapPriceForDisplay(price?: string | null): string { + const priceBN = new BigNumber(price ?? ''); + if (!priceBN.isFinite() || priceBN.lte(0)) { + return '--'; + } + return formatLocalizedNumberString(priceBN.toFixed()); +} + +export function formatTwapPriceForOrder({ + price, + szDecimals, + assetType, +}: { + price?: string; + szDecimals: number; + assetType: 'perp' | 'spot'; +}): string | undefined { + const trimmedPrice = price?.trim(); + return trimmedPrice + ? formatHlPrice(trimmedPrice, szDecimals, assetType) || undefined + : undefined; +} + +export function isValidTwapDuration(minutes: number): boolean { + return ( + Number.isInteger(minutes) && + minutes >= TWAP_MIN_DURATION_MINUTES && + minutes <= TWAP_MAX_DURATION_MINUTES + ); +} + +export function isTwapTotalNotionalValid({ + size, + price, +}: { + size: BigNumber.Value; + price: BigNumber.Value; +}): boolean { + const sizeBN = new BigNumber(size); + const priceBN = new BigNumber(price); + if ( + !sizeBN.isFinite() || + !priceBN.isFinite() || + sizeBN.lte(0) || + priceBN.lte(0) + ) { + return false; + } + return sizeBN.multipliedBy(priceBN).gte(TWAP_MIN_ORDER_NOTIONAL); +} + +export function getTwapTriggerAbove({ + triggerPrice, + markPrice, +}: { + triggerPrice: BigNumber.Value; + markPrice: BigNumber.Value; +}): boolean | undefined { + const triggerPriceBN = new BigNumber(triggerPrice); + const markPriceBN = new BigNumber(markPrice); + if ( + !triggerPriceBN.isFinite() || + !markPriceBN.isFinite() || + triggerPriceBN.lte(0) || + markPriceBN.lte(0) || + triggerPriceBN.eq(markPriceBN) + ) { + return undefined; + } + return triggerPriceBN.gt(markPriceBN); +} + +export function isTwapStopPriceValid({ + isBuy, + stopPrice, + referencePrice, + triggerPrice, +}: { + isBuy: boolean; + stopPrice: BigNumber.Value; + referencePrice: BigNumber.Value; + triggerPrice?: BigNumber.Value; +}): boolean { + const stopPriceBN = new BigNumber(stopPrice); + const referencePriceBN = new BigNumber(referencePrice); + const triggerPriceBN = + triggerPrice === undefined || triggerPrice === '' + ? referencePriceBN + : new BigNumber(triggerPrice); + if ( + !stopPriceBN.isFinite() || + !referencePriceBN.isFinite() || + !triggerPriceBN.isFinite() || + stopPriceBN.lte(0) || + referencePriceBN.lte(0) || + triggerPriceBN.lte(0) + ) { + return false; + } + const activationBoundary = triggerPriceBN; + return isBuy + ? stopPriceBN.gt(activationBoundary) + : stopPriceBN.lt(activationBoundary); +} + +export function getTwapElapsedMs({ + status, + timestamp, + activatedAt, + now, + endTime, + minutes, +}: { + status?: ITwapRuntimeStatus; + timestamp: number; + activatedAt?: number; + now: number; + endTime?: number; + minutes: number; +}): number { + if (status === 'waitingForTrigger') { + return 0; + } + const totalMs = Math.max(0, minutes) * 60_000; + return Math.min( + Math.max((endTime ?? now) - (activatedAt ?? timestamp), 0), + totalMs, + ); +} diff --git a/packages/shared/types/hyperliquid/sdk.ts b/packages/shared/types/hyperliquid/sdk.ts index 4a1ee841fbd7..8306f3e24c16 100644 --- a/packages/shared/types/hyperliquid/sdk.ts +++ b/packages/shared/types/hyperliquid/sdk.ts @@ -4,7 +4,7 @@ import type { ESubscriptionType, IPerpsFormattedAssetCtx } from './types'; import type * as HL from '@nktkas/hyperliquid'; // WebSocket event types -export type IWsWebData2 = HL.WebData2WsEvent; +export type IWsWebData2 = HL.WebData2Response; export type IWsWebData3 = HL.WebData3WsEvent; export type IWsAllMids = HL.AllMidsWsEvent; export type IWsActiveAssetCtx = HL.ActiveAssetCtxWsEvent; @@ -27,7 +27,10 @@ export type ITwapSliceFill = HL.UserTwapSliceFillsResponse[number]; export type IWsSpotState = HL.SpotStateWsEvent; export type IWsSpotAssetCtxs = HL.SpotAssetCtxsWsEvent; export type IWsActiveSpotAssetCtx = HL.ActiveSpotAssetCtxWsEvent; -export type ISpotBalance = IWsSpotState['spotState']['balances'][number]; +export type ISpotBalance = Extract< + IWsSpotState['spotState']['balances'][number], + { token: number } +>; export type IEventSpotStateParameters = HL.SpotStateWsParameters; export type IEventSpotAssetCtxsParameters = Record; export type IEventActiveSpotAssetCtxParameters = @@ -186,7 +189,7 @@ export type IEventFastL2Parameters = { m?: IEventL2BookParameters['mantissa']; }; export type IEventBboParameters = HL.BboWsParameters; -export type IEventWebData2Parameters = HL.WebData2WsParameters; +export type IEventWebData2Parameters = HL.WebData2Parameters; export type IEventUserFillsParameters = HL.UserFillsWsParameters; export type IEventUserNonFundingLedgerUpdatesParameters = HL.UserNonFundingLedgerUpdatesWsParameters; diff --git a/packages/shared/types/hyperliquid/types.ts b/packages/shared/types/hyperliquid/types.ts index 158e6c69381a..e8911c9ad823 100644 --- a/packages/shared/types/hyperliquid/types.ts +++ b/packages/shared/types/hyperliquid/types.ts @@ -264,6 +264,10 @@ export interface IPlaceTwapOrderParams { reduceOnly: boolean; minutes: number; randomize: boolean; + triggerPrice?: string; + triggerAbove?: boolean; + stopPrice?: string; + referencePrice: string; szDecimals?: number; } diff --git a/patches/@nktkas+hyperliquid+0.33.3.patch b/patches/@nktkas+hyperliquid+0.33.3.patch new file mode 100644 index 000000000000..ad7a517b7551 --- /dev/null +++ b/patches/@nktkas+hyperliquid+0.33.3.patch @@ -0,0 +1,36 @@ +diff --git a/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.d.ts b/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.d.ts +index 2bd4960..56e80e7 100644 +--- a/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.d.ts ++++ b/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.d.ts +@@ -19,7 +19,7 @@ export declare const TwapOrderRequest: v.ObjectSchema<{ + /** Whether the order is reduce-only. */ + readonly r: v.BooleanSchema; + /** TWAP duration in minutes. */ +- readonly m: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>, v.MinValueAction, v.MaxValueAction]>; ++ readonly m: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>, v.MinValueAction, v.MaxValueAction]>; + /** Enable random order timing. */ + readonly t: v.BooleanSchema; + }, undefined>; +@@ -96,7 +96,7 @@ declare const TwapOrderActionSchema: v.ObjectSchema<{ + /** Whether the order is reduce-only. */ + readonly r: v.BooleanSchema; + /** TWAP duration in minutes. */ +- readonly m: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>, v.MinValueAction, v.MaxValueAction]>; ++ readonly m: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>, v.MinValueAction, v.MaxValueAction]>; + /** Enable random order timing. */ + readonly t: v.BooleanSchema; + }, undefined>; +diff --git a/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js b/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js +index 1f6a653..0d22f71 100644 +--- a/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js ++++ b/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js +@@ -15,7 +15,8 @@ import { Address, Hex, UnsignedDecimal, UnsignedInteger } from "../../_schemas.j + /** Position side (`true` for long, `false` for short). */ b: v.boolean(), + /** Size (in base currency units). */ s: UnsignedDecimal, + /** Whether the order is reduce-only. */ r: v.boolean(), +- /** TWAP duration in minutes. */ m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(1440)), ++ // OneKey patch: Hyperliquid accepts seven-day TWAPs before the SDK has published the updated bound. ++ /** TWAP duration in minutes. */ m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(10080)), + /** Enable random order timing. */ t: v.boolean() + }), + /** Trigger and stop prices. */ details: v.optional(v.object({ diff --git a/patches/@nktkas+rews+2.0.2.patch b/patches/@nktkas+rews+2.0.2.patch deleted file mode 100644 index f655733ba533..000000000000 --- a/patches/@nktkas+rews+2.0.2.patch +++ /dev/null @@ -1,180 +0,0 @@ -diff --git a/node_modules/@nktkas/rews/esm/mod.js b/node_modules/@nktkas/rews/esm/mod.js -index 923f636..d345f6e 100644 ---- a/node_modules/@nktkas/rews/esm/mod.js -+++ b/node_modules/@nktkas/rews/esm/mod.js -@@ -168,11 +168,43 @@ export class ReconnectingWebSocket extends EventTarget { - }, { signal }); - this._socket.addEventListener("close", (e) => { - ac.abort(); -- this.dispatchEvent(new CloseEvent("close", { -- code: e.code, -- reason: e.reason, -- wasClean: e.wasClean, -- })); -+ // OneKey: prefer dispatching a real CloseEvent so consumers -+ // that check `event instanceof CloseEvent` keep working on -+ // web/desktop/Node. If that dispatch throws (e.g. on RN -+ // where polyfill ordering may make CloseEvent fail the -+ // wrapper EventTarget's `instanceof Event` check), fall back -+ // to a plain Event with the same expando fields so close -+ // still reaches consumers (WebSocketTransport, -+ // WebSocketSubscriptionManager, -+ // ServiceHyperliquidSubscription.socketCloseHandler). -+ // Outer try/catch is a last-resort guard: this listener is -+ // invoked from the underlying RN socket's dispatchEvent — -+ // any escaping throw would land in RN's RuntimeScheduler -+ // and become SIGABRT, defeating the entire patch. We log -+ // instead of silently no-op'ing so the pathological case is -+ // still visible in Sentry. -+ try { -+ try { -+ this.dispatchEvent(new CloseEvent("close", { -+ code: e.code, -+ reason: e.reason, -+ wasClean: e.wasClean, -+ })); -+ } -+ catch (_err) { -+ const fallback = new Event("close"); -+ fallback.code = e.code; -+ fallback.reason = e.reason; -+ fallback.wasClean = e.wasClean; -+ this.dispatchEvent(fallback); -+ } -+ } -+ catch (lastResortErr) { -+ try { -+ console.warn("[rews] outward close dispatch failed:", lastResortErr); -+ } -+ catch (_) { /* no-op */ } -+ } - resolve(); - }, { signal }); - }); -@@ -330,7 +362,16 @@ export class ReconnectingWebSocket extends EventTarget { - // removing all listeners before native events fire. - if (wasConnecting) { - // 1006 = Abnormal Closure (RFC 6455) — no close frame was received -- this._socket.dispatchEvent(new CloseEvent("close", { code: 1006, reason: "", wasClean: false })); -+ // OneKey: defensive try/catch — see explanation in the "close" listener above. -+ // The dispatched event is constructed via globalThis.CloseEvent polyfill (which -+ // uses globalThis.Event), but the underlying RN WebSocket extends RN's internal -+ // EventTarget whose dispatchEvent does instanceof against RN's internal Event -+ // class. Class identity mismatch would throw "parameter 1 is not of type 'Event'" -+ // and propagate as a fatal error via RuntimeScheduler → SIGABRT. -+ try { -+ this._socket.dispatchEvent(new CloseEvent("close", { code: 1006, reason: "", wasClean: false })); -+ } -+ catch (_err) { /* no-op: native close() above will eventually fire close event in RN */ } - } - } - /** -@@ -374,7 +415,17 @@ function createSocketWithTimeout(socketFactory, timeout) { - // because the internal close listener calls ac.abort(), - // removing all listeners before native events fire. - if (wasConnecting) { -- socket.dispatchEvent(new CloseEvent("close", { code: 3008, reason: "Timeout", wasClean: false })); -+ // OneKey: defensive try/catch — same root cause as the dispatchEvent calls -+ // above. This setTimeout fires after `timeout` ms (5s by default) when the -+ // underlying socket is still CONNECTING. On React Native, the throw from -+ // dispatchEvent would propagate out of this setTimeout callback into RN's -+ // RuntimeScheduler which hardcodes timer task errors as fatal → SIGABRT. -+ // Sentry issue REACT-NATIVE-4AX (357 events on so.onekey.wallet@6.2.0) is -+ // exactly this path firing during slow Hyperliquid WebSocket connects. -+ try { -+ socket.dispatchEvent(new CloseEvent("close", { code: 3008, reason: "Timeout", wasClean: false })); -+ } -+ catch (_err) { /* no-op: socket.close(3008) above already initiates teardown */ } - } - }, timeout); - const cleanup = () => clearTimeout(timer); -diff --git a/node_modules/@nktkas/rews/script/mod.js b/node_modules/@nktkas/rews/script/mod.js -index a3270c1..b1319a7 100644 ---- a/node_modules/@nktkas/rews/script/mod.js -+++ b/node_modules/@nktkas/rews/script/mod.js -@@ -172,11 +172,43 @@ class ReconnectingWebSocket extends EventTarget { - }, { signal }); - this._socket.addEventListener("close", (e) => { - ac.abort(); -- this.dispatchEvent(new CloseEvent("close", { -- code: e.code, -- reason: e.reason, -- wasClean: e.wasClean, -- })); -+ // OneKey: prefer dispatching a real CloseEvent so consumers -+ // that check `event instanceof CloseEvent` keep working on -+ // web/desktop/Node. If that dispatch throws (e.g. on RN -+ // where polyfill ordering may make CloseEvent fail the -+ // wrapper EventTarget's `instanceof Event` check), fall back -+ // to a plain Event with the same expando fields so close -+ // still reaches consumers (WebSocketTransport, -+ // WebSocketSubscriptionManager, -+ // ServiceHyperliquidSubscription.socketCloseHandler). -+ // Outer try/catch is a last-resort guard: this listener is -+ // invoked from the underlying RN socket's dispatchEvent — -+ // any escaping throw would land in RN's RuntimeScheduler -+ // and become SIGABRT, defeating the entire patch. We log -+ // instead of silently no-op'ing so the pathological case is -+ // still visible in Sentry. -+ try { -+ try { -+ this.dispatchEvent(new CloseEvent("close", { -+ code: e.code, -+ reason: e.reason, -+ wasClean: e.wasClean, -+ })); -+ } -+ catch (_err) { -+ const fallback = new Event("close"); -+ fallback.code = e.code; -+ fallback.reason = e.reason; -+ fallback.wasClean = e.wasClean; -+ this.dispatchEvent(fallback); -+ } -+ } -+ catch (lastResortErr) { -+ try { -+ console.warn("[rews] outward close dispatch failed:", lastResortErr); -+ } -+ catch (_) { /* no-op */ } -+ } - resolve(); - }, { signal }); - }); -@@ -334,7 +366,16 @@ class ReconnectingWebSocket extends EventTarget { - // removing all listeners before native events fire. - if (wasConnecting) { - // 1006 = Abnormal Closure (RFC 6455) — no close frame was received -- this._socket.dispatchEvent(new CloseEvent("close", { code: 1006, reason: "", wasClean: false })); -+ // OneKey: defensive try/catch — see explanation in the "close" listener above. -+ // The dispatched event is constructed via globalThis.CloseEvent polyfill (which -+ // uses globalThis.Event), but the underlying RN WebSocket extends RN's internal -+ // EventTarget whose dispatchEvent does instanceof against RN's internal Event -+ // class. Class identity mismatch would throw "parameter 1 is not of type 'Event'" -+ // and propagate as a fatal error via RuntimeScheduler → SIGABRT. -+ try { -+ this._socket.dispatchEvent(new CloseEvent("close", { code: 1006, reason: "", wasClean: false })); -+ } -+ catch (_err) { /* no-op: native close() above will eventually fire close event in RN */ } - } - } - /** -@@ -379,7 +420,17 @@ function createSocketWithTimeout(socketFactory, timeout) { - // because the internal close listener calls ac.abort(), - // removing all listeners before native events fire. - if (wasConnecting) { -- socket.dispatchEvent(new CloseEvent("close", { code: 3008, reason: "Timeout", wasClean: false })); -+ // OneKey: defensive try/catch — same root cause as the dispatchEvent calls -+ // above. This setTimeout fires after `timeout` ms (5s by default) when the -+ // underlying socket is still CONNECTING. On React Native, the throw from -+ // dispatchEvent would propagate out of this setTimeout callback into RN's -+ // RuntimeScheduler which hardcodes timer task errors as fatal → SIGABRT. -+ // Sentry issue REACT-NATIVE-4AX (357 events on so.onekey.wallet@6.2.0) is -+ // exactly this path firing during slow Hyperliquid WebSocket connects. -+ try { -+ socket.dispatchEvent(new CloseEvent("close", { code: 3008, reason: "Timeout", wasClean: false })); -+ } -+ catch (_err) { /* no-op: socket.close(3008) above already initiates teardown */ } - } - }, timeout); - const cleanup = () => clearTimeout(timer); diff --git a/yarn.lock b/yarn.lock index 88221c8ae93d..87fe8ab9aa84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8333,21 +8333,22 @@ __metadata: languageName: node linkType: hard -"@nktkas/hyperliquid@npm:0.32.2": - version: 0.32.2 - resolution: "@nktkas/hyperliquid@npm:0.32.2" +"@nktkas/hyperliquid@npm:0.33.3": + version: 0.33.3 + resolution: "@nktkas/hyperliquid@npm:0.33.3" dependencies: - "@nktkas/rews": "npm:^2" - "@noble/hashes": "npm:^2" - valibot: "npm:1.3.1" - checksum: 10/58ffc50d51aa5842285697c45b2c8bc80a7e0a610b82220902f8f7acee2e58c4099dcc195ce1456130869c3e33c93d6445f02833a59997e8b937398991d8829e + "@nktkas/rews": "npm:^4.1.0" + "@noble/hashes": "npm:^2.2.0" + decimal.js: "npm:^10.6.0" + valibot: "npm:^1.4.2" + checksum: 10/3797882abf9dc26093c37750f6927eba7b2f3cc6fdf1f95df611c8dbcd5579101099175226b6fd025e4f2f10c0d010da84b7f72f7322eb362fa4ae3e03cdafc3 languageName: node linkType: hard -"@nktkas/rews@npm:^2": - version: 2.0.2 - resolution: "@nktkas/rews@npm:2.0.2" - checksum: 10/2ae94b32d883da825a6b082ae5d22a7fa614b09ec424b92a6dc4c7feba58e60184110810c33f84ef79e6388833d5071e1cca2682252b1cf219fd179861e4defe +"@nktkas/rews@npm:^4.1.0": + version: 4.1.0 + resolution: "@nktkas/rews@npm:4.1.0" + checksum: 10/07130006a7e226729100ec1ffbf6c07e0c55a5c62c1662b2f484e099c21b76e71ff8752a04cf2d6de926546489078882e15805ec82063df4ed2e2cdd090d08ac languageName: node linkType: hard @@ -8451,13 +8452,6 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:^2": - version: 2.0.1 - resolution: "@noble/hashes@npm:2.0.1" - checksum: 10/f4d00e7564eb4ff4e6d16be151dd0e404aede35f91e4372b0a8a6ec888379c1dd1e02c721b480af8e7853bea9637185b5cb9533970c5b77d60c254ead0cfd8f7 - languageName: node - linkType: hard - "@noble/hashes@npm:~1.7.1": version: 1.7.1 resolution: "@noble/hashes@npm:1.7.1" @@ -9786,7 +9780,7 @@ __metadata: "@metamask/eth-sig-util": "npm:5.1.0" "@mysten/sui": "npm:2.17.0" "@ngraveio/bc-ur": "npm:^1.1.13" - "@nktkas/hyperliquid": "npm:0.32.2" + "@nktkas/hyperliquid": "npm:0.33.3" "@onekeyfe/cross-inpage-provider-core": "npm:2.2.73" "@onekeyfe/cross-inpage-provider-errors": "npm:2.2.73" "@onekeyfe/cross-inpage-provider-injected": "npm:2.2.73" @@ -26681,6 +26675,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.6.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 + languageName: node + linkType: hard + "decode-uri-component@npm:^0.2.2": version: 0.2.2 resolution: "decode-uri-component@npm:0.2.2" @@ -48201,18 +48202,6 @@ __metadata: languageName: node linkType: hard -"valibot@npm:1.3.1": - version: 1.3.1 - resolution: "valibot@npm:1.3.1" - peerDependencies: - typescript: ">=5" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/e14d085fa87fbf41f76d040cdcf17e31527f868c8b82f878bc488a5bc3bc81162406c605182fc720473ec6dcff05393b89fb4a921a4206d9f7b6f76e3c93cf34 - languageName: node - linkType: hard - "valibot@npm:^0.25.0": version: 0.25.0 resolution: "valibot@npm:0.25.0" @@ -48251,6 +48240,18 @@ __metadata: languageName: node linkType: hard +"valibot@npm:^1.4.2": + version: 1.4.2 + resolution: "valibot@npm:1.4.2" + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/bb88d083a3f8f37a19796cd9c4e06004ad939a1bdb996b77a7246fceb133d5da5d1ba21c89baebaec327f421f23200fea54d1e682de3452a30138e2e70548521 + languageName: node + linkType: hard + "validate-npm-package-license@npm:^3.0.1": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4"