Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions core/src/exchanges/hyperliquid/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ----------------------------------------------------------------------------
Expand All @@ -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);
Expand Down
58 changes: 42 additions & 16 deletions core/src/exchanges/hyperliquid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -310,12 +316,26 @@ export class HyperliquidExchange extends PredictionMarketExchange {

async cancelOrder(orderId: string): Promise<Order> {
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<string, unknown> = {
type: 'cancel',
cancels: [{ a: 0, o: parseInt(orderId, 10) }],
cancels: [{ a: assetId, o: oidNum }],
};

try {
Expand All @@ -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);
Expand Down
Loading