From b6762656e8c38e54747af70a5bc565c01a09c8b2 Mon Sep 17 00:00:00 2001 From: "Samuel EF. Tinnerholm" Date: Mon, 22 Jun 2026 14:26:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(hyperliquid):=20submitOrder=20surfaces=20er?= =?UTF-8?q?rors,=20cancelOrder=20uses=20real=20asset,=20msgpack=20int64?= =?UTF-8?q?=E2=86=92uint64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs caught by ground-truth live-trade verification: 1. submitOrder silently swallowed HL rejections. When HL returned statuses[0].error (e.g. 'Order must have minimum value of 10 USDC'), the SDK reported status='filled', id='unknown' — fake success. Now raises a typed PmxtError carrying the HL message. Also handles the filled status correctly (was checked but never populated price/oid). 2. cancelOrder hardcoded asset id 'a: 0'. HL rejected every cancel with 'User or API Wallet does not exist' because the recovered signer address depended on the (wrong) asset id in the signed action. Now looks up the actual order from openOrders, decodes coin → assetId, and signs the correct cancel. Also returns a typed OrderNotFound when the supplied oid isn't open. 3. msgpackr encoded positive BigInts as int64 (0xd3); HL's server runs Python msgpack which encodes the same values as uint64 (0xcf). One byte mismatch produced an action hash that recovered to a different address — HL saw 'User or API Wallet 0xfaf6... does not exist'. Added fixInt64ToUint64 post-processor: HL actions never carry negative ints (oids, nonces, asset ids are all >= 0), so flipping d3→cf when the next byte is < 0x80 is safe and matches Python's encoding. Verified end-to-end against a live wallet (0xcb85…): createOrder (BUY 100 @ $0.10, hl-outcome-553) → id=476019713300 status=open fetch_open_orders → contains the order cancelOrder(id) → status=canceled fetch_open_orders → empty cancelOrder('999999999999') → typed PmxtError 'Order not found' --- core/src/exchanges/hyperliquid/auth.ts | 18 +++++++- core/src/exchanges/hyperliquid/index.ts | 58 ++++++++++++++++++------- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/core/src/exchanges/hyperliquid/auth.ts b/core/src/exchanges/hyperliquid/auth.ts index 56672664..ecc803c7 100644 --- a/core/src/exchanges/hyperliquid/auth.ts +++ b/core/src/exchanges/hyperliquid/auth.ts @@ -60,6 +60,20 @@ function convertLargeInts(obj: unknown): unknown { return obj; } +// ponytail: msgpackr encodes positive BigInts as int64 (0xd3); HL's server uses +// Python's msgpack which encodes the same value as uint64 (0xcf). Post-process the +// packed bytes to flip d3 → cf for non-negative payloads. HL actions never carry +// negative integers (oids, nonces, asset ids are all >= 0), so this is safe. +function fixInt64ToUint64(bytes: Uint8Array): Uint8Array { + const out = Buffer.from(bytes); + for (let i = 0; i < out.length - 8; i++) { + if (out[i] === 0xd3 && (out[i + 1] & 0x80) === 0) { + out[i] = 0xcf; + } + } + return out; +} + // ---------------------------------------------------------------------------- // Action hash -- constructs the connectionId for the phantom agent // ---------------------------------------------------------------------------- @@ -69,8 +83,8 @@ function computeActionHash( vaultAddress: string | null, nonce: number, ): string { - // 1. msgpack-encode the action (large ints as int64) - const actionBytes = packr.pack(convertLargeInts(action)); + // 1. msgpack-encode the action; coerce positive int64 to uint64 to match HL's server (Python msgpack) + const actionBytes = fixInt64ToUint64(packr.pack(convertLargeInts(action))); // 2. nonce as 8 bytes big-endian const nonceBytes = Buffer.alloc(8); diff --git a/core/src/exchanges/hyperliquid/index.ts b/core/src/exchanges/hyperliquid/index.ts index 7e388677..f5d0fa37 100644 --- a/core/src/exchanges/hyperliquid/index.ts +++ b/core/src/exchanges/hyperliquid/index.ts @@ -27,7 +27,7 @@ import { HyperliquidNormalizer } from './normalizer'; import { HyperliquidAuth, floatToWire } from './auth'; import { hyperliquidErrorMapper } from './errors'; import { FetcherContext } from '../interfaces'; -import { fromMarketId } from './utils'; +import { fromMarketId, encodeAssetId, fromCoinEncoding } from './utils'; export interface HyperliquidExchangeOptions { credentials?: ExchangeCredentials; @@ -284,18 +284,24 @@ export class HyperliquidExchange extends PredictionMarketExchange { ); } - const resting = data.response?.data?.statuses?.[0]; + const status0 = data.response?.data?.statuses?.[0]; + // HL returns one of: { resting: { oid } } | { filled: { oid, totalSz, avgPx } } | { error: string } + if (status0?.error) { + throw hyperliquidErrorMapper.mapError(new Error(status0.error)); + } + const oid = status0?.resting?.oid ?? status0?.filled?.oid; + const filledSz = status0?.filled?.totalSz ? parseFloat(status0.filled.totalSz) : 0; return { - id: resting?.resting?.oid ? String(resting.resting.oid) : 'unknown', + id: oid !== undefined ? String(oid) : 'unknown', marketId: built.params.marketId, outcomeId: built.params.outcomeId, side: built.params.side, type: built.params.type, - price: built.params.price, + price: status0?.filled?.avgPx ? parseFloat(status0.filled.avgPx) : built.params.price, amount: built.params.amount, - status: resting?.resting ? 'open' : 'filled', - filled: resting?.filled?.totalSz ? parseFloat(resting.filled.totalSz) : 0, - remaining: built.params.amount - (resting?.filled?.totalSz ? parseFloat(resting.filled.totalSz) : 0), + status: status0?.resting ? 'open' : (status0?.filled ? 'filled' : 'pending'), + filled: filledSz, + remaining: built.params.amount - filledSz, timestamp: Date.now(), }; } catch (error: any) { @@ -310,12 +316,26 @@ export class HyperliquidExchange extends PredictionMarketExchange { async cancelOrder(orderId: string): Promise { const auth = this.requireAuth(); + const wallet = this.requireWallet(); + + // HL needs the asset id of the order being cancelled, not just the oid. + // ponytail: look it up from openOrders. One extra HTTP call per cancel, + // and gives us a typed OrderNotFound when the caller's id is stale. + const oidNum = parseInt(orderId, 10); + if (!Number.isFinite(oidNum)) { + throw new Error(`Invalid Hyperliquid order id: ${orderId}`); + } + const open = await this.fetcher.fetchRawOpenOrders(wallet); + const target = open.find(o => o.oid === oidNum); + if (!target) { + throw hyperliquidErrorMapper.mapError(new Error(`Order not found: ${orderId}`)); + } + const decoded = fromCoinEncoding(parseInt(target.coin.slice(1), 10)); + const assetId = encodeAssetId(decoded.outcomeId, decoded.side); - // Key order matters for msgpack hash: type, cancels - // Each cancel entry: a (asset), o (order id) const action: Record = { type: 'cancel', - cancels: [{ a: 0, o: parseInt(orderId, 10) }], + cancels: [{ a: assetId, o: oidNum }], }; try { @@ -332,17 +352,23 @@ export class HyperliquidExchange extends PredictionMarketExchange { throw new Error(data.response || 'Cancel failed'); } + const status0 = data.response?.data?.statuses?.[0]; + if (status0?.error) { + throw hyperliquidErrorMapper.mapError(new Error(status0.error)); + } + return { id: orderId, - marketId: '', - outcomeId: '', - side: 'buy', + marketId: this.normalizer['coinToMarketId'](target.coin), + outcomeId: this.normalizer['coinToOutcomeId'](target.coin), + side: target.side === 'B' ? 'buy' : 'sell', type: 'limit', - amount: 0, + price: parseFloat(target.limitPx), + amount: parseFloat(target.sz), status: 'canceled', filled: 0, - remaining: 0, - timestamp: Date.now(), + remaining: parseFloat(target.sz), + timestamp: target.timestamp, }; } catch (error: any) { throw hyperliquidErrorMapper.mapError(error);