From 1bd948b2d7164049b3f722f2d958d9eaaea22678 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 12 Aug 2026 18:45:02 +0800 Subject: [PATCH 01/18] fix: support upgraded Hyperliquid TWAP orders --- .../ServiceHyperliquidExchange.ts | 35 ++ .../jotai/contexts/hyperliquid/actions.ts | 66 ++-- .../jotai/contexts/hyperliquid/atoms.ts | 4 + .../Components/MobileTwapOpenOrdersRow.tsx | 88 +++-- .../List/PerpOpenOrdersList.tsx | 47 +++ .../OrderInfoPanel/List/PerpTwapList.tsx | 244 +++++++++++-- .../TradingPanel/TradingButtonGroup.tsx | 60 ++-- .../TradingPanel/modals/OrderConfirmModal.tsx | 52 ++- .../TradingPanel/panels/PerpTradingForm.tsx | 234 +++---------- .../src/views/Perp/hooks/useOrderConfirm.ts | 69 ++-- .../src/utils/hyperliquidTwapSdkPatch.test.ts | 46 +++ .../src/utils/hyperliquidTwapUtils.test.ts | 101 ++++++ .../shared/src/utils/hyperliquidTwapUtils.ts | 100 ++++++ packages/shared/types/hyperliquid/types.ts | 3 + patches/@nktkas+hyperliquid+0.32.2.patch | 322 ++++++++++++++++++ 15 files changed, 1180 insertions(+), 291 deletions(-) create mode 100644 packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts create mode 100644 packages/shared/src/utils/hyperliquidTwapUtils.test.ts create mode 100644 packages/shared/src/utils/hyperliquidTwapUtils.ts create mode 100644 patches/@nktkas+hyperliquid+0.32.2.patch diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts index 1305d5e297b6..2a77191ffc42 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -1394,6 +1394,33 @@ export default class ServiceHyperliquidExchange extends ServiceBase { 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 = formatHlPrice( + price, + szDecimals, + assetType ?? 'perp', + ); + if (!formattedPrice) { + throw new OneKeyLocalError( + `TWAP ${fieldName} price is too small for HL tick size`, + ); + } + return formattedPrice; + }; + const triggerPrice = formatOptionalTwapPrice( + params.triggerPrice, + 'trigger', + ); + const stopPrice = formatOptionalTwapPrice(params.stopPrice, 'stop'); + if (triggerPrice && typeof params.triggerAbove !== 'boolean') { + throw new OneKeyLocalError('TWAP trigger direction is required'); + } const twap = { a: params.assetId, b: params.isBuy, @@ -1402,6 +1429,12 @@ export default class ServiceHyperliquidExchange extends ServiceBase { m: params.minutes, t: params.randomize, }; + const details = { + t: triggerPrice + ? { p: triggerPrice, a: params.triggerAbove as boolean } + : null, + s: stopPrice ?? null, + }; const client = await this.getExchangeClientForTrading(); const context = await this._buildLogContext(); const requestPayload = { @@ -1413,12 +1446,14 @@ export default class ServiceHyperliquidExchange extends ServiceBase { minutes: params.minutes, randomize: params.randomize, }, + details, }; try { const response = await convertHyperLiquidResponse(() => client.twapOrder({ twap, + details, }), ); defaultLogger.perp.hyperliquid.twapOrder({ diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 95b8582dd6be..0ad254c34e86 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -49,13 +49,20 @@ 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, + getTwapTriggerAbove, + isTwapTotalNotionalValid, + isValidTwapDuration, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { getPerpsOrderBookTickOptionWithCache, getPerpsOrderBookTickOptionsWithCache, @@ -164,10 +171,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 +1679,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { triggerPrice: '', executionPrice: '', triggerReduceOnly: true, + twapTriggerPrice: '', + twapStopPrice: '', }; // update limit price once using current atom snapshot. @@ -1852,6 +1857,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(); @@ -2419,6 +2426,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { slValue: '', triggerPrice: '', executionPrice: '', + twapTriggerPrice: '', + twapStopPrice: '', }); }); @@ -2876,11 +2885,7 @@ 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`, ); @@ -2896,6 +2901,29 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { ); } + const triggerPrice = formData.twapTriggerPrice?.trim(); + const stopPrice = formData.twapStopPrice?.trim(); + let triggerAbove: boolean | undefined; + 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 (stopPrice) { + const stopPriceBN = new BigNumber(stopPrice); + if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) { + throw new OneKeyLocalError( + 'TWAP stop price must be a positive number', + ); + } + } + const szDecimals = isSpot ? (activeTradeInstrument.universe?.baseSzDecimals ?? env.szDecimals ?? @@ -2922,19 +2950,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 +3000,9 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { reduceOnly, minutes, randomize: formData.twapRandomize ?? true, + triggerPrice, + triggerAbove, + stopPrice, 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..4d60977d5bcc 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,14 @@ 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 { 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 +37,7 @@ const valueFormatter: INumberFormatProps = { interface IMobileTwapOpenOrdersRowProps { order: IPerpsActiveTwapOrder; + status: ITwapHistoryRecord['status']['status']; onCancelOrder: () => void; } @@ -62,6 +65,15 @@ function formatTotalDuration(minutes: number) { return `${hours}h ${remainingMinutes}m`; } +function formatTwapPrice(price?: string | null) { + const priceBN = new BigNumber(price ?? ''); + if (!priceBN.isFinite() || priceBN.lte(0)) { + return '--'; + } + const priceValue = priceBN.toFixed(getValidPriceDecimals(priceBN.toFixed())); + return formatLocalizedNumberString(priceValue); +} + function MobileTwapInfoRow({ label, value }: { label: string; value: string }) { return ( @@ -80,7 +92,7 @@ function MobileTwapInfoRow({ label, value }: { label: string; value: string }) { } const MobileTwapOpenOrdersRow = memo( - ({ order, onCancelOrder }: IMobileTwapOpenOrdersRowProps) => { + ({ order, status, 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,31 @@ 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, + now, + minutes: state.minutes, + }); return { progressText, avgPriceFormatted: avgPriceValue ? formatLocalizedNumberString(avgPriceValue) : '--', executedValueFormatted: numberFormat(state.executedNtl, valueFormatter), + triggerPriceFormatted: formatTwapPrice(state.trigger?.px), + stopPriceFormatted: formatTwapPrice(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]); + }, [intl, now, state, status]); const sideText = useMemo(() => { if (state.side === 'B') { @@ -177,6 +202,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 ( + + + + getPerpsAccountScopedListData({ + activeAccountAddress: accountScopedAddress, + dataAccountAddress: twapHistoryState.accountAddress, + data: twapHistoryState.history, + }), + [ + accountScopedAddress, + twapHistoryState.accountAddress, + twapHistoryState.history, + ], + ); + const activeTwapStatusById = useMemo(() => { + const latestRecordByTwapId = new Map< + number, + (typeof scopedTwapHistory)[number] + >(); + scopedTwapHistory.forEach((record) => { + if (record.twapId === undefined) { + return; + } + const previous = latestRecordByTwapId.get(record.twapId); + if (!previous || record.time > previous.time) { + latestRecordByTwapId.set(record.twapId, record); + } + }); + return new Map( + Array.from(latestRecordByTwapId.entries()).map( + ([twapId, record]) => + [ + twapId, + record.status.status === 'waitingForTrigger' + ? 'waitingForTrigger' + : 'activated', + ] as const, + ), + ); + }, [scopedTwapHistory]); const openOrders = useMemo( () => [...scopedPerpOpenOrders, ...scopedSpotOpenOrders].toSorted( @@ -486,6 +528,11 @@ function PerpOpenOrdersList({ 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..af5f973ccba5 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,10 @@ 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 { + getActiveTwapRuntimeStatus, + getTwapElapsedMs, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { formatLocalizedNumberString, @@ -165,11 +169,22 @@ 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] }); } +function formatTwapPrice(price?: string | null) { + const priceBN = new BigNumber(price ?? ''); + if (!priceBN.isFinite() || priceBN.lte(0)) { + return '--'; + } + const priceValue = priceBN.toFixed(getValidPriceDecimals(priceBN.toFixed())); + return formatLocalizedNumberString(priceValue); +} + function getTableRowBgColor({ isHovered, index, @@ -204,6 +219,7 @@ function getTwapBaseInfo({ state, now, endTime, + status, spotDisplayMap, spotPairDisplayNameMap, intl, @@ -211,6 +227,7 @@ function getTwapBaseInfo({ state: ITwapState; now: number; endTime?: number; + status?: ITwapHistoryRecord['status']['status']; spotDisplayMap: Record; spotPairDisplayNameMap: Record; intl: IntlShape; @@ -236,11 +253,13 @@ 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, + now, + endTime, + minutes: state.minutes, + }); return { assetSymbol, @@ -251,10 +270,15 @@ function getTwapBaseInfo({ avgPriceFormatted: avgPriceValue ? formatLocalizedNumberString(avgPriceValue) : '--', - runningTimeText: `${formatElapsedDuration(elapsedMs)} / ${formatTotalDuration( - state.minutes, - intl, - )}`, + triggerPriceFormatted: formatTwapPrice(state.trigger?.px), + stopPriceFormatted: formatTwapPrice(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 +457,7 @@ function TwapEmptyState({ function TwapActiveRow({ order, + status, now, cellMinWidth, columnConfigs, @@ -445,6 +470,7 @@ function TwapActiveRow({ spotPairDisplayNameMap, }: { order: IPerpsActiveTwapOrder; + status: ITwapHistoryRecord['status']['status']; now: number; cellMinWidth: number; columnConfigs: IColumnConfig[]; @@ -463,12 +489,13 @@ function TwapActiveRow({ () => getTwapBaseInfo({ state, + status, now, spotDisplayMap, spotPairDisplayNameMap, intl, }), - [intl, now, spotDisplayMap, spotPairDisplayNameMap, state], + [intl, now, spotDisplayMap, spotPairDisplayNameMap, state, status], ); const creationTime = useMemo( () => formatTwapDateTime(state.timestamp), @@ -531,29 +558,56 @@ function TwapActiveRow({ - {baseInfo.runningTimeText} + + {baseInfo.triggerPriceFormatted} + - + + {baseInfo.stopPriceFormatted} + + + + {baseInfo.runningTimeText} + + + + {getTwapHistoryStatusText(status, intl)} + + + {baseInfo.reduceOnlyText} {baseInfo.randomizeText} {creationTime.inline} @@ -561,8 +615,8 @@ function TwapActiveRow({ ) : null} {shouldRenderRight ? ( @@ -611,19 +665,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 +698,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 +826,21 @@ function TwapHistoryRow({ })} value={historyDisplayInfo.averagePrice} /> + + + + {baseInfo.triggerPriceFormatted} + + + + + {baseInfo.stopPriceFormatted} + + + {historyDisplayInfo.totalRuntime} {baseInfo.reduceOnlyText} {baseInfo.randomizeText} @@ -864,8 +971,8 @@ function TwapHistoryRow({ ) : null} {shouldRenderRight ? ( { + const latestRecordByTwapId = new Map(); + historyRows.forEach((record) => { + if (record.twapId === undefined) { + return; + } + const previous = latestRecordByTwapId.get(record.twapId); + if (!previous || record.time > previous.time) { + latestRecordByTwapId.set(record.twapId, record); + } + }); + return new Map( + Array.from(latestRecordByTwapId.entries()).map( + ([twapId, record]) => + [ + twapId, + record.status.status === 'waitingForTrigger' + ? 'waitingForTrigger' + : 'activated', + ] as const, + ), + ); + }, [historyRows]); + const sliceFills = useMemo(() => { if ( !currentAccountAddress || @@ -1331,6 +1462,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 +1491,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 +1601,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({ @@ -1638,6 +1816,11 @@ function PerpTwapList({ ) => ( TWAP_MAX_DURATION_MINUTES + triggerPrice && + typeof getTwapTriggerAbove({ + triggerPrice, + markPrice: latestEffectivePriceBN, + }) !== '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 stopPrice = latestFormData.twapStopPrice?.trim(); + if (stopPrice) { + const stopPriceBN = new BigNumber(stopPrice); + if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) { + Toast.message({ + title: intl.formatMessage({ + id: ETranslations.perps_input_price_place_holder, + }), + }); + return 'invalidTwapConfig' as const; + } + } } const isSliderMode = latestFormData.sizeInputMode === 'slider'; @@ -1073,22 +1098,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: latestEffectivePriceBN, + }) ) { 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..570a1b5df8ed 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx @@ -254,10 +254,33 @@ function OrderConfirmContent({ if (!isTwapMode) { return null; } + const triggerPrice = formData.twapTriggerPrice?.trim(); + const stopPrice = formData.twapStopPrice?.trim(); return { minutes: Number(formData.twapDurationMinutes ?? 0), + triggerPrice: triggerPrice + ? formatOrderPriceDisplay({ + price: triggerPrice, + isSpot, + szDecimals, + }) + : undefined, + stopPrice: stopPrice + ? formatOrderPriceDisplay({ + price: stopPrice, + isSpot, + szDecimals, + }) + : undefined, }; - }, [formData.twapDurationMinutes, isTwapMode]); + }, [ + formData.twapDurationMinutes, + formData.twapStopPrice, + formData.twapTriggerPrice, + isSpot, + isTwapMode, + szDecimals, + ]); const _inferredTpslBadge = useMemo(() => { if (!isTriggerMode || !formData.triggerPrice) return null; @@ -723,6 +746,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..7045ac0785f8 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 { @@ -100,10 +105,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 +126,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 +176,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 +183,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 +476,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( @@ -580,28 +575,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 +645,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 +655,6 @@ function PerpTradingForm({ }), [intl], ); - const twapSmallSliceHelperText = useMemo( - () => - intl.formatMessage({ - id: ETranslations.perp_twap_small_slice__msg, - }), - [intl], - ); const scaleAmountDistributionHelperText = useMemo( () => intl.formatMessage({ @@ -1076,11 +1042,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,95 +1056,6 @@ 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(''); const [focusedTwapDurationInput, setFocusedTwapDurationInput] = @@ -2257,26 +2130,56 @@ function PerpTradingForm({ ); }; - const renderTwapDurationSection = () => { + const renderTwapDetailsSection = () => { if (!isTwapMode) { return null; } - const quickOptionHeight = isMobile ? 28 : 26; - const renderTwapHelperMessage = () => { - if (!twapHelperMessage) { - 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} + /> + + ); + }; - return ( - - - {twapHelperMessage} - - - ); - }; + const renderTwapDurationSection = () => { + if (!isTwapMode) { + return null; + } + const quickOptionHeight = isMobile ? 28 : 26; if (isMobile) { return ( @@ -2323,7 +2226,6 @@ function PerpTradingForm({ ); })} - {renderTwapHelperMessage()} {twapDurationInputMessage ? ( {twapDurationInputMessage.text} @@ -2472,7 +2374,6 @@ function PerpTradingForm({ ); })} - {renderTwapHelperMessage()} {twapDurationInputMessage ? ( {twapDurationInputMessage.text} @@ -2591,32 +2492,6 @@ function PerpTradingForm({ } /> - {twapEstimatedSliceNotionalDisplay ? ( - - - {intl.formatMessage({ - id: ETranslations.perp_twap_child_order_size__title, - })} - - - {twapEstimatedSliceNotionalDisplay} - - - ) : null} ); @@ -3142,6 +3017,8 @@ function PerpTradingForm({ {isTwapMode ? null : renderPriceInputSection()} + {renderTwapDetailsSection()} + void; onError?: (error: unknown) => void; @@ -322,11 +323,7 @@ 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`, @@ -352,21 +349,51 @@ function useOrderConfirmWithMarketDataFreshness({ }); return; } - const estimatedSlices = Math.max( - 1, - Math.ceil((duration * 60) / TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS), - ); - const averageSliceNotional = twapSize - .multipliedBy(midPriceBN) - .dividedBy(estimatedSlices); + const triggerPrice = formDataSnapshot.twapTriggerPrice?.trim(); + if (triggerPrice) { + const triggerAbove = getTwapTriggerAbove({ + triggerPrice, + markPrice: midPriceBN, + }); + if (typeof triggerAbove !== 'boolean') { + const triggerPriceBN = new BigNumber(triggerPrice); + Toast.error({ + title: 'Order Failed', + message: + triggerPriceBN.isFinite() && triggerPriceBN.eq(midPriceBN) + ? intl.formatMessage({ + id: ETranslations.perps_trigger_price_equal_current, + }) + : intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + }); + return; + } + } + const stopPrice = formDataSnapshot.twapStopPrice?.trim(); + if (stopPrice) { + const stopPriceBN = new BigNumber(stopPrice); + if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) { + Toast.error({ + title: 'Order Failed', + message: intl.formatMessage({ + id: ETranslations.perps_input_price_place_holder, + }), + }); + return; + } + } if ( - !averageSliceNotional.isFinite() || - averageSliceNotional.lt(TWAP_MIN_ORDER_NOTIONAL) + !isTwapTotalNotionalValid({ + size: twapSize, + price: midPriceBN, + }) ) { Toast.error({ title: 'Order Failed', message: intl.formatMessage({ - id: ETranslations.perp_twap_small_slice__msg, + id: ETranslations.perp_scale_order_size_too_small__msg, }), }); return; diff --git a/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts b/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts new file mode 100644 index 000000000000..29ef4a991fd9 --- /dev/null +++ b/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts @@ -0,0 +1,46 @@ +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, + [ + '-e', + ` + const v = require( + './node_modules/@nktkas/hyperliquid/node_modules/valibot' + ); + const { TwapOrderRequest } = require( + './node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js' + ); + 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', + }, + }); + }); +}); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts new file mode 100644 index 000000000000..531c726059cb --- /dev/null +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -0,0 +1,101 @@ +import { + TWAP_MAX_DURATION_MINUTES, + TWAP_MIN_DURATION_MINUTES, + TWAP_MIN_ORDER_NOTIONAL, + getActiveTwapRuntimeStatus, + getTwapElapsedMs, + getTwapTriggerAbove, + 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('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: '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({ + triggerPrice: null, + executedSize: '0', + }), + ).toBe('activated'); + }); +}); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts new file mode 100644 index 000000000000..a27fbbffeaed --- /dev/null +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -0,0 +1,100 @@ +import BigNumber from 'bignumber.js'; + +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 function getActiveTwapRuntimeStatus({ + reportedStatus, + triggerPrice, + executedSize, +}: { + reportedStatus?: 'activated' | 'waitingForTrigger'; + triggerPrice?: string | null; + executedSize: BigNumber.Value; +}): 'activated' | 'waitingForTrigger' { + if (reportedStatus) { + return reportedStatus; + } + const executedSizeBN = new BigNumber(executedSize); + return triggerPrice && executedSizeBN.isFinite() && executedSizeBN.isZero() + ? 'waitingForTrigger' + : 'activated'; +} + +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 getTwapElapsedMs({ + status, + timestamp, + now, + endTime, + minutes, +}: { + status?: ITwapRuntimeStatus; + timestamp: 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) - timestamp, 0), totalMs); +} diff --git a/packages/shared/types/hyperliquid/types.ts b/packages/shared/types/hyperliquid/types.ts index 158e6c69381a..963b8d7ee29f 100644 --- a/packages/shared/types/hyperliquid/types.ts +++ b/packages/shared/types/hyperliquid/types.ts @@ -264,6 +264,9 @@ export interface IPlaceTwapOrderParams { reduceOnly: boolean; minutes: number; randomize: boolean; + triggerPrice?: string; + triggerAbove?: boolean; + stopPrice?: string; szDecimals?: number; } diff --git a/patches/@nktkas+hyperliquid+0.32.2.patch b/patches/@nktkas+hyperliquid+0.32.2.patch new file mode 100644 index 000000000000..db1d87b1a32b --- /dev/null +++ b/patches/@nktkas+hyperliquid+0.32.2.patch @@ -0,0 +1,322 @@ +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 a27bcc4..7268aca 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,10 +19,22 @@ export declare const TwapOrderRequest: v.ObjectSchema<{ + /** 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>; ++ /** Trigger and stop prices. */ ++ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; ++ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ ++ readonly a: v.BooleanSchema; ++ }, undefined>, undefined>; ++ /** Price at which the order is terminated. */ ++ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; ++ }, undefined>, undefined>; + }, undefined>; + /** Nonce (timestamp in ms) used to prevent replay attacks. */ + readonly nonce: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>; +@@ -82,10 +94,22 @@ declare const TwapOrderActionSchema: v.ObjectSchema<{ + /** 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>; ++ /** Trigger and stop prices. */ ++ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; ++ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ ++ readonly a: v.BooleanSchema; ++ }, undefined>, undefined>; ++ /** Price at which the order is terminated. */ ++ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; ++ }, undefined>, undefined>; + }, undefined>; + /** Action parameters for the {@linkcode twapOrder} function. */ + export type TwapOrderParameters = Omit, "type">; +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 7022cd8..73cfcc9 100644 +--- a/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js ++++ b/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js +@@ -25,10 +25,24 @@ export const TwapOrderRequest = /* @__PURE__ */ (() => { + /** Is reduce-only? */ + r: v.boolean(), + /** TWAP duration in minutes. */ +- m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(1440)), ++ // OneKey patch: Hyperliquid now supports TWAP durations up to seven days. ++ m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(10080)), + /** Enable random order timing. */ + t: v.boolean(), + }), ++ // OneKey patch: Backport trigger and stop details without upgrading the breaking SDK release. ++ /** Trigger and stop prices. */ ++ details: v.optional(v.object({ ++ /** Condition that activates the order. */ ++ t: v.nullable(v.object({ ++ /** Trigger price. */ ++ p: UnsignedDecimal, ++ /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ ++ a: v.boolean(), ++ })), ++ /** Price at which the order is terminated. */ ++ s: v.nullable(UnsignedDecimal), ++ })), + }), + /** Nonce (timestamp in ms) used to prevent replay attacks. */ + nonce: UnsignedInteger, +diff --git a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts +index 23bd8d5..28a242d 100644 +--- a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts ++++ b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts +@@ -216,6 +216,8 @@ export type TwapStateSchema = { + reduceOnly: boolean; + /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ + side: "B" | "A"; ++ /** Price at which the order is terminated; null when unset. */ ++ stopPx: string | null; + /** + * Order size. + * @pattern ^[0-9]+(\.[0-9]+)?$ +@@ -223,6 +225,13 @@ export type TwapStateSchema = { + sz: string; + /** Start time of the TWAP order (in ms since epoch). */ + timestamp: number; ++ /** Condition that activates the order; null when unset. */ ++ trigger: { ++ /** Trigger price. */ ++ px: string; ++ /** Activates when the mark price is above or below the trigger price. */ ++ above: boolean; ++ } | null; + /** + * User address. + * @pattern ^0x[a-fA-F0-9]{40}$ +diff --git a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts +index 4621907..4dfadaf 100644 +--- a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts ++++ b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts +@@ -29,7 +29,7 @@ export type TwapHistoryResponse = { + */ + status: { + /** Status of the TWAP order. */ +- status: "finished" | "activated" | "terminated"; ++ status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; + } | { + /** Status of the TWAP order. */ + status: "error"; +diff --git a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts +index a27bcc4..7268aca 100644 +--- a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts ++++ b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts +@@ -19,10 +19,22 @@ export declare const TwapOrderRequest: v.ObjectSchema<{ + /** 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>; ++ /** Trigger and stop prices. */ ++ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; ++ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ ++ readonly a: v.BooleanSchema; ++ }, undefined>, undefined>; ++ /** Price at which the order is terminated. */ ++ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; ++ }, undefined>, undefined>; + }, undefined>; + /** Nonce (timestamp in ms) used to prevent replay attacks. */ + readonly nonce: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>; +@@ -82,10 +94,22 @@ declare const TwapOrderActionSchema: v.ObjectSchema<{ + /** 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>; ++ /** Trigger and stop prices. */ ++ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; ++ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ ++ readonly a: v.BooleanSchema; ++ }, undefined>, undefined>; ++ /** Price at which the order is terminated. */ ++ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; ++ }, undefined>, undefined>; + }, undefined>; + /** Action parameters for the {@linkcode twapOrder} function. */ + export type TwapOrderParameters = Omit, "type">; +diff --git a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js +index 87cbd96..efffc7c 100644 +--- a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js ++++ b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js +@@ -62,10 +62,24 @@ exports.TwapOrderRequest = (() => { + /** Is reduce-only? */ + r: v.boolean(), + /** TWAP duration in minutes. */ +- m: v.pipe(_schemas_js_1.UnsignedInteger, v.minValue(5), v.maxValue(1440)), ++ // OneKey patch: Hyperliquid now supports TWAP durations up to seven days. ++ m: v.pipe(_schemas_js_1.UnsignedInteger, v.minValue(5), v.maxValue(10080)), + /** Enable random order timing. */ + t: v.boolean(), + }), ++ // OneKey patch: Backport trigger and stop details without upgrading the breaking SDK release. ++ /** Trigger and stop prices. */ ++ details: v.optional(v.object({ ++ /** Condition that activates the order. */ ++ t: v.nullable(v.object({ ++ /** Trigger price. */ ++ p: _schemas_js_1.UnsignedDecimal, ++ /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ ++ a: v.boolean(), ++ })), ++ /** Price at which the order is terminated. */ ++ s: v.nullable(_schemas_js_1.UnsignedDecimal), ++ })), + }), + /** Nonce (timestamp in ms) used to prevent replay attacks. */ + nonce: _schemas_js_1.UnsignedInteger, +diff --git a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts +index 23bd8d5..28a242d 100644 +--- a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts ++++ b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts +@@ -216,6 +216,8 @@ export type TwapStateSchema = { + reduceOnly: boolean; + /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ + side: "B" | "A"; ++ /** Price at which the order is terminated; null when unset. */ ++ stopPx: string | null; + /** + * Order size. + * @pattern ^[0-9]+(\.[0-9]+)?$ +@@ -223,6 +225,13 @@ export type TwapStateSchema = { + sz: string; + /** Start time of the TWAP order (in ms since epoch). */ + timestamp: number; ++ /** Condition that activates the order; null when unset. */ ++ trigger: { ++ /** Trigger price. */ ++ px: string; ++ /** Activates when the mark price is above or below the trigger price. */ ++ above: boolean; ++ } | null; + /** + * User address. + * @pattern ^0x[a-fA-F0-9]{40}$ +diff --git a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts +index 4621907..4dfadaf 100644 +--- a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts ++++ b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts +@@ -29,7 +29,7 @@ export type TwapHistoryResponse = { + */ + status: { + /** Status of the TWAP order. */ +- status: "finished" | "activated" | "terminated"; ++ status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; + } | { + /** Status of the TWAP order. */ + status: "error"; +diff --git a/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts b/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts +index 9b6bab6..b8b65da 100644 +--- a/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts ++++ b/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts +@@ -28,10 +28,24 @@ export const TwapOrderRequest = /* @__PURE__ */ (() => { + /** Is reduce-only? */ + r: v.boolean(), + /** TWAP duration in minutes. */ +- m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(1440)), ++ // OneKey patch: Hyperliquid now supports TWAP durations up to seven days. ++ m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(10080)), + /** Enable random order timing. */ + t: v.boolean(), + }), ++ // OneKey patch: Backport trigger and stop details without upgrading the breaking SDK release. ++ /** Trigger and stop prices. */ ++ details: v.optional(v.object({ ++ /** Condition that activates the order. */ ++ t: v.nullable(v.object({ ++ /** Trigger price. */ ++ p: UnsignedDecimal, ++ /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ ++ a: v.boolean(), ++ })), ++ /** Price at which the order is terminated. */ ++ s: v.nullable(UnsignedDecimal), ++ })), + }), + /** Nonce (timestamp in ms) used to prevent replay attacks. */ + nonce: UnsignedInteger, +diff --git a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts +index d58d766..cfebb35 100644 +--- a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts ++++ b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts +@@ -233,6 +233,9 @@ export type TwapStateSchema = { + reduceOnly: boolean; + /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ + side: "B" | "A"; ++ // OneKey patch: Backport TWAP trigger and stop state fields from the newer SDK. ++ /** Price at which the order is terminated; `null` when unset. */ ++ stopPx: string | null; + /** + * Order size. + * @pattern ^[0-9]+(\.[0-9]+)?$ +@@ -240,6 +243,13 @@ export type TwapStateSchema = { + sz: string; + /** Start time of the TWAP order (in ms since epoch). */ + timestamp: number; ++ /** Condition that activates the order; `null` when unset. */ ++ trigger: { ++ /** Trigger price. */ ++ px: string; ++ /** Activates when the mark price is above (`true`) or below (`false`) the trigger price. */ ++ above: boolean; ++ } | null; + /** + * User address. + * @pattern ^0x[a-fA-F0-9]{40}$ +diff --git a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts +index e7f982d..8859f30 100644 +--- a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts ++++ b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts +@@ -35,11 +35,14 @@ export type TwapHistoryResponse = { + * - `"finished"`: Fully executed. + * - `"activated"`: Active and executing. + * - `"terminated"`: Terminated. ++ * - `"waitingForTrigger"`: Awaiting the trigger price. ++ * - `"stopped"`: Terminated by the stop price. + * - `"error"`: An error occurred. + */ + status: { + /** Status of the TWAP order. */ +- status: "finished" | "activated" | "terminated"; ++ // OneKey patch: Backport trigger lifecycle statuses from the newer SDK. ++ status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; + } | { + /** Status of the TWAP order. */ + status: "error"; From f7139a4c396fca78b394288e12a0596dc08903bb Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 12 Aug 2026 21:44:34 +0800 Subject: [PATCH 02/18] fix: harden Hyperliquid TWAP runtime handling --- .../jotai/contexts/hyperliquid/actions.ts | 36 +++++-- .../Components/MobileTwapOpenOrdersRow.tsx | 29 +++--- .../List/PerpOpenOrdersList.tsx | 31 ++++-- .../OrderInfoPanel/List/PerpTwapList.tsx | 94 +++++++++++-------- .../TradingPanel/TradingButtonGroup.tsx | 17 +++- .../src/views/Perp/hooks/useOrderConfirm.ts | 24 ++++- .../src/utils/hyperliquidTwapUtils.test.ts | 50 ++++++++++ .../shared/src/utils/hyperliquidTwapUtils.ts | 37 +++++++- patches/@nktkas+hyperliquid+0.32.2.patch | 12 +-- 9 files changed, 246 insertions(+), 84 deletions(-) diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 0ad254c34e86..3f76da814f64 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -60,6 +60,7 @@ import { TWAP_MIN_DURATION_MINUTES, TWAP_MIN_ORDER_NOTIONAL, getTwapTriggerAbove, + getTwapTriggerReferencePrice, isTwapTotalNotionalValid, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -71,6 +72,7 @@ import { import { classifyTpSlOrder } from '@onekeyhq/shared/src/utils/perpsTpSlUtils'; import { findTokensByAlias, + formatHlPrice, formatPriceToSignificantDigits, formatSpotAssetCtx, getTriggerEffectivePrice, @@ -2891,19 +2893,38 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { ); } - 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 ?? env.markPrice ?? '', + }); if (!markPriceBN.isFinite() || markPriceBN.lte(0)) { throw new OneKeyLocalError( 'Market price unavailable, please try again', ); } - const triggerPrice = formData.twapTriggerPrice?.trim(); + const szDecimals = isSpot + ? (activeTradeInstrument.universe?.baseSzDecimals ?? + env.szDecimals ?? + 2) + : (activeAssetValue?.universe?.szDecimals ?? env.szDecimals ?? 2); + const rawTriggerPrice = formData.twapTriggerPrice?.trim(); + const triggerPrice = rawTriggerPrice + ? formatHlPrice( + rawTriggerPrice, + szDecimals, + isSpot ? 'spot' : 'perp', + ) + : undefined; const stopPrice = formData.twapStopPrice?.trim(); 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, @@ -2924,11 +2945,6 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { } } - const szDecimals = isSpot - ? (activeTradeInstrument.universe?.baseSzDecimals ?? - env.szDecimals ?? - 2) - : (activeAssetValue?.universe?.szDecimals ?? env.szDecimals ?? 2); const resolvedSize = resolveTradingSize({ sizeInputMode: formData.sizeInputMode, manualSize: formData.size, 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 4d60977d5bcc..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,7 +12,10 @@ 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 { getTwapElapsedMs } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; +import { + formatTwapPriceForDisplay, + getTwapElapsedMs, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { formatLocalizedNumberString, @@ -38,6 +41,7 @@ const valueFormatter: INumberFormatProps = { interface IMobileTwapOpenOrdersRowProps { order: IPerpsActiveTwapOrder; status: ITwapHistoryRecord['status']['status']; + activatedAt?: number; onCancelOrder: () => void; } @@ -65,15 +69,6 @@ function formatTotalDuration(minutes: number) { return `${hours}h ${remainingMinutes}m`; } -function formatTwapPrice(price?: string | null) { - const priceBN = new BigNumber(price ?? ''); - if (!priceBN.isFinite() || priceBN.lte(0)) { - return '--'; - } - const priceValue = priceBN.toFixed(getValidPriceDecimals(priceBN.toFixed())); - return formatLocalizedNumberString(priceValue); -} - function MobileTwapInfoRow({ label, value }: { label: string; value: string }) { return ( @@ -92,7 +87,12 @@ function MobileTwapInfoRow({ label, value }: { label: string; value: string }) { } const MobileTwapOpenOrdersRow = memo( - ({ order, status, onCancelOrder }: IMobileTwapOpenOrdersRowProps) => { + ({ + order, + status, + activatedAt, + onCancelOrder, + }: IMobileTwapOpenOrdersRowProps) => { const intl = useIntl(); const { twapId, state } = order; const [now, setNow] = useState(Date.now()); @@ -168,6 +168,7 @@ const MobileTwapOpenOrdersRow = memo( const elapsedMs = getTwapElapsedMs({ status, timestamp: state.timestamp, + activatedAt, now, minutes: state.minutes, }); @@ -177,8 +178,8 @@ const MobileTwapOpenOrdersRow = memo( ? formatLocalizedNumberString(avgPriceValue) : '--', executedValueFormatted: numberFormat(state.executedNtl, valueFormatter), - triggerPriceFormatted: formatTwapPrice(state.trigger?.px), - stopPriceFormatted: formatTwapPrice(state.stopPx), + triggerPriceFormatted: formatTwapPriceForDisplay(state.trigger?.px), + stopPriceFormatted: formatTwapPriceForDisplay(state.stopPx), execution, runningTimeText: isWaitingForTrigger ? '--' @@ -189,7 +190,7 @@ const MobileTwapOpenOrdersRow = memo( ? intl.formatMessage({ id: ETranslations.perp_yes__title }) : intl.formatMessage({ id: ETranslations.perp_no__title }), }; - }, [intl, now, state, status]); + }, [activatedAt, intl, now, state, status]); const sideText = useMemo(() => { if (state.side === 'B') { 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 27a7e56b7b65..c7b4aaeec66e 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx @@ -42,6 +42,7 @@ import { MobileOpenOrdersListHeader } from '../Components/MobileOpenOrdersListHe import { MobileTwapOpenOrdersRow } from '../Components/MobileTwapOpenOrdersRow'; import { OpenOrdersRow } from '../Components/OpenOrdersRow'; import { OrderInfoSubTabs } from '../Components/OrderInfoSubTabs'; +import { normalizeEpochMs } from '../utils'; import { CommonTableListView, type IColumnConfig } from './CommonTableListView'; import { shouldRenderMobileOpenOrdersNativeTree } from './mobileOpenOrdersVisibility'; @@ -280,7 +281,7 @@ function PerpOpenOrdersList({ twapHistoryState.history, ], ); - const activeTwapStatusById = useMemo(() => { + const activeTwapRuntimeInfoById = useMemo(() => { const latestRecordByTwapId = new Map< number, (typeof scopedTwapHistory)[number] @@ -299,9 +300,16 @@ function PerpOpenOrdersList({ ([twapId, record]) => [ twapId, - record.status.status === 'waitingForTrigger' - ? 'waitingForTrigger' - : 'activated', + { + reportedStatus: + record.status.status === 'waitingForTrigger' + ? 'waitingForTrigger' + : 'activated', + activatedAt: + record.status.status === 'activated' + ? normalizeEpochMs(record.time) + : undefined, + }, ] as const, ), ); @@ -525,14 +533,19 @@ function PerpOpenOrdersList({ onHoverChange?: (index: number | null) => void, ) => { if (item.type === 'twap') { + const runtimeInfo = activeTwapRuntimeInfoById.get(item.order.twapId); + 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 af5f973ccba5..aabe8994afe3 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx @@ -35,6 +35,7 @@ import { import { ETranslations } from '@onekeyhq/shared/src/locale'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; import { + formatTwapPriceForDisplay, getActiveTwapRuntimeStatus, getTwapElapsedMs, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -176,15 +177,6 @@ function getTwapHistoryStatusText( return intl.formatMessage({ id: statusTextMap[status] }); } -function formatTwapPrice(price?: string | null) { - const priceBN = new BigNumber(price ?? ''); - if (!priceBN.isFinite() || priceBN.lte(0)) { - return '--'; - } - const priceValue = priceBN.toFixed(getValidPriceDecimals(priceBN.toFixed())); - return formatLocalizedNumberString(priceValue); -} - function getTableRowBgColor({ isHovered, index, @@ -219,6 +211,7 @@ function getTwapBaseInfo({ state, now, endTime, + activatedAt, status, spotDisplayMap, spotPairDisplayNameMap, @@ -227,6 +220,7 @@ function getTwapBaseInfo({ state: ITwapState; now: number; endTime?: number; + activatedAt?: number; status?: ITwapHistoryRecord['status']['status']; spotDisplayMap: Record; spotPairDisplayNameMap: Record; @@ -256,6 +250,7 @@ function getTwapBaseInfo({ const elapsedMs = getTwapElapsedMs({ status, timestamp: state.timestamp, + activatedAt, now, endTime, minutes: state.minutes, @@ -270,8 +265,8 @@ function getTwapBaseInfo({ avgPriceFormatted: avgPriceValue ? formatLocalizedNumberString(avgPriceValue) : '--', - triggerPriceFormatted: formatTwapPrice(state.trigger?.px), - stopPriceFormatted: formatTwapPrice(state.stopPx), + triggerPriceFormatted: formatTwapPriceForDisplay(state.trigger?.px), + stopPriceFormatted: formatTwapPriceForDisplay(state.stopPx), runningTimeText: status === 'waitingForTrigger' ? '--' @@ -458,6 +453,7 @@ function TwapEmptyState({ function TwapActiveRow({ order, status, + activatedAt, now, cellMinWidth, columnConfigs, @@ -471,6 +467,7 @@ function TwapActiveRow({ }: { order: IPerpsActiveTwapOrder; status: ITwapHistoryRecord['status']['status']; + activatedAt?: number; now: number; cellMinWidth: number; columnConfigs: IColumnConfig[]; @@ -490,12 +487,21 @@ function TwapActiveRow({ getTwapBaseInfo({ state, status, + activatedAt, now, spotDisplayMap, spotPairDisplayNameMap, intl, }), - [intl, now, spotDisplayMap, spotPairDisplayNameMap, state, status], + [ + activatedAt, + intl, + now, + spotDisplayMap, + spotPairDisplayNameMap, + state, + status, + ], ); const creationTime = useMemo( () => formatTwapDateTime(state.timestamp), @@ -1388,7 +1394,7 @@ function PerpTwapList({ return rawHistory; }, [currentAccountAddress, historyAccountAddress, rawHistory]); - const activeStatusByTwapId = useMemo(() => { + const activeRuntimeInfoByTwapId = useMemo(() => { const latestRecordByTwapId = new Map(); historyRows.forEach((record) => { if (record.twapId === undefined) { @@ -1404,9 +1410,16 @@ function PerpTwapList({ ([twapId, record]) => [ twapId, - record.status.status === 'waitingForTrigger' - ? 'waitingForTrigger' - : 'activated', + { + reportedStatus: + record.status.status === 'waitingForTrigger' + ? 'waitingForTrigger' + : 'activated', + activatedAt: + record.status.status === 'activated' + ? normalizeEpochMs(record.time) + : undefined, + }, ] as const, ), ); @@ -1813,29 +1826,36 @@ 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 = activeRuntimeInfoByTwapId.get(item.twapId); + 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, - activeStatusByTwapId, + activeRuntimeInfoByTwapId, 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 ac19d8e3e9ed..cb74afe13a5a 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx @@ -45,6 +45,7 @@ import { usePerpsActiveAccountEnableTradingModeAtom, usePerpsActiveAccountStatusAtom, usePerpsActiveAssetAtom, + usePerpsActiveAssetCtxAtom, usePerpsCommonConfigPersistAtom, usePerpsCustomSettingsAtom, usePerpsTradingPreferencesAtom, @@ -72,6 +73,7 @@ import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, getTwapTriggerAbove, + getTwapTriggerReferencePrice, isTwapTotalNotionalValid, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -439,6 +441,7 @@ function SideButtonInternal({ ? 'usd' : tradingPreferences.sizeInputUnit; const [activeAsset] = usePerpsActiveAssetAtom(); + const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const orderContextKey = useMemo( () => @@ -459,6 +462,15 @@ function SideButtonInternal({ const [isSubmitting] = useTradingLoadingAtom(); const { midPriceBN } = useTradingPrice(); + const twapTriggerReferencePriceBN = useMemo( + () => + getTwapTriggerReferencePrice({ + isSpot, + midPrice: midPriceBN, + markPrice: activeAssetCtx?.ctx?.markPrice, + }), + [activeAssetCtx?.ctx?.markPrice, isSpot, midPriceBN], + ); const shouldBlockForMarketData = shouldBlockPerpsTradingForMarketData(marketDataFreshness); const confirmHyperliquidTerms = useConfirmHyperliquidTerms(); @@ -793,6 +805,7 @@ function SideButtonInternal({ side, shouldBlockForMarketData, szDecimals, + twapTriggerReferencePriceBN, }); latestOrderPanelStateRef.current = { activeAsset, @@ -819,6 +832,7 @@ function SideButtonInternal({ side, shouldBlockForMarketData, szDecimals, + twapTriggerReferencePriceBN, }; type ILatestOrderPanelState = typeof latestOrderPanelStateRef.current; @@ -851,6 +865,7 @@ function SideButtonInternal({ resolvedSizeInputUnit: latestResolvedSizeInputUnit, shouldBlockForMarketData: latestShouldBlockForMarketData, szDecimals: latestSzDecimals, + twapTriggerReferencePriceBN: latestTwapTriggerReferencePriceBN, } = orderPanelState; if ( @@ -986,7 +1001,7 @@ function SideButtonInternal({ triggerPrice && typeof getTwapTriggerAbove({ triggerPrice, - markPrice: latestEffectivePriceBN, + markPrice: latestTwapTriggerReferencePriceBN, }) !== 'boolean' ) { Toast.message({ diff --git a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts index 348751a7c9af..e0d939f77c23 100644 --- a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts +++ b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { BigNumber } from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -11,7 +11,10 @@ import { useTradingFormAtom, useTradingLoadingAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; -import { usePerpsActiveAccountAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import { + usePerpsActiveAccountAtom, + usePerpsActiveAssetCtxAtom, +} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { SCALE_ORDER_MAX_COUNT, @@ -28,6 +31,7 @@ import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, getTwapTriggerAbove, + getTwapTriggerReferencePrice, isTwapTotalNotionalValid, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -69,10 +73,20 @@ function useOrderConfirmWithMarketDataFreshness({ const [formData] = useTradingFormAtom(); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const [currentUser] = usePerpsActiveAccountAtom(); + const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [activePositionsValue] = usePerpsActivePositionAtom(); const hyperliquidActions = useHyperliquidActions(); const [isSubmitting] = useTradingLoadingAtom(); const { midPrice, midPriceBN } = useTradingPrice(); + const twapTriggerReferencePriceBN = useMemo( + () => + getTwapTriggerReferencePrice({ + isSpot: activeTradeInstrument.mode === 'spot', + midPrice: midPriceBN, + markPrice: activeAssetCtx?.ctx?.markPrice, + }), + [activeAssetCtx?.ctx?.markPrice, activeTradeInstrument.mode, midPriceBN], + ); const shouldBlockForMarketData = shouldBlockPerpsTradingForMarketData(marketDataFreshness); @@ -353,14 +367,15 @@ function useOrderConfirmWithMarketDataFreshness({ if (triggerPrice) { const triggerAbove = getTwapTriggerAbove({ triggerPrice, - markPrice: midPriceBN, + markPrice: twapTriggerReferencePriceBN, }); if (typeof triggerAbove !== 'boolean') { const triggerPriceBN = new BigNumber(triggerPrice); Toast.error({ title: 'Order Failed', message: - triggerPriceBN.isFinite() && triggerPriceBN.eq(midPriceBN) + triggerPriceBN.isFinite() && + triggerPriceBN.eq(twapTriggerReferencePriceBN) ? intl.formatMessage({ id: ETranslations.perps_trigger_price_equal_current, }) @@ -554,6 +569,7 @@ function useOrderConfirmWithMarketDataFreshness({ shortOrderPrice, intl, shouldBlockForMarketData, + twapTriggerReferencePriceBN, ], ); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts index 531c726059cb..6f892e9f363b 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -2,9 +2,11 @@ import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, TWAP_MIN_ORDER_NOTIONAL, + formatTwapPriceForDisplay, getActiveTwapRuntimeStatus, getTwapElapsedMs, getTwapTriggerAbove, + getTwapTriggerReferencePrice, isTwapTotalNotionalValid, isValidTwapDuration, } from './hyperliquidTwapUtils'; @@ -48,6 +50,38 @@ describe('hyperliquidTwapUtils', () => { ).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('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('does not advance running time while waiting for a trigger', () => { const timestamp = 1000; expect( @@ -66,6 +100,15 @@ describe('hyperliquidTwapUtils', () => { 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', @@ -91,6 +134,13 @@ describe('hyperliquidTwapUtils', () => { executedSize: '0', }), ).toBe('activated'); + expect( + getActiveTwapRuntimeStatus({ + reportedStatus: 'waitingForTrigger', + triggerPrice: '101', + executedSize: '0.01', + }), + ).toBe('activated'); expect( getActiveTwapRuntimeStatus({ triggerPrice: null, diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts index a27fbbffeaed..b79095ad30ba 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -1,5 +1,7 @@ import BigNumber from 'bignumber.js'; +import { formatLocalizedNumberString } from './numberUtils'; + export const TWAP_MIN_DURATION_MINUTES = 5; export const TWAP_MAX_DURATION_MINUTES = 7 * 24 * 60; export const TWAP_MIN_ORDER_NOTIONAL = 100; @@ -21,15 +23,41 @@ export function getActiveTwapRuntimeStatus({ 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; } - const executedSizeBN = new BigNumber(executedSize); 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 isValidTwapDuration(minutes: number): boolean { return ( Number.isInteger(minutes) && @@ -82,12 +110,14 @@ export function getTwapTriggerAbove({ export function getTwapElapsedMs({ status, timestamp, + activatedAt, now, endTime, minutes, }: { status?: ITwapRuntimeStatus; timestamp: number; + activatedAt?: number; now: number; endTime?: number; minutes: number; @@ -96,5 +126,8 @@ export function getTwapElapsedMs({ return 0; } const totalMs = Math.max(0, minutes) * 60_000; - return Math.min(Math.max((endTime ?? now) - timestamp, 0), totalMs); + return Math.min( + Math.max((endTime ?? now) - (activatedAt ?? timestamp), 0), + totalMs, + ); } diff --git a/patches/@nktkas+hyperliquid+0.32.2.patch b/patches/@nktkas+hyperliquid+0.32.2.patch index db1d87b1a32b..a70c28ffd9bc 100644 --- a/patches/@nktkas+hyperliquid+0.32.2.patch +++ b/patches/@nktkas+hyperliquid+0.32.2.patch @@ -273,20 +273,19 @@ index 9b6bab6..b8b65da 100644 /** Nonce (timestamp in ms) used to prevent replay attacks. */ nonce: UnsignedInteger, diff --git a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts -index d58d766..cfebb35 100644 +index d58d766..9ee2578 100644 --- a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts +++ b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts -@@ -233,6 +233,9 @@ export type TwapStateSchema = { +@@ -233,6 +233,8 @@ export type TwapStateSchema = { reduceOnly: boolean; /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ side: "B" | "A"; -+ // OneKey patch: Backport TWAP trigger and stop state fields from the newer SDK. + /** Price at which the order is terminated; `null` when unset. */ + stopPx: string | null; /** * Order size. * @pattern ^[0-9]+(\.[0-9]+)?$ -@@ -240,6 +243,13 @@ export type TwapStateSchema = { +@@ -240,6 +242,13 @@ export type TwapStateSchema = { sz: string; /** Start time of the TWAP order (in ms since epoch). */ timestamp: number; @@ -301,10 +300,10 @@ index d58d766..cfebb35 100644 * User address. * @pattern ^0x[a-fA-F0-9]{40}$ diff --git a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts -index e7f982d..8859f30 100644 +index e7f982d..2c56195 100644 --- a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts +++ b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts -@@ -35,11 +35,14 @@ export type TwapHistoryResponse = { +@@ -35,11 +35,13 @@ export type TwapHistoryResponse = { * - `"finished"`: Fully executed. * - `"activated"`: Active and executing. * - `"terminated"`: Terminated. @@ -315,7 +314,6 @@ index e7f982d..8859f30 100644 status: { /** Status of the TWAP order. */ - status: "finished" | "activated" | "terminated"; -+ // OneKey patch: Backport trigger lifecycle statuses from the newer SDK. + status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; } | { /** Status of the TWAP order. */ From 08ebcf07a067f79a110bf2ed546d73f8b79a1597 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 12 Aug 2026 21:48:04 +0800 Subject: [PATCH 03/18] refactor: centralize TWAP runtime history mapping --- .../List/PerpOpenOrdersList.tsx | 43 ++++--------------- .../OrderInfoPanel/List/PerpTwapList.tsx | 35 +++------------ .../src/utils/hyperliquidTwapUtils.test.ts | 21 +++++++++ .../shared/src/utils/hyperliquidTwapUtils.ts | 43 +++++++++++++++++++ 4 files changed, 77 insertions(+), 65 deletions(-) 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 c7b4aaeec66e..5a1177317411 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx @@ -26,7 +26,10 @@ import { } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import { getActiveTwapRuntimeStatus } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; +import { + buildActiveTwapRuntimeInfoById, + getActiveTwapRuntimeStatus, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { IPerpsFrontendOrder } from '@onekeyhq/shared/types/hyperliquid/sdk'; import { usePerpsAccountScopedCacheAddress } from '../../../hooks/usePerpsAccountScopedCacheAddress'; @@ -42,7 +45,6 @@ import { MobileOpenOrdersListHeader } from '../Components/MobileOpenOrdersListHe import { MobileTwapOpenOrdersRow } from '../Components/MobileTwapOpenOrdersRow'; import { OpenOrdersRow } from '../Components/OpenOrdersRow'; import { OrderInfoSubTabs } from '../Components/OrderInfoSubTabs'; -import { normalizeEpochMs } from '../utils'; import { CommonTableListView, type IColumnConfig } from './CommonTableListView'; import { shouldRenderMobileOpenOrdersNativeTree } from './mobileOpenOrdersVisibility'; @@ -281,39 +283,10 @@ function PerpOpenOrdersList({ twapHistoryState.history, ], ); - const activeTwapRuntimeInfoById = useMemo(() => { - const latestRecordByTwapId = new Map< - number, - (typeof scopedTwapHistory)[number] - >(); - scopedTwapHistory.forEach((record) => { - if (record.twapId === undefined) { - return; - } - const previous = latestRecordByTwapId.get(record.twapId); - if (!previous || record.time > previous.time) { - latestRecordByTwapId.set(record.twapId, record); - } - }); - return new Map( - Array.from(latestRecordByTwapId.entries()).map( - ([twapId, record]) => - [ - twapId, - { - reportedStatus: - record.status.status === 'waitingForTrigger' - ? 'waitingForTrigger' - : 'activated', - activatedAt: - record.status.status === 'activated' - ? normalizeEpochMs(record.time) - : undefined, - }, - ] as const, - ), - ); - }, [scopedTwapHistory]); + const activeTwapRuntimeInfoById = useMemo( + () => buildActiveTwapRuntimeInfoById(scopedTwapHistory), + [scopedTwapHistory], + ); const openOrders = useMemo( () => [...scopedPerpOpenOrders, ...scopedSpotOpenOrders].toSorted( 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 aabe8994afe3..d9828f4c830a 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx @@ -35,6 +35,7 @@ import { import { ETranslations } from '@onekeyhq/shared/src/locale'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; import { + buildActiveTwapRuntimeInfoById, formatTwapPriceForDisplay, getActiveTwapRuntimeStatus, getTwapElapsedMs, @@ -1394,36 +1395,10 @@ function PerpTwapList({ return rawHistory; }, [currentAccountAddress, historyAccountAddress, rawHistory]); - const activeRuntimeInfoByTwapId = useMemo(() => { - const latestRecordByTwapId = new Map(); - historyRows.forEach((record) => { - if (record.twapId === undefined) { - return; - } - const previous = latestRecordByTwapId.get(record.twapId); - if (!previous || record.time > previous.time) { - latestRecordByTwapId.set(record.twapId, record); - } - }); - return new Map( - Array.from(latestRecordByTwapId.entries()).map( - ([twapId, record]) => - [ - twapId, - { - reportedStatus: - record.status.status === 'waitingForTrigger' - ? 'waitingForTrigger' - : 'activated', - activatedAt: - record.status.status === 'activated' - ? normalizeEpochMs(record.time) - : undefined, - }, - ] as const, - ), - ); - }, [historyRows]); + const activeRuntimeInfoByTwapId = useMemo( + () => buildActiveTwapRuntimeInfoById(historyRows), + [historyRows], + ); const sliceFills = useMemo(() => { if ( diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts index 6f892e9f363b..e1c7471e983a 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -2,6 +2,7 @@ import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, TWAP_MIN_ORDER_NOTIONAL, + buildActiveTwapRuntimeInfoById, formatTwapPriceForDisplay, getActiveTwapRuntimeStatus, getTwapElapsedMs, @@ -148,4 +149,24 @@ describe('hyperliquidTwapUtils', () => { }), ).toBe('activated'); }); + + it('keeps the latest reported status and activation time for each TWAP', () => { + expect( + buildActiveTwapRuntimeInfoById?.([ + { + twapId: 7, + time: 1_718_000_000, + status: { status: 'waitingForTrigger' }, + }, + { + twapId: 7, + time: 1_718_000_120, + status: { status: 'activated' }, + }, + ]).get(7), + ).toEqual({ + reportedStatus: 'activated', + activatedAt: 1_718_000_120_000, + }); + }); }); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts index b79095ad30ba..2f8172d63602 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -14,6 +14,49 @@ export type ITwapRuntimeStatus = | 'terminated' | 'waitingForTrigger'; +export type IActiveTwapRuntimeInfo = { + reportedStatus: 'activated' | 'waitingForTrigger'; + activatedAt?: number; +}; + +function normalizeTwapHistoryTimeMs(time: number): number { + return time > 1_000_000_000_000 ? time : time * 1000; +} + +export function buildActiveTwapRuntimeInfoById( + records: readonly { + twapId?: number; + time: number; + status: { status: ITwapRuntimeStatus }; + }[], +): Map { + const latestRecordByTwapId = new Map(); + records.forEach((record) => { + if (record.twapId === undefined) { + return; + } + const previous = latestRecordByTwapId.get(record.twapId); + if (!previous || record.time > previous.time) { + latestRecordByTwapId.set(record.twapId, record); + } + }); + return new Map( + Array.from(latestRecordByTwapId.entries()).map(([twapId, record]) => [ + twapId, + { + reportedStatus: + record.status.status === 'waitingForTrigger' + ? 'waitingForTrigger' + : 'activated', + activatedAt: + record.status.status === 'activated' + ? normalizeTwapHistoryTimeMs(record.time) + : undefined, + }, + ]), + ); +} + export function getActiveTwapRuntimeStatus({ reportedStatus, triggerPrice, From 30248c4626f1524b963ac2e62f1db5f9a6f6e0f6 Mon Sep 17 00:00:00 2001 From: Zen Date: Wed, 12 Aug 2026 22:42:11 +0800 Subject: [PATCH 04/18] fix: correlate Hyperliquid TWAP runtime status --- .../List/PerpOpenOrdersList.tsx | 11 +++--- .../OrderInfoPanel/List/PerpTwapList.tsx | 13 ++++--- .../src/utils/hyperliquidTwapUtils.test.ts | 35 ++++++++++++++++--- .../shared/src/utils/hyperliquidTwapUtils.ts | 30 ++++++++++------ 4 files changed, 64 insertions(+), 25 deletions(-) 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 5a1177317411..667eefb79e1c 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpOpenOrdersList.tsx @@ -27,8 +27,9 @@ import { import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { - buildActiveTwapRuntimeInfoById, + buildActiveTwapRuntimeInfoByKey, getActiveTwapRuntimeStatus, + getTwapRuntimeInfoKey, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { IPerpsFrontendOrder } from '@onekeyhq/shared/types/hyperliquid/sdk'; @@ -283,8 +284,8 @@ function PerpOpenOrdersList({ twapHistoryState.history, ], ); - const activeTwapRuntimeInfoById = useMemo( - () => buildActiveTwapRuntimeInfoById(scopedTwapHistory), + const activeTwapRuntimeInfoByKey = useMemo( + () => buildActiveTwapRuntimeInfoByKey(scopedTwapHistory), [scopedTwapHistory], ); const openOrders = useMemo( @@ -506,7 +507,9 @@ function PerpOpenOrdersList({ onHoverChange?: (index: number | null) => void, ) => { if (item.type === 'twap') { - const runtimeInfo = activeTwapRuntimeInfoById.get(item.order.twapId); + const runtimeInfo = activeTwapRuntimeInfoByKey.get( + getTwapRuntimeInfoKey(item.order.state), + ); const status = getActiveTwapRuntimeStatus({ reportedStatus: runtimeInfo?.reportedStatus, triggerPrice: item.order.state.trigger?.px, 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 d9828f4c830a..ebbd57b7dbc1 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/List/PerpTwapList.tsx @@ -35,10 +35,11 @@ import { import { ETranslations } from '@onekeyhq/shared/src/locale'; import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils'; import { - buildActiveTwapRuntimeInfoById, + buildActiveTwapRuntimeInfoByKey, formatTwapPriceForDisplay, getActiveTwapRuntimeStatus, getTwapElapsedMs, + getTwapRuntimeInfoKey, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils'; import { @@ -1395,8 +1396,8 @@ function PerpTwapList({ return rawHistory; }, [currentAccountAddress, historyAccountAddress, rawHistory]); - const activeRuntimeInfoByTwapId = useMemo( - () => buildActiveTwapRuntimeInfoById(historyRows), + const activeRuntimeInfoByKey = useMemo( + () => buildActiveTwapRuntimeInfoByKey(historyRows), [historyRows], ); @@ -1802,7 +1803,9 @@ function PerpTwapList({ isHovered?: boolean, onHoverChange?: (index: number | null) => void, ) => { - const runtimeInfo = activeRuntimeInfoByTwapId.get(item.twapId); + const runtimeInfo = activeRuntimeInfoByKey.get( + getTwapRuntimeInfoKey(item.state), + ); const status = getActiveTwapRuntimeStatus({ reportedStatus: runtimeInfo?.reportedStatus, triggerPrice: item.state.trigger?.px, @@ -1830,7 +1833,7 @@ function PerpTwapList({ }, [ activeColumns, - activeRuntimeInfoByTwapId, + activeRuntimeInfoByKey, activeMinWidth, handleTerminate, now, diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts index e1c7471e983a..9264aae0e5f0 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -2,7 +2,7 @@ import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, TWAP_MIN_ORDER_NOTIONAL, - buildActiveTwapRuntimeInfoById, + buildActiveTwapRuntimeInfoByKey, formatTwapPriceForDisplay, getActiveTwapRuntimeStatus, getTwapElapsedMs, @@ -152,21 +152,46 @@ describe('hyperliquidTwapUtils', () => { it('keeps the latest reported status and activation time for each TWAP', () => { expect( - buildActiveTwapRuntimeInfoById?.([ + buildActiveTwapRuntimeInfoByKey?.([ { - twapId: 7, time: 1_718_000_000, + state: { coin: 'ETH', timestamp: 1_717_999_900_000 }, status: { status: 'waitingForTrigger' }, }, { - twapId: 7, time: 1_718_000_120, + state: { coin: 'ETH', timestamp: 1_717_999_900_000 }, status: { status: 'activated' }, }, - ]).get(7), + ]).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 index 2f8172d63602..bdd1c034e775 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -19,30 +19,38 @@ export type IActiveTwapRuntimeInfo = { 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 buildActiveTwapRuntimeInfoById( +export function buildActiveTwapRuntimeInfoByKey( records: readonly { - twapId?: number; time: number; + state: { + coin: string; + timestamp: number; + }; status: { status: ITwapRuntimeStatus }; }[], -): Map { - const latestRecordByTwapId = new Map(); +): Map { + const latestRecordByKey = new Map(); records.forEach((record) => { - if (record.twapId === undefined) { - return; - } - const previous = latestRecordByTwapId.get(record.twapId); + const key = getTwapRuntimeInfoKey(record.state); + const previous = latestRecordByKey.get(key); if (!previous || record.time > previous.time) { - latestRecordByTwapId.set(record.twapId, record); + latestRecordByKey.set(key, record); } }); return new Map( - Array.from(latestRecordByTwapId.entries()).map(([twapId, record]) => [ - twapId, + Array.from(latestRecordByKey.entries()).map(([key, record]) => [ + key, { reportedStatus: record.status.status === 'waitingForTrigger' From 83501874eead54ff2c19690b62439bcd62e224a0 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 00:02:21 +0800 Subject: [PATCH 05/18] fix: upgrade Hyperliquid SDK for TWAP --- package.json | 2 +- .../ServiceHyperLiquid/ServiceHyperliquid.ts | 22 +- .../ServiceHyperliquidExchange.ts | 23 +- .../ServiceHyperliquidSubscription.ts | 70 ++-- .../jotai/contexts/hyperliquid/actions.ts | 37 +- .../TradingPanel/TradingButtonGroup.tsx | 36 +- .../TradingPanel/panels/PerpTradingForm.tsx | 7 +- .../src/views/Perp/hooks/useOrderConfirm.ts | 32 +- .../hooks/useTradingCalculationsForSide.ts | 24 +- .../utils/hyperliquidPortfolioUtils.test.ts | 24 ++ .../src/utils/hyperliquidPortfolioUtils.ts | 21 +- .../src/utils/hyperliquidTwapSdkPatch.test.ts | 34 +- .../src/utils/hyperliquidTwapUtils.test.ts | 58 ++++ .../shared/src/utils/hyperliquidTwapUtils.ts | 35 ++ packages/shared/types/hyperliquid/sdk.ts | 9 +- packages/shared/types/hyperliquid/types.ts | 1 + patches/@nktkas+hyperliquid+0.32.2.patch | 320 ------------------ patches/@nktkas+hyperliquid+0.33.3.patch | 36 ++ patches/@nktkas+rews+2.0.2.patch | 180 ---------- yarn.lock | 64 ++-- 20 files changed, 414 insertions(+), 621 deletions(-) delete mode 100644 patches/@nktkas+hyperliquid+0.32.2.patch create mode 100644 patches/@nktkas+hyperliquid+0.33.3.patch delete mode 100644 patches/@nktkas+rews+2.0.2.patch diff --git a/package.json b/package.json index 3a8386b6c91b..ef00005db312 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts index c73557b01527..aa1216dd3b0d 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,9 @@ 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 balances = (spotStateData?.spotState?.balances || []).filter( + (balance): balance is ISpotBalance => 'token' in balance, + ); await spotBalancesAtom.set({ balances, isLoaded: true }); @@ -3324,7 +3327,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 +3338,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 +3353,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 +3372,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 +3425,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 2a77191ffc42..8e601e57daa3 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -35,6 +35,7 @@ import { assertValidScaleOrderLegs, buildScaleOrderLegs, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; +import { isTwapStopPriceValid } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { normalizeDexCoin } from '@onekeyhq/shared/src/utils/perpsDexUtils'; import { MAX_DECIMALS_PERP, @@ -1421,6 +1422,21 @@ export default class ServiceHyperliquidExchange extends ServiceBase { if (triggerPrice && typeof params.triggerAbove !== 'boolean') { throw new OneKeyLocalError('TWAP trigger direction is required'); } + if ( + stopPrice && + !isTwapStopPriceValid({ + isBuy: params.isBuy, + stopPrice, + referencePrice: params.referencePrice, + triggerPrice, + }) + ) { + throw new OneKeyLocalError( + params.isBuy + ? 'TWAP maximum price must be above the market and trigger price' + : 'TWAP minimum price must be below the market and trigger price', + ); + } const twap = { a: params.assetId, b: params.isBuy, @@ -1445,6 +1461,7 @@ export default class ServiceHyperliquidExchange extends ServiceBase { reduceOnly, minutes: params.minutes, randomize: params.randomize, + referencePrice: params.referencePrice, }, details, }; @@ -1741,11 +1758,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.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts index da1ff4d7eda3..0e9d293344da 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], @@ -1636,9 +1634,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 +1714,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 +1727,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); } @@ -2527,7 +2545,7 @@ export default class ServiceHyperliquidSubscription extends ServiceBase { } 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; diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index 3f76da814f64..ebd060535103 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -61,6 +61,7 @@ import { TWAP_MIN_ORDER_NOTIONAL, getTwapTriggerAbove, getTwapTriggerReferencePrice, + isTwapStopPriceValid, isTwapTotalNotionalValid, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -2399,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 }); }, ); @@ -2936,13 +2945,20 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { ); } } - if (stopPrice) { - const stopPriceBN = new BigNumber(stopPrice); - if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) { - throw new OneKeyLocalError( - 'TWAP stop price must be a positive number', - ); - } + 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({ @@ -3019,6 +3035,7 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { triggerPrice, triggerAbove, stopPrice, + referencePrice: markPriceBN.toFixed(), szDecimals, }, ); diff --git a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx index cb74afe13a5a..918eb0cc7c18 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx @@ -74,6 +74,7 @@ import { TWAP_MIN_DURATION_MINUTES, getTwapTriggerAbove, getTwapTriggerReferencePrice, + isTwapStopPriceValid, isTwapTotalNotionalValid, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -996,6 +997,13 @@ function SideButtonInternal({ }); return 'invalidTwapConfig' as const; } + if ( + !latestTwapTriggerReferencePriceBN.isFinite() || + latestTwapTriggerReferencePriceBN.lte(0) + ) { + Toast.error({ title: 'Market price unavailable, please try again' }); + return 'marketDataUnavailable' as const; + } const triggerPrice = latestFormData.twapTriggerPrice?.trim(); if ( triggerPrice && @@ -1012,16 +1020,22 @@ function SideButtonInternal({ return 'invalidTwapConfig' as const; } const stopPrice = latestFormData.twapStopPrice?.trim(); - if (stopPrice) { - const stopPriceBN = new BigNumber(stopPrice); - if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) { - Toast.message({ - title: intl.formatMessage({ - id: ETranslations.perps_input_price_place_holder, - }), - }); - return 'invalidTwapConfig' as const; - } + if ( + stopPrice && + !isTwapStopPriceValid({ + isBuy: validationSide === 'long', + stopPrice, + referencePrice: latestTwapTriggerReferencePriceBN, + triggerPrice, + }) + ) { + Toast.message({ + title: + validationSide === 'long' + ? 'Maximum price must be above the market and trigger price.' + : 'Minimum price must be below the market and trigger price.', + }); + return 'invalidTwapConfig' as const; } } @@ -1116,7 +1130,7 @@ function SideButtonInternal({ if ( !isTwapTotalNotionalValid({ size: latestComputedSizeForSide, - price: latestEffectivePriceBN, + price: latestTwapTriggerReferencePriceBN, }) ) { Toast.message({ 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 7045ac0785f8..896849b93301 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx @@ -894,7 +894,11 @@ 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 = isSpot + ? midPriceBN + : new BigNumber(activeAssetData?.markPx ?? ''); + } else if (formData.orderMode === 'trigger' && formData.triggerOrderType) { price = getTriggerEffectivePrice({ triggerOrderType: formData.triggerOrderType, triggerPrice: formData.triggerPrice, @@ -929,6 +933,7 @@ function PerpTradingForm({ formData.executionPrice, formData.scaleLowerPrice, formData.scaleUpperPrice, + activeAssetData?.markPx, isSpot, midPriceBN, sizeSzDecimals, diff --git a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts index e0d939f77c23..cd0d2088028a 100644 --- a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts +++ b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts @@ -32,6 +32,7 @@ import { TWAP_MIN_DURATION_MINUTES, getTwapTriggerAbove, getTwapTriggerReferencePrice, + isTwapStopPriceValid, isTwapTotalNotionalValid, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; @@ -78,7 +79,7 @@ function useOrderConfirmWithMarketDataFreshness({ const hyperliquidActions = useHyperliquidActions(); const [isSubmitting] = useTradingLoadingAtom(); const { midPrice, midPriceBN } = useTradingPrice(); - const twapTriggerReferencePriceBN = useMemo( + const twapReferencePriceBN = useMemo( () => getTwapTriggerReferencePrice({ isSpot: activeTradeInstrument.mode === 'spot', @@ -356,7 +357,7 @@ function useOrderConfirmWithMarketDataFreshness({ }); 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.', @@ -367,7 +368,7 @@ function useOrderConfirmWithMarketDataFreshness({ if (triggerPrice) { const triggerAbove = getTwapTriggerAbove({ triggerPrice, - markPrice: twapTriggerReferencePriceBN, + markPrice: twapReferencePriceBN, }); if (typeof triggerAbove !== 'boolean') { const triggerPriceBN = new BigNumber(triggerPrice); @@ -375,7 +376,7 @@ function useOrderConfirmWithMarketDataFreshness({ title: 'Order Failed', message: triggerPriceBN.isFinite() && - triggerPriceBN.eq(twapTriggerReferencePriceBN) + triggerPriceBN.eq(twapReferencePriceBN) ? intl.formatMessage({ id: ETranslations.perps_trigger_price_equal_current, }) @@ -388,13 +389,20 @@ function useOrderConfirmWithMarketDataFreshness({ } const stopPrice = formDataSnapshot.twapStopPrice?.trim(); if (stopPrice) { - const stopPriceBN = new BigNumber(stopPrice); - if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) { + if ( + !isTwapStopPriceValid({ + isBuy: side === 'long', + stopPrice, + referencePrice: twapReferencePriceBN, + triggerPrice, + }) + ) { Toast.error({ title: 'Order Failed', - message: intl.formatMessage({ - id: ETranslations.perps_input_price_place_holder, - }), + message: + side === 'long' + ? 'Maximum price must be above the market and trigger price.' + : 'Minimum price must be below the market and trigger price.', }); return; } @@ -402,7 +410,7 @@ function useOrderConfirmWithMarketDataFreshness({ if ( !isTwapTotalNotionalValid({ size: twapSize, - price: midPriceBN, + price: twapReferencePriceBN, }) ) { Toast.error({ @@ -464,7 +472,7 @@ function useOrderConfirmWithMarketDataFreshness({ await hyperliquidActions.current.submitOrder({ assetId: activeTradeInstrument.assetId, formData: effectiveFormData, - price: midPrice || '0', + price: twapReferencePriceBN.toFixed(), }); options?.onSuccess?.(); } catch (error) { @@ -569,7 +577,7 @@ function useOrderConfirmWithMarketDataFreshness({ shortOrderPrice, intl, shouldBlockForMarketData, - twapTriggerReferencePriceBN, + twapReferencePriceBN, ], ); diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts index 4941e0b51bd7..466458bd64da 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts @@ -174,6 +174,11 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { return new BigNumber(markPx ?? 0); }, [activeAssetData?.markPx, effectiveSpotPriceBN, isSpot]); + const calculationPriceBN = useMemo( + () => (formData.orderMode === 'twap' ? markPxBN : effectivePriceBN), + [effectivePriceBN, formData.orderMode, markPxBN], + ); + const availableMarginBN = useMemo(() => { if (isSpot) { if (side === 'long') { @@ -247,7 +252,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { } return computeMaxTradeSize({ side, - price: effectivePriceBN.isFinite() ? effectivePriceBN.toFixed() : '', + price: calculationPriceBN.isFinite() ? calculationPriceBN.toFixed() : '', markPrice: activeAssetData?.markPx, maxSize: scaleReduceOnlyMaxSizeBN, maxTradeSzs: effectiveMaxTradeSzs, @@ -257,7 +262,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { }); }, [ side, - effectivePriceBN, + calculationPriceBN, activeAssetData?.markPx, scaleReduceOnlyMaxSizeBN, effectiveMaxTradeSzs, @@ -298,7 +303,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { manualSize: formData.size, sizePercent: formData.sizePercent, side, - price: effectivePriceBN.isFinite() ? effectivePriceBN.toFixed() : '', + price: calculationPriceBN.isFinite() ? calculationPriceBN.toFixed() : '', markPrice: activeAssetData?.markPx, maxSize: scaleReduceOnlyMaxSizeBN, maxTradeSzs: effectiveMaxTradeSzs, @@ -311,7 +316,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { formData.size, formData.sizePercent, side, - effectivePriceBN, + calculationPriceBN, activeAssetData?.markPx, scaleReduceOnlyMaxSizeBN, effectiveMaxTradeSzs, @@ -324,8 +329,8 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { ]); const orderValue = useMemo( - () => computedSizeForSide.multipliedBy(effectivePriceBN), - [computedSizeForSide, effectivePriceBN], + () => computedSizeForSide.multipliedBy(calculationPriceBN), + [calculationPriceBN, computedSizeForSide], ); const marginRequired = useMemo( @@ -391,19 +396,20 @@ 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); }, [ computedSizeForSide, + calculationPriceBN, effectivePriceBN, availableMarginBN, leverage, 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 index 29ef4a991fd9..508bdfe9598e 100644 --- a/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapSdkPatch.test.ts @@ -5,14 +5,11 @@ describe('Hyperliquid TWAP SDK patch', () => { const output = execFileSync( process.execPath, [ + '--input-type=module', '-e', ` - const v = require( - './node_modules/@nktkas/hyperliquid/node_modules/valibot' - ); - const { TwapOrderRequest } = require( - './node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js' - ); + 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', @@ -43,4 +40,29 @@ describe('Hyperliquid TWAP SDK patch', () => { }, }); }); + + 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 index 9264aae0e5f0..0c2c61163727 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -8,6 +8,7 @@ import { getTwapElapsedMs, getTwapTriggerAbove, getTwapTriggerReferencePrice, + isTwapStopPriceValid, isTwapTotalNotionalValid, isValidTwapDuration, } from './hyperliquidTwapUtils'; @@ -77,6 +78,63 @@ describe('hyperliquidTwapUtils', () => { ).toBe(false); }); + it('keeps stop prices beyond the market and trigger 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('preserves the wire precision of TWAP prices for display', () => { expect(formatTwapPriceForDisplay('0.000012345')).toBe('0.000012345'); expect(formatTwapPriceForDisplay('12345.678')).toBe('12,345.678'); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts index bdd1c034e775..ccbd7f1fad2d 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -158,6 +158,41 @@ export function getTwapTriggerAbove({ 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 = isBuy + ? BigNumber.max(referencePriceBN, triggerPriceBN) + : BigNumber.min(referencePriceBN, triggerPriceBN); + return isBuy + ? stopPriceBN.gt(activationBoundary) + : stopPriceBN.lt(activationBoundary); +} + export function getTwapElapsedMs({ status, timestamp, 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 963b8d7ee29f..e8911c9ad823 100644 --- a/packages/shared/types/hyperliquid/types.ts +++ b/packages/shared/types/hyperliquid/types.ts @@ -267,6 +267,7 @@ export interface IPlaceTwapOrderParams { triggerPrice?: string; triggerAbove?: boolean; stopPrice?: string; + referencePrice: string; szDecimals?: number; } diff --git a/patches/@nktkas+hyperliquid+0.32.2.patch b/patches/@nktkas+hyperliquid+0.32.2.patch deleted file mode 100644 index a70c28ffd9bc..000000000000 --- a/patches/@nktkas+hyperliquid+0.32.2.patch +++ /dev/null @@ -1,320 +0,0 @@ -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 a27bcc4..7268aca 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,10 +19,22 @@ export declare const TwapOrderRequest: v.ObjectSchema<{ - /** 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>; -+ /** Trigger and stop prices. */ -+ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; -+ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ -+ readonly a: v.BooleanSchema; -+ }, undefined>, undefined>; -+ /** Price at which the order is terminated. */ -+ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; -+ }, undefined>, undefined>; - }, undefined>; - /** Nonce (timestamp in ms) used to prevent replay attacks. */ - readonly nonce: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>; -@@ -82,10 +94,22 @@ declare const TwapOrderActionSchema: v.ObjectSchema<{ - /** 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>; -+ /** Trigger and stop prices. */ -+ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; -+ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ -+ readonly a: v.BooleanSchema; -+ }, undefined>, undefined>; -+ /** Price at which the order is terminated. */ -+ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; -+ }, undefined>, undefined>; - }, undefined>; - /** Action parameters for the {@linkcode twapOrder} function. */ - export type TwapOrderParameters = Omit, "type">; -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 7022cd8..73cfcc9 100644 ---- a/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js -+++ b/node_modules/@nktkas/hyperliquid/esm/api/exchange/_methods/twapOrder.js -@@ -25,10 +25,24 @@ export const TwapOrderRequest = /* @__PURE__ */ (() => { - /** Is reduce-only? */ - r: v.boolean(), - /** TWAP duration in minutes. */ -- m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(1440)), -+ // OneKey patch: Hyperliquid now supports TWAP durations up to seven days. -+ m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(10080)), - /** Enable random order timing. */ - t: v.boolean(), - }), -+ // OneKey patch: Backport trigger and stop details without upgrading the breaking SDK release. -+ /** Trigger and stop prices. */ -+ details: v.optional(v.object({ -+ /** Condition that activates the order. */ -+ t: v.nullable(v.object({ -+ /** Trigger price. */ -+ p: UnsignedDecimal, -+ /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ -+ a: v.boolean(), -+ })), -+ /** Price at which the order is terminated. */ -+ s: v.nullable(UnsignedDecimal), -+ })), - }), - /** Nonce (timestamp in ms) used to prevent replay attacks. */ - nonce: UnsignedInteger, -diff --git a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts -index 23bd8d5..28a242d 100644 ---- a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts -+++ b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/_base/commonSchemas.d.ts -@@ -216,6 +216,8 @@ export type TwapStateSchema = { - reduceOnly: boolean; - /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ - side: "B" | "A"; -+ /** Price at which the order is terminated; null when unset. */ -+ stopPx: string | null; - /** - * Order size. - * @pattern ^[0-9]+(\.[0-9]+)?$ -@@ -223,6 +225,13 @@ export type TwapStateSchema = { - sz: string; - /** Start time of the TWAP order (in ms since epoch). */ - timestamp: number; -+ /** Condition that activates the order; null when unset. */ -+ trigger: { -+ /** Trigger price. */ -+ px: string; -+ /** Activates when the mark price is above or below the trigger price. */ -+ above: boolean; -+ } | null; - /** - * User address. - * @pattern ^0x[a-fA-F0-9]{40}$ -diff --git a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts -index 4621907..4dfadaf 100644 ---- a/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts -+++ b/node_modules/@nktkas/hyperliquid/esm/api/info/_methods/twapHistory.d.ts -@@ -29,7 +29,7 @@ export type TwapHistoryResponse = { - */ - status: { - /** Status of the TWAP order. */ -- status: "finished" | "activated" | "terminated"; -+ status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; - } | { - /** Status of the TWAP order. */ - status: "error"; -diff --git a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts -index a27bcc4..7268aca 100644 ---- a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts -+++ b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.d.ts -@@ -19,10 +19,22 @@ export declare const TwapOrderRequest: v.ObjectSchema<{ - /** 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>; -+ /** Trigger and stop prices. */ -+ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; -+ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ -+ readonly a: v.BooleanSchema; -+ }, undefined>, undefined>; -+ /** Price at which the order is terminated. */ -+ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; -+ }, undefined>, undefined>; - }, undefined>; - /** Nonce (timestamp in ms) used to prevent replay attacks. */ - readonly nonce: v.SchemaWithPipe, v.NumberSchema], undefined>, v.ToNumberAction, v.NumberSchema, v.SafeIntegerAction, v.MinValueAction]>; -@@ -82,10 +94,22 @@ declare const TwapOrderActionSchema: v.ObjectSchema<{ - /** 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>; -+ /** Trigger and stop prices. */ -+ readonly details: v.OptionalSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>; -+ /** Activate when the mark price is above (\`true\`) or below (\`false\`) the trigger price. */ -+ readonly a: v.BooleanSchema; -+ }, undefined>, undefined>; -+ /** Price at which the order is terminated. */ -+ readonly s: v.NullableSchema, v.NumberSchema], undefined>, v.ToStringAction, v.StringSchema, v.TransformAction, v.RegexAction]>, undefined>; -+ }, undefined>, undefined>; - }, undefined>; - /** Action parameters for the {@linkcode twapOrder} function. */ - export type TwapOrderParameters = Omit, "type">; -diff --git a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js -index 87cbd96..efffc7c 100644 ---- a/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js -+++ b/node_modules/@nktkas/hyperliquid/script/api/exchange/_methods/twapOrder.js -@@ -62,10 +62,24 @@ exports.TwapOrderRequest = (() => { - /** Is reduce-only? */ - r: v.boolean(), - /** TWAP duration in minutes. */ -- m: v.pipe(_schemas_js_1.UnsignedInteger, v.minValue(5), v.maxValue(1440)), -+ // OneKey patch: Hyperliquid now supports TWAP durations up to seven days. -+ m: v.pipe(_schemas_js_1.UnsignedInteger, v.minValue(5), v.maxValue(10080)), - /** Enable random order timing. */ - t: v.boolean(), - }), -+ // OneKey patch: Backport trigger and stop details without upgrading the breaking SDK release. -+ /** Trigger and stop prices. */ -+ details: v.optional(v.object({ -+ /** Condition that activates the order. */ -+ t: v.nullable(v.object({ -+ /** Trigger price. */ -+ p: _schemas_js_1.UnsignedDecimal, -+ /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ -+ a: v.boolean(), -+ })), -+ /** Price at which the order is terminated. */ -+ s: v.nullable(_schemas_js_1.UnsignedDecimal), -+ })), - }), - /** Nonce (timestamp in ms) used to prevent replay attacks. */ - nonce: _schemas_js_1.UnsignedInteger, -diff --git a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts -index 23bd8d5..28a242d 100644 ---- a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts -+++ b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/_base/commonSchemas.d.ts -@@ -216,6 +216,8 @@ export type TwapStateSchema = { - reduceOnly: boolean; - /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ - side: "B" | "A"; -+ /** Price at which the order is terminated; null when unset. */ -+ stopPx: string | null; - /** - * Order size. - * @pattern ^[0-9]+(\.[0-9]+)?$ -@@ -223,6 +225,13 @@ export type TwapStateSchema = { - sz: string; - /** Start time of the TWAP order (in ms since epoch). */ - timestamp: number; -+ /** Condition that activates the order; null when unset. */ -+ trigger: { -+ /** Trigger price. */ -+ px: string; -+ /** Activates when the mark price is above or below the trigger price. */ -+ above: boolean; -+ } | null; - /** - * User address. - * @pattern ^0x[a-fA-F0-9]{40}$ -diff --git a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts -index 4621907..4dfadaf 100644 ---- a/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts -+++ b/node_modules/@nktkas/hyperliquid/script/api/info/_methods/twapHistory.d.ts -@@ -29,7 +29,7 @@ export type TwapHistoryResponse = { - */ - status: { - /** Status of the TWAP order. */ -- status: "finished" | "activated" | "terminated"; -+ status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; - } | { - /** Status of the TWAP order. */ - status: "error"; -diff --git a/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts b/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts -index 9b6bab6..b8b65da 100644 ---- a/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts -+++ b/node_modules/@nktkas/hyperliquid/src/api/exchange/_methods/twapOrder.ts -@@ -28,10 +28,24 @@ export const TwapOrderRequest = /* @__PURE__ */ (() => { - /** Is reduce-only? */ - r: v.boolean(), - /** TWAP duration in minutes. */ -- m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(1440)), -+ // OneKey patch: Hyperliquid now supports TWAP durations up to seven days. -+ m: v.pipe(UnsignedInteger, v.minValue(5), v.maxValue(10080)), - /** Enable random order timing. */ - t: v.boolean(), - }), -+ // OneKey patch: Backport trigger and stop details without upgrading the breaking SDK release. -+ /** Trigger and stop prices. */ -+ details: v.optional(v.object({ -+ /** Condition that activates the order. */ -+ t: v.nullable(v.object({ -+ /** Trigger price. */ -+ p: UnsignedDecimal, -+ /** Activate when the mark price is above (`true`) or below (`false`) the trigger price. */ -+ a: v.boolean(), -+ })), -+ /** Price at which the order is terminated. */ -+ s: v.nullable(UnsignedDecimal), -+ })), - }), - /** Nonce (timestamp in ms) used to prevent replay attacks. */ - nonce: UnsignedInteger, -diff --git a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts -index d58d766..9ee2578 100644 ---- a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts -+++ b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/_base/commonSchemas.ts -@@ -233,6 +233,8 @@ export type TwapStateSchema = { - reduceOnly: boolean; - /** Order side ("B" = Bid/Buy, "A" = Ask/Sell). */ - side: "B" | "A"; -+ /** Price at which the order is terminated; `null` when unset. */ -+ stopPx: string | null; - /** - * Order size. - * @pattern ^[0-9]+(\.[0-9]+)?$ -@@ -240,6 +242,13 @@ export type TwapStateSchema = { - sz: string; - /** Start time of the TWAP order (in ms since epoch). */ - timestamp: number; -+ /** Condition that activates the order; `null` when unset. */ -+ trigger: { -+ /** Trigger price. */ -+ px: string; -+ /** Activates when the mark price is above (`true`) or below (`false`) the trigger price. */ -+ above: boolean; -+ } | null; - /** - * User address. - * @pattern ^0x[a-fA-F0-9]{40}$ -diff --git a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts -index e7f982d..2c56195 100644 ---- a/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts -+++ b/node_modules/@nktkas/hyperliquid/src/api/info/_methods/twapHistory.ts -@@ -35,11 +35,13 @@ export type TwapHistoryResponse = { - * - `"finished"`: Fully executed. - * - `"activated"`: Active and executing. - * - `"terminated"`: Terminated. -+ * - `"waitingForTrigger"`: Awaiting the trigger price. -+ * - `"stopped"`: Terminated by the stop price. - * - `"error"`: An error occurred. - */ - status: { - /** Status of the TWAP order. */ -- status: "finished" | "activated" | "terminated"; -+ status: "finished" | "activated" | "terminated" | "waitingForTrigger" | "stopped"; - } | { - /** Status of the TWAP order. */ - status: "error"; 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..c84604d3e6f2 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,10 +8452,10 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:^2": - version: 2.0.1 - resolution: "@noble/hashes@npm:2.0.1" - checksum: 10/f4d00e7564eb4ff4e6d16be151dd0e404aede35f91e4372b0a8a6ec888379c1dd1e02c721b480af8e7853bea9637185b5cb9533970c5b77d60c254ead0cfd8f7 +"@noble/hashes@npm:^2.2.0": + version: 2.3.0 + resolution: "@noble/hashes@npm:2.3.0" + checksum: 10/2a980fa3257269d0f2f0c65ad30033a395121a523cb207bd5e481054cabf21af3cce87a8d657a3d7409924ec28f7fcaaf3fb01150813ac3c9e16964a95ae0ab7 languageName: node linkType: hard @@ -9786,7 +9787,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 +26682,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 +48209,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 +48247,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" From c3b440be49bf8a275ba702df7a87392aa590a6d9 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 00:12:31 +0800 Subject: [PATCH 06/18] fix: pin Hyperliquid hash dependency --- package.json | 1 + yarn.lock | 7 ------- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/package.json b/package.json index ef00005db312..6182b1372858 100644 --- a/package.json +++ b/package.json @@ -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/yarn.lock b/yarn.lock index c84604d3e6f2..87fe8ab9aa84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8452,13 +8452,6 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:^2.2.0": - version: 2.3.0 - resolution: "@noble/hashes@npm:2.3.0" - checksum: 10/2a980fa3257269d0f2f0c65ad30033a395121a523cb207bd5e481054cabf21af3cce87a8d657a3d7409924ec28f7fcaaf3fb01150813ac3c9e16964a95ae0ab7 - languageName: node - linkType: hard - "@noble/hashes@npm:~1.7.1": version: 1.7.1 resolution: "@noble/hashes@npm:1.7.1" From 49369a1d61d731680a54c45e0d3748dd2068efbf Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 00:23:34 +0800 Subject: [PATCH 07/18] fix: align open order types with SDK --- .../Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx index 37156ae4f5be..3bbbf6609cdb 100644 --- a/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx +++ b/packages/kit/src/views/Perp/components/OrderInfoPanel/Components/OpenOrdersRow.tsx @@ -214,7 +214,7 @@ const OpenOrdersRow = memo( ]); const tpslInfo = useMemo(() => { - const tpslChildren = (order.children ?? []) as IPerpsFrontendOrder[]; + const tpslChildren = order.children ?? []; let tpPrice = '--'; let slPrice = '--'; if (tpslChildren && tpslChildren.length > 0) { From ae79df45429b87948400142276eca951bac0e79f Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 00:40:48 +0800 Subject: [PATCH 08/18] fix: normalize TWAP boundary prices --- .../ServiceHyperliquidExchange.ts | 11 +++-- .../jotai/contexts/hyperliquid/actions.ts | 26 ++++++++---- .../TradingPanel/TradingButtonGroup.tsx | 41 ++++++++++++------- .../src/views/Perp/hooks/useOrderConfirm.ts | 34 +++++++++++++-- .../src/utils/hyperliquidTwapUtils.test.ts | 18 ++++++++ .../shared/src/utils/hyperliquidTwapUtils.ts | 16 ++++++++ 6 files changed, 115 insertions(+), 31 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts index 8e601e57daa3..37c1b1b61be7 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -35,7 +35,10 @@ import { assertValidScaleOrderLegs, buildScaleOrderLegs, } from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils'; -import { isTwapStopPriceValid } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; +import { + formatTwapPriceForOrder, + isTwapStopPriceValid, +} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { normalizeDexCoin } from '@onekeyhq/shared/src/utils/perpsDexUtils'; import { MAX_DECIMALS_PERP, @@ -1402,11 +1405,11 @@ export default class ServiceHyperliquidExchange extends ServiceBase { if (!price) { return undefined; } - const formattedPrice = formatHlPrice( + const formattedPrice = formatTwapPriceForOrder({ price, szDecimals, - assetType ?? 'perp', - ); + assetType: assetType ?? 'perp', + }); if (!formattedPrice) { throw new OneKeyLocalError( `TWAP ${fieldName} price is too small for HL tick size`, diff --git a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts index ebd060535103..54e27b7c8e26 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -59,6 +59,7 @@ import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, TWAP_MIN_ORDER_NOTIONAL, + formatTwapPriceForOrder, getTwapTriggerAbove, getTwapTriggerReferencePrice, isTwapStopPriceValid, @@ -73,7 +74,6 @@ import { import { classifyTpSlOrder } from '@onekeyhq/shared/src/utils/perpsTpSlUtils'; import { findTokensByAlias, - formatHlPrice, formatPriceToSignificantDigits, formatSpotAssetCtx, getTriggerEffectivePrice, @@ -2920,14 +2920,17 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { 2) : (activeAssetValue?.universe?.szDecimals ?? env.szDecimals ?? 2); const rawTriggerPrice = formData.twapTriggerPrice?.trim(); - const triggerPrice = rawTriggerPrice - ? formatHlPrice( - rawTriggerPrice, - szDecimals, - isSpot ? 'spot' : 'perp', - ) - : undefined; - const stopPrice = formData.twapStopPrice?.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( @@ -2945,6 +2948,11 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { ); } } + if (rawStopPrice && !stopPrice) { + throw new OneKeyLocalError( + 'TWAP stop price is too small for HL tick size', + ); + } if ( stopPrice && !isTwapStopPriceValid({ diff --git a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx index 918eb0cc7c18..43cbeb169a5b 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx @@ -72,6 +72,7 @@ import { import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, + formatTwapPriceForOrder, getTwapTriggerAbove, getTwapTriggerReferencePrice, isTwapStopPriceValid, @@ -1004,13 +1005,19 @@ function SideButtonInternal({ Toast.error({ title: 'Market price unavailable, please try again' }); return 'marketDataUnavailable' as const; } - const triggerPrice = latestFormData.twapTriggerPrice?.trim(); + const rawTriggerPrice = latestFormData.twapTriggerPrice?.trim(); + const triggerPrice = formatTwapPriceForOrder({ + price: rawTriggerPrice, + szDecimals: latestSzDecimals, + assetType: latestIsSpot ? 'spot' : 'perp', + }); if ( - triggerPrice && - typeof getTwapTriggerAbove({ - triggerPrice, - markPrice: latestTwapTriggerReferencePriceBN, - }) !== 'boolean' + rawTriggerPrice && + (!triggerPrice || + typeof getTwapTriggerAbove({ + triggerPrice, + markPrice: latestTwapTriggerReferencePriceBN, + }) !== 'boolean') ) { Toast.message({ title: intl.formatMessage({ @@ -1019,15 +1026,21 @@ function SideButtonInternal({ }); return 'invalidTwapConfig' as const; } - const stopPrice = latestFormData.twapStopPrice?.trim(); + const rawStopPrice = latestFormData.twapStopPrice?.trim(); + const stopPrice = formatTwapPriceForOrder({ + price: rawStopPrice, + szDecimals: latestSzDecimals, + assetType: latestIsSpot ? 'spot' : 'perp', + }); if ( - stopPrice && - !isTwapStopPriceValid({ - isBuy: validationSide === 'long', - stopPrice, - referencePrice: latestTwapTriggerReferencePriceBN, - triggerPrice, - }) + rawStopPrice && + (!stopPrice || + !isTwapStopPriceValid({ + isBuy: validationSide === 'long', + stopPrice, + referencePrice: latestTwapTriggerReferencePriceBN, + triggerPrice, + })) ) { Toast.message({ title: diff --git a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts index cd0d2088028a..894f1b3bb5ff 100644 --- a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts +++ b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts @@ -30,6 +30,7 @@ import { import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, + formatTwapPriceForOrder, getTwapTriggerAbove, getTwapTriggerReferencePrice, isTwapStopPriceValid, @@ -346,6 +347,9 @@ function useOrderConfirmWithMarketDataFreshness({ return; } const isSpotOrder = activeTradeInstrument.mode === 'spot'; + const szDecimals = isSpotOrder + ? (activeTradeInstrument.universe?.baseSzDecimals ?? 2) + : (activeTradeInstrument.universe?.szDecimals ?? 2); const twapSize = side === 'long' ? longCalculations.computedSizeForSide @@ -364,8 +368,22 @@ function useOrderConfirmWithMarketDataFreshness({ }); return; } - const triggerPrice = formDataSnapshot.twapTriggerPrice?.trim(); - if (triggerPrice) { + const rawTriggerPrice = formDataSnapshot.twapTriggerPrice?.trim(); + const triggerPrice = formatTwapPriceForOrder({ + price: rawTriggerPrice, + szDecimals, + assetType: isSpotOrder ? 'spot' : 'perp', + }); + if (rawTriggerPrice) { + if (!triggerPrice) { + Toast.error({ + title: 'Order Failed', + message: intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + }); + return; + } const triggerAbove = getTwapTriggerAbove({ triggerPrice, markPrice: twapReferencePriceBN, @@ -387,9 +405,15 @@ function useOrderConfirmWithMarketDataFreshness({ return; } } - const stopPrice = formDataSnapshot.twapStopPrice?.trim(); - if (stopPrice) { + const rawStopPrice = formDataSnapshot.twapStopPrice?.trim(); + const stopPrice = formatTwapPriceForOrder({ + price: rawStopPrice, + szDecimals, + assetType: isSpotOrder ? 'spot' : 'perp', + }); + if (rawStopPrice) { if ( + !stopPrice || !isTwapStopPriceValid({ isBuy: side === 'long', stopPrice, @@ -464,6 +488,8 @@ function useOrderConfirmWithMarketDataFreshness({ price: '', bboPriceMode: null, hasTpsl: false, + twapTriggerPrice: triggerPrice ?? '', + twapStopPrice: stopPrice ?? '', twapReduceOnly: isSpotOrder ? false : formDataSnapshot.twapReduceOnly, }; diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts index 0c2c61163727..2b84b8936088 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -4,6 +4,7 @@ import { TWAP_MIN_ORDER_NOTIONAL, buildActiveTwapRuntimeInfoByKey, formatTwapPriceForDisplay, + formatTwapPriceForOrder, getActiveTwapRuntimeStatus, getTwapElapsedMs, getTwapTriggerAbove, @@ -141,6 +142,23 @@ describe('hyperliquidTwapUtils', () => { 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( diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts index ccbd7f1fad2d..ee344d9b3656 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -1,6 +1,7 @@ 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; @@ -109,6 +110,21 @@ export function formatTwapPriceForDisplay(price?: string | null): string { 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) && From 0a4ed3c2ed2022472a7f37fd8ea69e66be27b374 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 11:43:23 +0800 Subject: [PATCH 09/18] fix: harden TWAP form market data --- package.json | 2 +- .../ServiceHyperliquidExchange.ts | 34 ++++++++++-- .../TradingPanel/TradingButtonGroup.tsx | 28 ++++++++-- .../TradingPanel/panels/PerpTradingForm.tsx | 13 +++-- .../src/views/Perp/hooks/useOrderConfirm.ts | 50 +++++++++++------ .../useTradingCalculationsForSide.test.ts | 55 +++++++++++++++++++ .../hooks/useTradingCalculationsForSide.ts | 32 ++++++++--- 7 files changed, 173 insertions(+), 41 deletions(-) create mode 100644 packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts diff --git a/package.json b/package.json index 6182b1372858..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','')}}\"", diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts index 37c1b1b61be7..537af6bbac84 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -1392,7 +1392,11 @@ 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; @@ -1411,8 +1415,16 @@ export default class ServiceHyperliquidExchange extends ServiceBase { 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( - `TWAP ${fieldName} price is too small for HL tick size`, + appLocale.intl.formatMessage({ + id: messageId, + }), ); } return formattedPrice; @@ -1423,7 +1435,11 @@ export default class ServiceHyperliquidExchange extends ServiceBase { ); const stopPrice = formatOptionalTwapPrice(params.stopPrice, 'stop'); if (triggerPrice && typeof params.triggerAbove !== 'boolean') { - throw new OneKeyLocalError('TWAP trigger direction is required'); + throw new OneKeyLocalError( + appLocale.intl.formatMessage({ + id: ETranslations.perps_input_trigger_price, + }), + ); } if ( stopPrice && @@ -1435,9 +1451,15 @@ export default class ServiceHyperliquidExchange extends ServiceBase { }) ) { throw new OneKeyLocalError( - params.isBuy - ? 'TWAP maximum price must be above the market and trigger price' - : 'TWAP minimum price must be below the market and trigger price', + `${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 = { diff --git a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx index 43cbeb169a5b..32c9285eae28 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx @@ -994,7 +994,13 @@ function SideButtonInternal({ const duration = Number(latestFormData.twapDurationMinutes ?? 0); if (!isValidTwapDuration(duration)) { Toast.message({ - title: `TWAP duration must be ${TWAP_MIN_DURATION_MINUTES}-${TWAP_MAX_DURATION_MINUTES} minutes`, + title: intl.formatMessage( + { id: ETranslations.perp_twap_duration_range__msg }, + { + min: TWAP_MIN_DURATION_MINUTES, + max: TWAP_MAX_DURATION_MINUTES, + }, + ), }); return 'invalidTwapConfig' as const; } @@ -1002,7 +1008,11 @@ function SideButtonInternal({ !latestTwapTriggerReferencePriceBN.isFinite() || latestTwapTriggerReferencePriceBN.lte(0) ) { - Toast.error({ title: 'Market price unavailable, please try again' }); + Toast.error({ + title: intl.formatMessage({ + id: ETranslations.provider_unavailable, + }), + }); return 'marketDataUnavailable' as const; } const rawTriggerPrice = latestFormData.twapTriggerPrice?.trim(); @@ -1043,10 +1053,16 @@ function SideButtonInternal({ })) ) { Toast.message({ - title: - validationSide === 'long' - ? 'Maximum price must be above the market and trigger price.' - : 'Minimum price must be below the market and trigger price.', + 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; } 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 896849b93301..59320095733f 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx @@ -46,6 +46,7 @@ import { usePerpsActiveAccountEnableTradingModeAtom, usePerpsActiveAccountStatusAtom, usePerpsActiveAssetAtom, + usePerpsActiveAssetCtxAtom, usePerpsActiveAssetCtxReadyAtom, usePerpsActiveAssetDataAtom, usePerpsCommonConfigPersistAtom, @@ -68,6 +69,7 @@ import { import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, + getTwapTriggerReferencePrice, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { numberFormat } from '@onekeyhq/shared/src/utils/numberUtils'; @@ -489,6 +491,7 @@ function PerpTradingForm({ const intl = useIntl(); const actions = useHyperliquidActions(); const [activeAsset] = usePerpsActiveAssetAtom(); + const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [isPerpsActiveAssetCtxReady] = usePerpsActiveAssetCtxReadyAtom(); const [spotActiveAsset] = useSpotActiveAssetAtom(); const [isSpotActiveAssetCtxReady] = useSpotActiveAssetCtxReadyAtom(); @@ -895,9 +898,11 @@ function PerpTradingForm({ const [, referencePriceString] = useMemo(() => { let price = new BigNumber(0); if (formData.orderMode === 'twap') { - price = isSpot - ? midPriceBN - : new BigNumber(activeAssetData?.markPx ?? ''); + price = getTwapTriggerReferencePrice({ + isSpot, + midPrice: midPriceBN, + markPrice: activeAssetCtx?.ctx?.markPrice, + }); } else if (formData.orderMode === 'trigger' && formData.triggerOrderType) { price = getTriggerEffectivePrice({ triggerOrderType: formData.triggerOrderType, @@ -933,7 +938,7 @@ function PerpTradingForm({ formData.executionPrice, formData.scaleLowerPrice, formData.scaleUpperPrice, - activeAssetData?.markPx, + activeAssetCtx?.ctx?.markPrice, isSpot, midPriceBN, sizeSzDecimals, diff --git a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts index 894f1b3bb5ff..eff327a99f67 100644 --- a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts +++ b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts @@ -341,8 +341,14 @@ function useOrderConfirmWithMarketDataFreshness({ const duration = Number(formDataSnapshot.twapDurationMinutes ?? 0); 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; } @@ -356,15 +362,21 @@ function useOrderConfirmWithMarketDataFreshness({ : 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 (!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; } @@ -377,7 +389,7 @@ function useOrderConfirmWithMarketDataFreshness({ if (rawTriggerPrice) { if (!triggerPrice) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: intl.formatMessage({ id: ETranslations.perps_input_trigger_price, }), @@ -391,7 +403,7 @@ function useOrderConfirmWithMarketDataFreshness({ if (typeof triggerAbove !== 'boolean') { const triggerPriceBN = new BigNumber(triggerPrice); Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: triggerPriceBN.isFinite() && triggerPriceBN.eq(twapReferencePriceBN) @@ -422,11 +434,17 @@ function useOrderConfirmWithMarketDataFreshness({ }) ) { Toast.error({ - title: 'Order Failed', - message: - side === 'long' - ? 'Maximum price must be above the market and trigger price.' - : 'Minimum price must be below the market and trigger price.', + 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; } @@ -438,7 +456,7 @@ function useOrderConfirmWithMarketDataFreshness({ }) ) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: intl.formatMessage({ id: ETranslations.perp_scale_order_size_too_small__msg, }), @@ -453,7 +471,7 @@ function useOrderConfirmWithMarketDataFreshness({ }); if (snapshotError) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: snapshotError, }); return; @@ -475,7 +493,7 @@ function useOrderConfirmWithMarketDataFreshness({ }); if (reduceOnlyError) { Toast.error({ - title: 'Order Failed', + title: intl.formatMessage({ id: ETranslations.global_failed }), message: reduceOnlyError, }); return; 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..91e139b20543 --- /dev/null +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts @@ -0,0 +1,55 @@ +/* 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, +}; + +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: () => [{ ctx: { markPrice: '100' } }], + usePerpsActiveAssetDataAtom: () => [undefined], + 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', () => { + 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'); + }); +}); diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts index 466458bd64da..68ea5c9c9d70 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,25 @@ 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; + } 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), @@ -253,7 +269,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { return computeMaxTradeSize({ side, price: calculationPriceBN.isFinite() ? calculationPriceBN.toFixed() : '', - markPrice: activeAssetData?.markPx, + markPrice: calculationMarkPrice, maxSize: scaleReduceOnlyMaxSizeBN, maxTradeSzs: effectiveMaxTradeSzs, leverageValue: activeAssetData?.leverage?.value, @@ -263,7 +279,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { }, [ side, calculationPriceBN, - activeAssetData?.markPx, + calculationMarkPrice, scaleReduceOnlyMaxSizeBN, effectiveMaxTradeSzs, activeAssetData?.leverage?.value, @@ -304,7 +320,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { sizePercent: formData.sizePercent, side, price: calculationPriceBN.isFinite() ? calculationPriceBN.toFixed() : '', - markPrice: activeAssetData?.markPx, + markPrice: calculationMarkPrice, maxSize: scaleReduceOnlyMaxSizeBN, maxTradeSzs: effectiveMaxTradeSzs, leverageValue: activeAssetData?.leverage?.value, @@ -317,7 +333,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { formData.sizePercent, side, calculationPriceBN, - activeAssetData?.markPx, + calculationMarkPrice, scaleReduceOnlyMaxSizeBN, effectiveMaxTradeSzs, activeAssetData?.leverage?.value, From dc32cfe0863926455e288bcf600fafe533e10fbb Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 12:09:48 +0800 Subject: [PATCH 10/18] fix: validate triggered TWAP stop boundary --- .../src/utils/hyperliquidTwapUtils.test.ts | 21 ++++++++++++++++++- .../shared/src/utils/hyperliquidTwapUtils.ts | 4 +--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts index 2b84b8936088..e3d81ee5b842 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.test.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.test.ts @@ -79,7 +79,7 @@ describe('hyperliquidTwapUtils', () => { ).toBe(false); }); - it('keeps stop prices beyond the market and trigger activation boundary', () => { + it('keeps stop prices beyond the activation boundary', () => { expect( isTwapStopPriceValid({ isBuy: true, @@ -136,6 +136,25 @@ describe('hyperliquidTwapUtils', () => { ).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'); diff --git a/packages/shared/src/utils/hyperliquidTwapUtils.ts b/packages/shared/src/utils/hyperliquidTwapUtils.ts index ee344d9b3656..62a51b0ca11c 100644 --- a/packages/shared/src/utils/hyperliquidTwapUtils.ts +++ b/packages/shared/src/utils/hyperliquidTwapUtils.ts @@ -201,9 +201,7 @@ export function isTwapStopPriceValid({ ) { return false; } - const activationBoundary = isBuy - ? BigNumber.max(referencePriceBN, triggerPriceBN) - : BigNumber.min(referencePriceBN, triggerPriceBN); + const activationBoundary = triggerPriceBN; return isBuy ? stopPriceBN.gt(activationBoundary) : stopPriceBN.lt(activationBoundary); From e03e8cc35c9480db98df95d75d389fc7083c0a74 Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 12:21:02 +0800 Subject: [PATCH 11/18] fix: stabilize TWAP calculations and ping monitoring --- .../ServiceHyperliquidSubscription.test.ts | 23 ++++++++++++++++ .../ServiceHyperliquidSubscription.ts | 10 ++++++- .../useTradingCalculationsForSide.test.ts | 26 +++++++++++++++++-- .../hooks/useTradingCalculationsForSide.ts | 2 +- 4 files changed, 57 insertions(+), 4 deletions(-) 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 0e9d293344da..e0d718ad3270 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidSubscription.ts @@ -184,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 @@ -2540,9 +2542,10 @@ 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.ping(); @@ -2553,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/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts index 91e139b20543..0640cd708a9e 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts @@ -15,6 +15,12 @@ const mockFormData = { 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' }], @@ -28,8 +34,8 @@ jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ universe: { maxLeverage: 20, szDecimals: 4 }, }, ], - usePerpsActiveAssetCtxAtom: () => [{ ctx: { markPrice: '100' } }], - usePerpsActiveAssetDataAtom: () => [undefined], + usePerpsActiveAssetCtxAtom: () => [mockActiveAssetCtx], + usePerpsActiveAssetDataAtom: () => [mockActiveAssetData], useSpotBalancesAtom: () => [{ balances: [] }], })); @@ -46,10 +52,26 @@ jest.mock('./useTradingPrice', () => ({ })); 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'); + }); }); diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts index 68ea5c9c9d70..0fd7729fa0a5 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts @@ -174,7 +174,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { if (isSpot) { markPx = effectiveSpotPriceBN.toFixed(); } else if (formData.orderMode === 'twap') { - markPx = activeAssetCtx?.ctx?.markPrice; + markPx = activeAssetCtx?.ctx?.markPrice ?? activeAssetData?.markPx; } return new BigNumber(markPx ?? 0); }, [ From 96405115bf6240f2ebc580c18f36a8b651316a7d Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 12:22:06 +0800 Subject: [PATCH 12/18] chore: format TWAP calculation test --- .../views/Perp/hooks/useTradingCalculationsForSide.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts index 0640cd708a9e..d4d1cedebc26 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts @@ -15,9 +15,9 @@ const mockFormData = { scaleReduceOnly: false, twapReduceOnly: false, }; -let mockActiveAssetCtx: - | { ctx: { markPrice: string } } - | undefined = { ctx: { markPrice: '100' } }; +let mockActiveAssetCtx: { ctx: { markPrice: string } } | undefined = { + ctx: { markPrice: '100' }, +}; let mockActiveAssetData: | { leverage: { value: number }; markPx: string } | undefined; From 2bc551da512a823900b8da659b2ab17596c13fba Mon Sep 17 00:00:00 2001 From: Zen Date: Thu, 13 Aug 2026 20:52:20 +0800 Subject: [PATCH 13/18] fix: unify TWAP reference price and normalize confirm preview --- .../kit-bg/src/states/jotai/atoms/perps.ts | 7 ++ .../jotai/contexts/hyperliquid/actions.ts | 5 +- .../TradingPanel/TradingButtonGroup.tsx | 14 +--- .../TradingPanel/modals/OrderConfirmModal.tsx | 27 +++---- .../TradingPanel/panels/PerpTradingForm.tsx | 13 ++-- .../src/views/Perp/hooks/useOrderConfirm.ts | 20 ++---- .../Perp/hooks/useTwapReferencePrice.test.ts | 71 +++++++++++++++++++ .../views/Perp/hooks/useTwapReferencePrice.ts | 35 +++++++++ 8 files changed, 142 insertions(+), 50 deletions(-) create mode 100644 packages/kit/src/views/Perp/hooks/useTwapReferencePrice.test.ts create mode 100644 packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts 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 54e27b7c8e26..38081743f5ef 100644 --- a/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts +++ b/packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts @@ -2906,7 +2906,10 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase { isSpot, midPrice: params.price ?? env.markPrice ?? '', markPrice: - activeAssetCtxValue?.ctx?.markPrice ?? env.markPrice ?? '', + activeAssetCtxValue?.ctx?.markPrice ?? + activeAssetDataValue?.markPx ?? + env.markPrice ?? + '', }); if (!markPriceBN.isFinite() || markPriceBN.lte(0)) { throw new OneKeyLocalError( diff --git a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx index 32c9285eae28..54fd916a81a2 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/TradingButtonGroup.tsx @@ -45,7 +45,6 @@ import { usePerpsActiveAccountEnableTradingModeAtom, usePerpsActiveAccountStatusAtom, usePerpsActiveAssetAtom, - usePerpsActiveAssetCtxAtom, usePerpsCommonConfigPersistAtom, usePerpsCustomSettingsAtom, usePerpsTradingPreferencesAtom, @@ -74,7 +73,6 @@ import { TWAP_MIN_DURATION_MINUTES, formatTwapPriceForOrder, getTwapTriggerAbove, - getTwapTriggerReferencePrice, isTwapStopPriceValid, isTwapTotalNotionalValid, isValidTwapDuration, @@ -101,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'; @@ -443,7 +442,6 @@ function SideButtonInternal({ ? 'usd' : tradingPreferences.sizeInputUnit; const [activeAsset] = usePerpsActiveAssetAtom(); - const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const orderContextKey = useMemo( () => @@ -464,15 +462,7 @@ function SideButtonInternal({ const [isSubmitting] = useTradingLoadingAtom(); const { midPriceBN } = useTradingPrice(); - const twapTriggerReferencePriceBN = useMemo( - () => - getTwapTriggerReferencePrice({ - isSpot, - midPrice: midPriceBN, - markPrice: activeAssetCtx?.ctx?.markPrice, - }), - [activeAssetCtx?.ctx?.markPrice, isSpot, midPriceBN], - ); + const twapTriggerReferencePriceBN = useTwapReferencePrice({ midPriceBN }); const shouldBlockForMarketData = shouldBlockPerpsTradingForMarketData(marketDataFreshness); const confirmHyperliquidTerms = useConfirmHyperliquidTerms(); 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 570a1b5df8ed..9a80483a883e 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,23 +255,25 @@ function OrderConfirmContent({ if (!isTwapMode) { return null; } - const triggerPrice = formData.twapTriggerPrice?.trim(); - const stopPrice = formData.twapStopPrice?.trim(); + // Preview the same wire-normalized prices the submit path sends, so the + // user confirms exactly what goes out. + 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 - ? formatOrderPriceDisplay({ - price: triggerPrice, - isSpot, - szDecimals, - }) + ? `$${formatLocalizedNumberString(triggerPrice)}` : undefined, stopPrice: stopPrice - ? formatOrderPriceDisplay({ - price: stopPrice, - isSpot, - szDecimals, - }) + ? `$${formatLocalizedNumberString(stopPrice)}` : undefined, }; }, [ 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 59320095733f..e38feacd81de 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx @@ -46,7 +46,6 @@ import { usePerpsActiveAccountEnableTradingModeAtom, usePerpsActiveAccountStatusAtom, usePerpsActiveAssetAtom, - usePerpsActiveAssetCtxAtom, usePerpsActiveAssetCtxReadyAtom, usePerpsActiveAssetDataAtom, usePerpsCommonConfigPersistAtom, @@ -69,7 +68,6 @@ import { import { TWAP_MAX_DURATION_MINUTES, TWAP_MIN_DURATION_MINUTES, - getTwapTriggerReferencePrice, isValidTwapDuration, } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils'; import { numberFormat } from '@onekeyhq/shared/src/utils/numberUtils'; @@ -95,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'; @@ -491,7 +490,6 @@ function PerpTradingForm({ const intl = useIntl(); const actions = useHyperliquidActions(); const [activeAsset] = usePerpsActiveAssetAtom(); - const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [isPerpsActiveAssetCtxReady] = usePerpsActiveAssetCtxReadyAtom(); const [spotActiveAsset] = useSpotActiveAssetAtom(); const [isSpotActiveAssetCtxReady] = useSpotActiveAssetCtxReadyAtom(); @@ -500,6 +498,7 @@ function PerpTradingForm({ const { midPrice, midPriceBN } = useTradingPrice({ source: tradingPriceSource, }); + const twapReferencePriceBN = useTwapReferencePrice({ midPriceBN }); const { price: orderPriceBN } = useOrderPrice(formData.side, { priceSource: tradingPriceSource, }); @@ -898,11 +897,7 @@ function PerpTradingForm({ const [, referencePriceString] = useMemo(() => { let price = new BigNumber(0); if (formData.orderMode === 'twap') { - price = getTwapTriggerReferencePrice({ - isSpot, - midPrice: midPriceBN, - markPrice: activeAssetCtx?.ctx?.markPrice, - }); + price = twapReferencePriceBN; } else if (formData.orderMode === 'trigger' && formData.triggerOrderType) { price = getTriggerEffectivePrice({ triggerOrderType: formData.triggerOrderType, @@ -938,7 +933,7 @@ function PerpTradingForm({ formData.executionPrice, formData.scaleLowerPrice, formData.scaleUpperPrice, - activeAssetCtx?.ctx?.markPrice, + twapReferencePriceBN, isSpot, midPriceBN, sizeSzDecimals, diff --git a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts index eff327a99f67..e5704f927d33 100644 --- a/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts +++ b/packages/kit/src/views/Perp/hooks/useOrderConfirm.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { BigNumber } from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -11,10 +11,7 @@ import { useTradingFormAtom, useTradingLoadingAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid'; -import { - usePerpsActiveAccountAtom, - usePerpsActiveAssetCtxAtom, -} from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import { usePerpsActiveAccountAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { SCALE_ORDER_MAX_COUNT, @@ -32,7 +29,6 @@ import { TWAP_MIN_DURATION_MINUTES, formatTwapPriceForOrder, getTwapTriggerAbove, - getTwapTriggerReferencePrice, isTwapStopPriceValid, isTwapTotalNotionalValid, isValidTwapDuration, @@ -54,6 +50,7 @@ import { useOrderPrice } from './useOrderPrice'; import { usePerpsMarketDataFreshness } from './usePerpsMarketDataFreshness'; import { useTradingCalculationsForSide } from './useTradingCalculationsForSide'; import { useTradingPrice } from './useTradingPrice'; +import { useTwapReferencePrice } from './useTwapReferencePrice'; interface IUseOrderConfirmOptions { onSuccess?: () => void; @@ -75,20 +72,11 @@ function useOrderConfirmWithMarketDataFreshness({ const [formData] = useTradingFormAtom(); const [activeTradeInstrument] = useActiveTradeInstrumentAtom(); const [currentUser] = usePerpsActiveAccountAtom(); - const [activeAssetCtx] = usePerpsActiveAssetCtxAtom(); const [activePositionsValue] = usePerpsActivePositionAtom(); const hyperliquidActions = useHyperliquidActions(); const [isSubmitting] = useTradingLoadingAtom(); const { midPrice, midPriceBN } = useTradingPrice(); - const twapReferencePriceBN = useMemo( - () => - getTwapTriggerReferencePrice({ - isSpot: activeTradeInstrument.mode === 'spot', - midPrice: midPriceBN, - markPrice: activeAssetCtx?.ctx?.markPrice, - }), - [activeAssetCtx?.ctx?.markPrice, activeTradeInstrument.mode, midPriceBN], - ); + const twapReferencePriceBN = useTwapReferencePrice({ midPriceBN }); const shouldBlockForMarketData = shouldBlockPerpsTradingForMarketData(marketDataFreshness); 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..7a7c8d47f7a5 --- /dev/null +++ b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts @@ -0,0 +1,35 @@ +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 of the TWAP reference price so form display, CTA validation, +// and submit agree; falls back to webData2 markPx while activeAssetCtx lags. +// midPriceBN comes from the caller's useTradingPrice to avoid an extra live +// mid subscription in callers that are on the display source. +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], + ); +} From b6becfd4a04ab6a1f78d6e92e9510996a9e11823 Mon Sep 17 00:00:00 2001 From: Zen Date: Fri, 14 Aug 2026 01:54:10 +0800 Subject: [PATCH 14/18] fix: omit empty TWAP details and repair lint --- .../ServiceHyperliquidExchange.ts | 19 ++++++++++++------- .../views/Perp/hooks/useTwapReferencePrice.ts | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts index 537af6bbac84..2dda80d1108e 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -1470,12 +1470,17 @@ export default class ServiceHyperliquidExchange extends ServiceBase { m: params.minutes, t: params.randomize, }; - const details = { - t: triggerPrice - ? { p: triggerPrice, a: params.triggerAbove as boolean } - : null, - s: stopPrice ?? null, - }; + // Omit the optional details wrapper entirely for plain TWAPs so the wire + // action keeps the shape production already validated before 0.33.x. + 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 = { @@ -1495,7 +1500,7 @@ export default class ServiceHyperliquidExchange extends ServiceBase { const response = await convertHyperLiquidResponse(() => client.twapOrder({ twap, - details, + ...(details ? { details } : {}), }), ); defaultLogger.perp.hyperliquid.twapOrder({ diff --git a/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts index 7a7c8d47f7a5..c6c41065bef0 100644 --- a/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts +++ b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts @@ -1,12 +1,12 @@ 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 of the TWAP reference price so form display, CTA validation, From 764fbe781ba42cefe6a4a208b42dfc7fe4578146 Mon Sep 17 00:00:00 2001 From: Zen Date: Fri, 14 Aug 2026 02:14:18 +0800 Subject: [PATCH 15/18] chore: log excluded outcome spot balances --- .../ServiceHyperLiquid/ServiceHyperliquid.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts index aa1216dd3b0d..5044f99c5c91 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts @@ -2090,9 +2090,21 @@ 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 || []).filter( + const allBalances = spotStateData?.spotState?.balances || []; + const balances = allBalances.filter( (balance): balance is ISpotBalance => 'token' in balance, ); + // Outcome-market balances have no token id, price feed, or UI support; + // they are excluded from spot state but logged so the omission is visible. + 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 }); From b11d55b4c6bfe7d9ea28eeaf3c53d39404284d99 Mon Sep 17 00:00:00 2001 From: Zen Date: Fri, 14 Aug 2026 02:44:36 +0800 Subject: [PATCH 16/18] chore: tighten comments and formatting --- .../src/services/ServiceHyperLiquid/ServiceHyperliquid.ts | 7 +++---- .../ServiceHyperLiquid/ServiceHyperliquidExchange.ts | 3 +-- .../components/TradingPanel/modals/OrderConfirmModal.tsx | 3 +-- packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts | 7 +++---- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts index 5044f99c5c91..3b3340b4efdc 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquid.ts @@ -2094,11 +2094,10 @@ export default class ServiceHyperliquid extends ServiceBase { const balances = allBalances.filter( (balance): balance is ISpotBalance => 'token' in balance, ); - // Outcome-market balances have no token id, price feed, or UI support; - // they are excluded from spot state but logged so the omission is visible. + // 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), + (balance) => !('token' in balance) && new BigNumber(balance.total).gt(0), ).length; if (droppedPositiveCount > 0) { console.warn( diff --git a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts index 2dda80d1108e..3283a1234d9e 100644 --- a/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts +++ b/packages/kit-bg/src/services/ServiceHyperLiquid/ServiceHyperliquidExchange.ts @@ -1470,8 +1470,7 @@ export default class ServiceHyperliquidExchange extends ServiceBase { m: params.minutes, t: params.randomize, }; - // Omit the optional details wrapper entirely for plain TWAPs so the wire - // action keeps the shape production already validated before 0.33.x. + // Plain TWAPs must keep the pre-0.33 wire shape production has validated. const details = triggerPrice || stopPrice ? { 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 9a80483a883e..1cdd530dcf21 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/modals/OrderConfirmModal.tsx @@ -255,8 +255,7 @@ function OrderConfirmContent({ if (!isTwapMode) { return null; } - // Preview the same wire-normalized prices the submit path sends, so the - // user confirms exactly what goes out. + // The user must confirm the exact wire prices the submit path sends. const triggerPrice = formatTwapPriceForOrder({ price: formData.twapTriggerPrice, szDecimals, diff --git a/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts index c6c41065bef0..9e52ff25b318 100644 --- a/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts +++ b/packages/kit/src/views/Perp/hooks/useTwapReferencePrice.ts @@ -9,10 +9,9 @@ import { getTwapTriggerReferencePrice } from '@onekeyhq/shared/src/utils/hyperli import type { BigNumber } from 'bignumber.js'; -// Single source of the TWAP reference price so form display, CTA validation, -// and submit agree; falls back to webData2 markPx while activeAssetCtx lags. -// midPriceBN comes from the caller's useTradingPrice to avoid an extra live -// mid subscription in callers that are on the display source. +// 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, }: { From 7f2ce0b6559d4a5fb6ab6a2a301509bfc5bb6512 Mon Sep 17 00:00:00 2001 From: Zen Date: Mon, 17 Aug 2026 09:42:14 +0800 Subject: [PATCH 17/18] feat: gate TWAP trigger and stop inputs behind advanced settings --- .../TradingPanel/panels/PerpTradingForm.tsx | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) 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 e38feacd81de..31bd5b1b9247 100644 --- a/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx +++ b/packages/kit/src/views/Perp/components/TradingPanel/panels/PerpTradingForm.tsx @@ -1063,6 +1063,10 @@ function PerpTradingForm({ 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( @@ -2136,7 +2140,7 @@ function PerpTradingForm({ }; const renderTwapDetailsSection = () => { - if (!isTwapMode) { + if (!isTwapMode || !isTwapAdvancedVisible) { return null; } @@ -2497,7 +2501,39 @@ function PerpTradingForm({ } /> + + { + 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.global_advanced_settings, + })} + + + {renderTwapDetailsSection()} ); } @@ -3022,8 +3058,6 @@ function PerpTradingForm({ {isTwapMode ? null : renderPriceInputSection()} - {renderTwapDetailsSection()} - Date: Mon, 17 Aug 2026 09:55:20 +0800 Subject: [PATCH 18/18] fix: skip margin guard until account data loads --- .../Perp/hooks/useTradingCalculationsForSide.test.ts | 9 +++++++++ .../views/Perp/hooks/useTradingCalculationsForSide.ts | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts index d4d1cedebc26..d6a32b15452f 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.test.ts @@ -74,4 +74,13 @@ describe('useTradingCalculationsForSide', () => { 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 0fd7729fa0a5..a3b08a6c91aa 100644 --- a/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts +++ b/packages/kit/src/views/Perp/hooks/useTradingCalculationsForSide.ts @@ -392,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; @@ -424,6 +430,7 @@ export function useTradingCalculationsForSide(side: 'long' | 'short') { return requiredMargin.isFinite() && requiredMargin.gt(availableMarginBN); }, [ + activeAssetData, computedSizeForSide, calculationPriceBN, effectivePriceBN,