Skip to content

Commit 2e264de

Browse files
Hiksangclaude
andcommitted
feat(outcome): add Hyperliquid Outcome markets (HIP-4) support
Adds new asset class: HL outcome markets (binary/range contracts, fully collateralized, no leverage, no liquidation, USDH-quoted, $10 min order). Currently 1 live market: BTC binary daily settling at 06:00 UTC. New surface: perp outcome list # active markets perp outcome book <outcome> <side> [--depth N] # orderbook perp outcome positions # holdings perp outcome orders # open orders perp outcome buy <outcome> <side> <usd> # IoC by default, --limit for GTC perp outcome sell <outcome> <side> <usd> perp outcome cancel <outcome> <side> <oid> Key facts (verified end-to-end against mainnet): - Asset id formula: 100,000,000 + (10 * outcome + side) - Coin name: '#<enc>' (l2Book/candle/allMids), '+<enc>' (spot balance) - Universe: POST /info {type:"outcomeMeta"} - Positions: filter spotClearinghouseState for '+<enc>' coins - Order/cancel actions identical to spot, only assetId differs - Quote token = USDH (NOT USDC) — outcome trades draw from spot USDH balance - Min notional: price * size >= 10 USDH - Probe scripts: scripts/probe-outcome-{ws,order}.ts (manual mainnet verify) Adapter (HyperliquidOutcomeAdapter) composes with HyperliquidAdapter for signing. Bypasses HL's cached spot state on getPositions() to surface fresh balances after fills (cache TTL would be stale). CLI uses optsWithGlobals() so the parent program's --dry-run flag is not shadowed by the subcommand's local options (commander v13 behavior). Tests: +10 unit tests covering encoding, coin formatting, description parsing. Total: 1307 → 1317. SSOT compliance: - Rule #2: unknown outcome/side throws SYMBOL_NOT_FOUND/INVALID_PARAMS; notional below $10 throws INVALID_PARAMS with remediation. - Rule #3: no new env vars, reuses existing HL agent. Out of scope for this commit (deferred): - Portfolio aggregation of outcome holdings - Landing page outcome line - close subcommand (use sell with --limit + notional for now) - HIP-4 builder/deployer mechanics Plan reference: .omc/plans/v0.13.0-outcome.md (local). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cae2ec9 commit 2e264de

7 files changed

Lines changed: 991 additions & 0 deletions

File tree

scripts/probe-outcome-order.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Probe β v2: HL Outcome order — direct sign+send (no adapter wrapper).
3+
*
4+
* Uses the agent's local EVM keypair from OWS vault, signs the L1 action,
5+
* and posts directly to /exchange. Logs full HTTP status + raw body so we
6+
* can see venue rejections that the adapter swallows.
7+
*/
8+
9+
import { encode } from "@msgpack/msgpack";
10+
import { ethers, keccak256, Wallet } from "ethers";
11+
import { getAgent } from "../src/agent-wallet/store.js";
12+
import { OwsEvmSigner } from "../src/signer/ows-evm.js";
13+
14+
const ASSET_ID = 100_000_010; // outcome=1, side=0 (Yes)
15+
const PRICE = "0.30"; // < current best bid (~$0.583) so it rests
16+
const SIZE = "40"; // 0.30 * 40 = 12 USDH notional, > $10 minimum
17+
18+
async function signAndSendCapture(action: Record<string, unknown>, signer: ethers.Signer | OwsEvmSigner): Promise<unknown> {
19+
const baseUrl = "https://api.hyperliquid.xyz";
20+
21+
// Normalize p/s trailing zeros (replicate adapter logic)
22+
const normalize = (a: Record<string, unknown>): Record<string, unknown> => {
23+
if (a.type !== "order" || !Array.isArray(a.orders)) return a;
24+
return {
25+
...a,
26+
orders: (a.orders as Record<string, unknown>[]).map(o => {
27+
const trim = (s: string) => s.includes(".") ? (s.replace(/\.?0+$/, "") || "0") : s;
28+
return { ...o, p: trim(o.p as string), s: trim(o.s as string) };
29+
}),
30+
};
31+
};
32+
const normAction = normalize(action);
33+
34+
const nonce = Date.now();
35+
const msgPackBytes = encode(normAction);
36+
const data = new Uint8Array(msgPackBytes.length + 9);
37+
data.set(msgPackBytes);
38+
new DataView(data.buffer).setBigUint64(msgPackBytes.length, BigInt(nonce), false);
39+
// last byte = 0 (no vault) — already 0 from Uint8Array
40+
41+
const hash = keccak256(data);
42+
const phantomDomain = {
43+
name: "Exchange",
44+
version: "1",
45+
chainId: 1337,
46+
verifyingContract: "0x0000000000000000000000000000000000000000",
47+
};
48+
const agentTypes = {
49+
Agent: [
50+
{ name: "source", type: "string" },
51+
{ name: "connectionId", type: "bytes32" },
52+
],
53+
};
54+
const phantomAgent = {
55+
source: "a", // mainnet
56+
connectionId: hash,
57+
};
58+
59+
const sig = await signer.signTypedData(phantomDomain, agentTypes, phantomAgent);
60+
const parsed = ethers.Signature.from(sig);
61+
62+
const payload = {
63+
action,
64+
nonce,
65+
signature: { r: parsed.r, s: parsed.s, v: parsed.v },
66+
vaultAddress: null,
67+
};
68+
69+
console.log(`\n[POST /exchange] nonce=${nonce}`);
70+
console.log(`[payload]:`, JSON.stringify(payload).slice(0, 400));
71+
72+
const res = await fetch(`${baseUrl}/exchange`, {
73+
method: "POST",
74+
headers: { "Content-Type": "application/json" },
75+
body: JSON.stringify(payload),
76+
});
77+
const text = await res.text();
78+
console.log(`[response] http=${res.status} body=${text}`);
79+
return JSON.parse(text);
80+
}
81+
82+
async function main() {
83+
console.log(`[probe-β v2] direct sign+send`);
84+
console.log(` asset_id: ${ASSET_ID} price: ${PRICE} size: ${SIZE} side: BUY`);
85+
86+
const agentMeta = getAgent("hyperliquid");
87+
if (!agentMeta) throw new Error("No HL agent — run: perp wallet agent approve hyperliquid");
88+
const agentSigner = OwsEvmSigner.create(agentMeta.agentWalletName, "");
89+
console.log(` agent: ${agentMeta.agentWalletName} (${agentMeta.agentEvmAddress})`);
90+
91+
// Place
92+
const orderAction = {
93+
type: "order",
94+
orders: [{
95+
a: ASSET_ID,
96+
b: true,
97+
p: PRICE,
98+
s: SIZE,
99+
r: false,
100+
t: { limit: { tif: "Gtc" } },
101+
}],
102+
grouping: "na",
103+
};
104+
const placeRes = await signAndSendCapture(orderAction, agentSigner as unknown as ethers.Signer);
105+
106+
// Extract OID and cancel
107+
const r = placeRes as { status?: string; response?: { data?: { statuses?: Array<{ resting?: { oid: number }; filled?: { oid: number }; error?: string }> } } };
108+
const status = r.response?.data?.statuses?.[0];
109+
if (!status) {
110+
console.error(`[place] no statuses[0] — bailing`);
111+
return;
112+
}
113+
if (status.error) {
114+
console.error(`[place] FAILED: ${status.error}`);
115+
return;
116+
}
117+
const oid = status.resting?.oid ?? status.filled?.oid;
118+
if (!oid) {
119+
console.error(`[place] no oid in response`);
120+
return;
121+
}
122+
console.log(`\n[place] OK — oid=${oid} (resting=${!!status.resting})`);
123+
124+
const cancelAction = {
125+
type: "cancel",
126+
cancels: [{ a: ASSET_ID, o: oid }],
127+
};
128+
await signAndSendCapture(cancelAction, agentSigner as unknown as ethers.Signer);
129+
console.log(`\n[probe-β v2] DONE — outcome order placement + cancel verified end-to-end`);
130+
}
131+
132+
main().catch((err) => {
133+
console.error(`[probe-β v2] FAILED:`, err instanceof Error ? err.stack : err);
134+
process.exit(1);
135+
});

scripts/probe-outcome-ws.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Probe α: HL Outcome WebSocket subscription.
3+
*
4+
* Subscribes to l2Book and trades for outcome #10 (BTC binary Yes side) and
5+
* logs the first few messages from each stream. Run with:
6+
* pnpm tsx scripts/probe-outcome-ws.ts
7+
*/
8+
9+
import WebSocket from "ws";
10+
11+
const WS_URL = "wss://api.hyperliquid.xyz/ws";
12+
const COIN = "#10";
13+
const MAX_MSGS_PER_STREAM = 3;
14+
15+
const counts = { l2Book: 0, trades: 0 };
16+
17+
const ws = new WebSocket(WS_URL);
18+
19+
ws.on("open", () => {
20+
console.log(`[ws] connected to ${WS_URL}`);
21+
22+
const subL2 = { method: "subscribe", subscription: { type: "l2Book", coin: COIN } };
23+
const subTrades = { method: "subscribe", subscription: { type: "trades", coin: COIN } };
24+
25+
ws.send(JSON.stringify(subL2));
26+
ws.send(JSON.stringify(subTrades));
27+
console.log(`[ws] subscribed: l2Book ${COIN}, trades ${COIN}`);
28+
console.log(`[ws] waiting for ${MAX_MSGS_PER_STREAM} msgs per stream...`);
29+
});
30+
31+
ws.on("message", (raw) => {
32+
let msg: { channel?: string; data?: unknown };
33+
try {
34+
msg = JSON.parse(raw.toString());
35+
} catch {
36+
console.log(`[ws] non-json: ${String(raw).slice(0, 200)}`);
37+
return;
38+
}
39+
40+
const ch = msg.channel;
41+
if (ch === "subscriptionResponse") {
42+
console.log(`[ws] sub-ack:`, JSON.stringify(msg.data));
43+
return;
44+
}
45+
46+
if (ch === "l2Book") {
47+
counts.l2Book += 1;
48+
const data = msg.data as { coin: string; time: number; levels: [unknown[], unknown[]] };
49+
const bids = data.levels[0];
50+
const asks = data.levels[1];
51+
const bestBid = bids[0] as { px: string; sz: string; n: number } | undefined;
52+
const bestAsk = asks[0] as { px: string; sz: string; n: number } | undefined;
53+
console.log(`[l2Book #${counts.l2Book}] coin=${data.coin} time=${data.time} bid=${bestBid?.px}@${bestBid?.sz} ask=${bestAsk?.px}@${bestAsk?.sz} (${bids.length}/${asks.length} levels)`);
54+
} else if (ch === "trades") {
55+
counts.trades += 1;
56+
const data = msg.data as Array<{ coin: string; side: string; px: string; sz: string; time: number }>;
57+
for (const t of data) {
58+
console.log(`[trade #${counts.trades}] ${t.coin} ${t.side === "B" ? "BUY" : "SELL"} sz=${t.sz} @ ${t.px}`);
59+
}
60+
} else {
61+
console.log(`[unknown channel=${ch}]`, JSON.stringify(msg).slice(0, 200));
62+
}
63+
64+
if (counts.l2Book >= MAX_MSGS_PER_STREAM && counts.trades >= MAX_MSGS_PER_STREAM) {
65+
console.log(`[ws] received enough samples, closing`);
66+
ws.close();
67+
}
68+
});
69+
70+
ws.on("error", (err) => {
71+
console.error(`[ws] error:`, err.message);
72+
});
73+
74+
ws.on("close", (code, reason) => {
75+
console.log(`[ws] closed code=${code} reason=${reason.toString()}`);
76+
console.log(`[summary] l2Book=${counts.l2Book} trades=${counts.trades}`);
77+
process.exit(0);
78+
});
79+
80+
setTimeout(() => {
81+
console.error(`[ws] timeout — only got l2Book=${counts.l2Book} trades=${counts.trades}`);
82+
ws.close();
83+
}, 30_000);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from "vitest";
2+
import { HyperliquidOutcomeAdapter } from "../../exchanges/hyperliquid-outcome.js";
3+
4+
describe("HyperliquidOutcomeAdapter — pure helpers", () => {
5+
describe("encoding & assetId", () => {
6+
it("encodes per HIP-4 formula 10*outcome + side", () => {
7+
expect(HyperliquidOutcomeAdapter.encoding(0, 0)).toBe(0);
8+
expect(HyperliquidOutcomeAdapter.encoding(1, 0)).toBe(10);
9+
expect(HyperliquidOutcomeAdapter.encoding(1, 1)).toBe(11);
10+
expect(HyperliquidOutcomeAdapter.encoding(7, 3)).toBe(73);
11+
});
12+
13+
it("derives asset id from offset 100,000,000 + encoding", () => {
14+
expect(HyperliquidOutcomeAdapter.assetId(0, 0)).toBe(100_000_000);
15+
expect(HyperliquidOutcomeAdapter.assetId(1, 0)).toBe(100_000_010);
16+
expect(HyperliquidOutcomeAdapter.assetId(1, 1)).toBe(100_000_011);
17+
});
18+
});
19+
20+
describe("coin name conventions", () => {
21+
it("renders mint-style coin (l2Book/candle/allMids) with `#` prefix", () => {
22+
expect(HyperliquidOutcomeAdapter.mintCoin(1, 0)).toBe("#10");
23+
expect(HyperliquidOutcomeAdapter.mintCoin(1, 1)).toBe("#11");
24+
});
25+
26+
it("renders balance coin (spotClearinghouseState) with `+` prefix", () => {
27+
expect(HyperliquidOutcomeAdapter.balanceCoin(1, 0)).toBe("+10");
28+
expect(HyperliquidOutcomeAdapter.balanceCoin(1, 1)).toBe("+11");
29+
});
30+
31+
it("decodes balance coin back to (outcome, side)", () => {
32+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("+10")).toEqual({ outcome: 1, side: 0 });
33+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("+11")).toEqual({ outcome: 1, side: 1 });
34+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("+73")).toEqual({ outcome: 7, side: 3 });
35+
});
36+
37+
it("returns null for non-outcome balance coin names", () => {
38+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("USDC")).toBeNull();
39+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("USDH")).toBeNull();
40+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("#10")).toBeNull(); // mint prefix, not balance prefix
41+
expect(HyperliquidOutcomeAdapter.decodeBalanceCoin("+abc")).toBeNull();
42+
});
43+
});
44+
45+
describe("parseDescription", () => {
46+
it("parses the live BTC binary outcome description", () => {
47+
const parsed = HyperliquidOutcomeAdapter.parseDescription(
48+
"class:priceBinary|underlying:BTC|expiry:20260504-0600|targetPrice:78213|period:1d",
49+
);
50+
expect(parsed.class).toBe("priceBinary");
51+
expect(parsed.underlying).toBe("BTC");
52+
expect(parsed.targetPrice).toBe(78213);
53+
expect(parsed.period).toBe("1d");
54+
expect(parsed.expiryMs).toBe(Date.UTC(2026, 4, 4, 6, 0));
55+
});
56+
57+
it("returns partial struct when fields missing", () => {
58+
const parsed = HyperliquidOutcomeAdapter.parseDescription("class:priceBinary|underlying:BTC");
59+
expect(parsed.class).toBe("priceBinary");
60+
expect(parsed.underlying).toBe("BTC");
61+
expect(parsed.expiryMs).toBeUndefined();
62+
expect(parsed.targetPrice).toBeUndefined();
63+
expect(parsed.period).toBeUndefined();
64+
});
65+
66+
it("ignores malformed expiry strings instead of throwing", () => {
67+
const parsed = HyperliquidOutcomeAdapter.parseDescription("expiry:not-a-date");
68+
expect(parsed.expiryMs).toBeUndefined();
69+
});
70+
71+
it("returns empty struct for empty / non-keyed description", () => {
72+
expect(HyperliquidOutcomeAdapter.parseDescription("")).toEqual({});
73+
expect(HyperliquidOutcomeAdapter.parseDescription("plain text without colons")).toEqual({});
74+
});
75+
});
76+
});

0 commit comments

Comments
 (0)