Skip to content

Commit 673c502

Browse files
Hiksangclaude
andcommitted
fix: 7 bugs + editOrder raw signing + live-verified on 3 exchanges
CRITICAL: HL editOrder no longer hardcodes "buy" — looks up existing order side via getOpenOrders() before modifying. HIGH: Lighter getMarkPrice() now throws instead of returning 0, preventing broken slippage calculations on market orders. MEDIUM: - smartOrder inferTickSize rounds to orderbook precision (fixes float noise like 0.10000000000000142) - cancelAllOrders on Pacifica/Lighter now respects symbol filter instead of always cancelling everything - HL _loadAssetMap logs errors + getAssetIndex retries once on empty map LOW: - Pacifica getRecentTrades uses API fee field when available - cli-spec reads version from package.json via createRequire BONUS: Extracted _signAndSendAction() from _rawPlaceOrder — universal EIP-712 signing for any HL exchange action. _sendExchangeAction falls back to it when SDK postAction is unavailable, fixing editOrder/TWAP/etc. All 1197 tests pass. Live-verified: market/limit/cancel/close/smart/edit across Hyperliquid, Pacifica, and Lighter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0502b11 commit 673c502

10 files changed

Lines changed: 333 additions & 74 deletions

src/__tests__/arb-auto-3dex.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,8 @@ describe("computeRoundTripCostPct", () => {
371371

372372
it("uses default slippage of 0.05%", () => {
373373
const cost = computeRoundTripCostPct("hyperliquid", "lighter");
374-
// 2 × (0.035% + 0.035%) + 2 × 0.05% = 0.24%
375-
expect(cost).toBeCloseTo(0.24, 4);
374+
// lighter taker fee = 0%, so: 2 × (0.035% + 0%) + 2 × 0.05% = 0.17%
375+
expect(cost).toBeCloseTo(0.17, 4);
376376
});
377377

378378
it("handles custom slippage", () => {

src/__tests__/bugfix-v042.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Tests for v0.4.2 bug fixes (BUG 1–7).
3+
*/
4+
import { describe, it, expect, vi } from "vitest";
5+
6+
// ── BUG 1: HL editOrder preserves original side ──
7+
describe("BUG 1: HL editOrder preserves side", () => {
8+
it("editOrder passes existing order side (sell) to modifyOrder", async () => {
9+
// Dynamically import to avoid top-level side effects
10+
const mod = await import("../exchanges/hyperliquid.js");
11+
const HyperliquidAdapter = mod.HyperliquidAdapter;
12+
13+
// Create a partial mock: override getOpenOrders and modifyOrder
14+
const adapter = Object.create(HyperliquidAdapter.prototype);
15+
adapter.getOpenOrders = vi.fn().mockResolvedValue([
16+
{ orderId: "42", symbol: "ETH", side: "sell", price: "3000", size: "0.1", filled: "0", status: "open", type: "limit" },
17+
]);
18+
adapter.modifyOrder = vi.fn().mockResolvedValue({ status: "ok" });
19+
20+
await adapter.editOrder("ETH", "42", "3100", "0.1");
21+
22+
expect(adapter.modifyOrder).toHaveBeenCalledWith("ETH", 42, "sell", "3100", "0.1");
23+
});
24+
25+
it("editOrder defaults to buy when order not found in open orders", async () => {
26+
const mod = await import("../exchanges/hyperliquid.js");
27+
const HyperliquidAdapter = mod.HyperliquidAdapter;
28+
29+
const adapter = Object.create(HyperliquidAdapter.prototype);
30+
adapter.getOpenOrders = vi.fn().mockResolvedValue([]);
31+
adapter.modifyOrder = vi.fn().mockResolvedValue({ status: "ok" });
32+
33+
await adapter.editOrder("ETH", "999", "3100", "0.1");
34+
35+
// Falls back to "buy" when order not found
36+
expect(adapter.modifyOrder).toHaveBeenCalledWith("ETH", 999, "buy", "3100", "0.1");
37+
});
38+
});
39+
40+
// ── BUG 2: Lighter getMarkPrice throws on 0 ──
41+
describe("BUG 2: Lighter getMarkPrice throws on zero", () => {
42+
it("marketOrder throws when mark price is 0", async () => {
43+
const mod = await import("../exchanges/lighter.js");
44+
const LighterAdapter = mod.LighterAdapter;
45+
46+
// Create mock adapter that simulates getMarkPrice returning 0
47+
const adapter = Object.create(LighterAdapter.prototype);
48+
adapter._readOnly = false;
49+
adapter._signer = {};
50+
adapter._marketMap = new Map([["BTC", 0]]);
51+
adapter._marketDecimals = new Map([["BTC", { size: 4, price: 2 }]]);
52+
adapter.ensureSigner = vi.fn();
53+
adapter.getNextNonce = vi.fn().mockResolvedValue(1);
54+
adapter.getMarketIndex = vi.fn().mockReturnValue(0);
55+
adapter.toTicks = vi.fn().mockReturnValue({ baseAmount: 10000, priceTicks: 0 });
56+
57+
// Mock restGet to return 0 mark price
58+
adapter.restGet = vi.fn().mockResolvedValue({
59+
order_book_details: [{ symbol: "BTC", last_trade_price: 0 }],
60+
});
61+
62+
await expect(adapter.marketOrder("BTC", "buy", "0.001")).rejects.toThrow(
63+
/Cannot determine mark price/
64+
);
65+
});
66+
});
67+
68+
// ── BUG 3: inferTickSize float precision ──
69+
// (Covered in smart-order.test.ts — "rounds tick size to eliminate floating-point noise")
70+
71+
// ── BUG 4: cancelAllOrders respects symbol filter ──
72+
describe("BUG 4: cancelAllOrders respects symbol filter", () => {
73+
it("Pacifica cancelAllOrders filters by symbol", async () => {
74+
const mod = await import("../exchanges/pacifica.js");
75+
const PacificaAdapter = mod.PacificaAdapter;
76+
77+
const adapter = Object.create(PacificaAdapter.prototype);
78+
adapter.getOpenOrders = vi.fn().mockResolvedValue([
79+
{ orderId: "1", symbol: "SOL", side: "buy", price: "100", size: "1", filled: "0", status: "open", type: "limit" },
80+
{ orderId: "2", symbol: "BTC", side: "sell", price: "50000", size: "0.01", filled: "0", status: "open", type: "limit" },
81+
{ orderId: "3", symbol: "SOL", side: "sell", price: "110", size: "1", filled: "0", status: "open", type: "limit" },
82+
]);
83+
adapter.cancelOrder = vi.fn().mockResolvedValue({ ok: true });
84+
85+
await adapter.cancelAllOrders("SOL");
86+
87+
// Should only cancel SOL orders (IDs 1 and 3)
88+
expect(adapter.cancelOrder).toHaveBeenCalledTimes(2);
89+
expect(adapter.cancelOrder).toHaveBeenCalledWith("SOL", "1");
90+
expect(adapter.cancelOrder).toHaveBeenCalledWith("SOL", "3");
91+
});
92+
93+
it("Pacifica cancelAllOrders cancels all when no symbol", async () => {
94+
const mod = await import("../exchanges/pacifica.js");
95+
const PacificaAdapter = mod.PacificaAdapter;
96+
97+
const adapter = Object.create(PacificaAdapter.prototype);
98+
adapter.client = {
99+
cancelAllOrders: vi.fn().mockResolvedValue({ ok: true }),
100+
};
101+
adapter.account = "test-account";
102+
adapter.signMessage = vi.fn();
103+
104+
await adapter.cancelAllOrders();
105+
106+
expect(adapter.client.cancelAllOrders).toHaveBeenCalledWith(
107+
{ all_symbols: true, exclude_reduce_only: false },
108+
"test-account",
109+
expect.any(Function),
110+
);
111+
});
112+
113+
it("Lighter cancelAllOrders filters by symbol", async () => {
114+
const mod = await import("../exchanges/lighter.js");
115+
const LighterAdapter = mod.LighterAdapter;
116+
117+
const adapter = Object.create(LighterAdapter.prototype);
118+
adapter._readOnly = false;
119+
adapter.ensureSigner = vi.fn();
120+
adapter.getOpenOrders = vi.fn().mockResolvedValue([
121+
{ orderId: "10", symbol: "ETH", side: "buy", price: "3000", size: "0.1", filled: "0", status: "open", type: "limit" },
122+
{ orderId: "20", symbol: "BTC", side: "sell", price: "50000", size: "0.01", filled: "0", status: "open", type: "limit" },
123+
]);
124+
adapter.cancelOrder = vi.fn().mockResolvedValue({ ok: true });
125+
126+
await adapter.cancelAllOrders("ETH");
127+
128+
expect(adapter.cancelOrder).toHaveBeenCalledTimes(1);
129+
expect(adapter.cancelOrder).toHaveBeenCalledWith("ETH", "10");
130+
});
131+
});
132+
133+
// ── BUG 5: HL getAssetIndex retries on empty map ──
134+
describe("BUG 5: HL getAssetIndex retries on empty map", () => {
135+
it("retries _loadAssetMap when map is empty", async () => {
136+
const mod = await import("../exchanges/hyperliquid.js");
137+
const HyperliquidAdapter = mod.HyperliquidAdapter;
138+
139+
const adapter = Object.create(HyperliquidAdapter.prototype);
140+
adapter._assetMap = new Map();
141+
adapter._assetMapReverse = new Map();
142+
adapter._dex = "";
143+
144+
// _loadAssetMap populates the map on retry
145+
adapter._loadAssetMap = vi.fn().mockImplementation(async () => {
146+
adapter._assetMap.set("BTC", 0);
147+
adapter._assetMap.set("ETH", 1);
148+
});
149+
adapter.resolveSymbol = vi.fn().mockReturnValue("BTC");
150+
151+
const idx = await adapter.getAssetIndex("BTC");
152+
expect(adapter._loadAssetMap).toHaveBeenCalledTimes(1);
153+
expect(idx).toBe(0);
154+
});
155+
156+
it("does not retry when map is already populated", async () => {
157+
const mod = await import("../exchanges/hyperliquid.js");
158+
const HyperliquidAdapter = mod.HyperliquidAdapter;
159+
160+
const adapter = Object.create(HyperliquidAdapter.prototype);
161+
adapter._assetMap = new Map([["BTC", 0]]);
162+
adapter._assetMapReverse = new Map([[0, "BTC"]]);
163+
adapter._loadAssetMap = vi.fn();
164+
adapter.resolveSymbol = vi.fn().mockReturnValue("BTC");
165+
166+
const idx = await adapter.getAssetIndex("BTC");
167+
expect(adapter._loadAssetMap).not.toHaveBeenCalled();
168+
expect(idx).toBe(0);
169+
});
170+
});
171+
172+
// ── BUG 7: cli-spec version from package.json ──
173+
describe("BUG 7: cli-spec reads version from package.json", () => {
174+
it("version is not hardcoded '0.1.0'", async () => {
175+
const { getCliSpec } = await import("../cli-spec.js");
176+
const { Command } = await import("commander");
177+
const program = new Command();
178+
program.name("perp");
179+
const spec = getCliSpec(program);
180+
expect(spec.version).not.toBe("0.1.0");
181+
// Should match the version in package.json
182+
const { createRequire } = await import("node:module");
183+
const req = createRequire(import.meta.url);
184+
const pkg = req("../../package.json") as { version: string };
185+
expect(spec.version).toBe(pkg.version);
186+
});
187+
});

src/__tests__/integration/agent-features.integration.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,11 +538,11 @@ describe("Agent Features Integration (Hyperliquid Mainnet)", () => {
538538
expect(markets.length).toBeGreaterThan(50);
539539
// Verify we can look up at least one symbol using the exact format from the map
540540
const firstSymbol = markets[0].symbol;
541-
expect(adapter.getAssetIndex(firstSymbol)).toBeGreaterThanOrEqual(0);
541+
await expect(adapter.getAssetIndex(firstSymbol)).resolves.toBeGreaterThanOrEqual(0);
542542
}, 30_000);
543543

544-
it("adapter getAssetIndex throws for unknown symbol", () => {
545-
expect(() => adapter.getAssetIndex("XYZNOTREAL999FAKE")).toThrow(/Unknown/i);
544+
it("adapter getAssetIndex throws for unknown symbol", async () => {
545+
await expect(adapter.getAssetIndex("XYZNOTREAL999FAKE")).rejects.toThrow(/Unknown/i);
546546
});
547547

548548
it("adapter getMarkets returns well-formed data from real API", async () => {

src/__tests__/integration/hyperliquid.integration.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ describe.skipIf(SKIP)("Hyperliquid Integration (Testnet)", () => {
3636
expect(Number(btc!.markPrice)).toBeGreaterThan(0);
3737
});
3838

39-
it("resolves asset index for BTC", () => {
40-
const idx = adapter.getAssetIndex("BTC");
39+
it("resolves asset index for BTC", async () => {
40+
const idx = await adapter.getAssetIndex("BTC");
4141
expect(typeof idx).toBe("number");
4242
expect(idx).toBeGreaterThanOrEqual(0);
4343
});

src/__tests__/smart-order.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,22 @@ describe("smartOrder", () => {
179179
expect(adapter.marketOrder).toHaveBeenCalledWith("BTC", "sell", "0.1");
180180
});
181181

182+
it("rounds tick size to eliminate floating-point noise", async () => {
183+
// Prices like "1.1" and "1.2" produce diff = 0.10000000000000009 in JS
184+
const adapter = mockAdapter({
185+
getOrderbook: vi.fn().mockResolvedValue({
186+
bids: [["1.2", "10"], ["1.1", "20"], ["1.0", "15"]],
187+
asks: [["1.3", "10"], ["1.4", "20"], ["1.5", "15"]],
188+
}),
189+
});
190+
const result = await smartOrder(adapter, "TOKEN", "buy", "100");
191+
192+
// Tick should be exactly 0.1, not 0.10000000000000009
193+
expect(result.tickSize).toBe("0.1");
194+
// Price = 1.3 + 0.1 = 1.4
195+
expect(result.price).toBe("1.4");
196+
});
197+
182198
it("does not fallback on embedded error when fallback=false", async () => {
183199
const hlResponse = {
184200
status: "ok",

src/cli-spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
* Generates a structured CLI spec from Commander's program tree.
33
* Used by `perp api-spec` so agents can discover all commands programmatically.
44
*/
5+
import { createRequire } from "node:module";
56
import type { Command } from "commander";
67

8+
const require = createRequire(import.meta.url);
9+
const { version: pkgVersion } = require("../package.json") as { version: string };
10+
711
interface ArgSpec {
812
name: string;
913
required: boolean;
@@ -77,7 +81,7 @@ export function getCliSpec(program: Command): CliSpec {
7781

7882
return {
7983
name: "perp",
80-
version: "0.1.0",
84+
version: pkgVersion,
8185
description: "Multi-DEX Perpetual Futures CLI (Pacifica, Hyperliquid, Lighter)",
8286
globalOptions,
8387
commands,

0 commit comments

Comments
 (0)