@phantom/perps-client 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @phantom/perps-client
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 92e3472: Perps Support
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @phantom/perps-client
2
+
3
+ Hyperliquid perpetuals trading client for Phantom Wallet. Handles EIP-712 signing and Phantom backend API calls for the full perpetuals lifecycle — from funding deposits through position management.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ PerpsClient
9
+ ├── Read operations → Phantom backend API → Hyperliquid data
10
+ └── Write operations → EIP-712 sign → Phantom backend → Hyperliquid exchange
11
+ ```
12
+
13
+ The client is intentionally decoupled from `PhantomClient`. It receives a plain EVM address and a `signTypedData` callback, making it testable with any signer and reusable outside the MCP server.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ yarn add @phantom/perps-client
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```typescript
24
+ import { PerpsClient } from "@phantom/perps-client";
25
+
26
+ const client = new PerpsClient({
27
+ evmAddress: "0xYourEvmAddress",
28
+ signTypedData: async typedData => {
29
+ // Sign EIP-712 typed data and return 0x-prefixed hex signature
30
+ return phantomClient.ethereumSignTypedData({
31
+ walletId,
32
+ typedData,
33
+ networkId: "eip155:42161", // Arbitrum — used for all Hyperliquid signing
34
+ derivationIndex: 0,
35
+ });
36
+ },
37
+ apiBaseUrl: "https://api.phantom.app", // optional
38
+ });
39
+
40
+ // Read
41
+ const balance = await client.getBalance();
42
+ const positions = await client.getPositions();
43
+ const orders = await client.getOpenOrders();
44
+ const markets = await client.getMarkets();
45
+
46
+ // Write
47
+ await client.openPosition({ market: "BTC", direction: "long", sizeUsd: "100", leverage: 10, orderType: "market" });
48
+ await client.closePosition({ market: "BTC" });
49
+ await client.cancelOrder({ market: "BTC", orderId: 12345 });
50
+ await client.updateLeverage({ market: "BTC", leverage: 5, marginType: "cross" });
51
+
52
+ // Internal transfers (both accounts on Hypercore)
53
+ await client.deposit("100"); // spot → perp
54
+ await client.withdraw("50"); // perp → spot
55
+
56
+ // Deposit flow helpers (used by the MCP deposit_to_hyperliquid tool)
57
+ const funding = await client.getFundingAddress("solana:101");
58
+ // ... user sends tokens to funding.depositAddress on the source chain ...
59
+ const destinationAmount = await client.pollBridgeDeposit(txHash, funding.depositAddress, Date.now());
60
+ const usdcAmount = await client.sellSpotToUsdc(funding.spotAssetId, destinationAmount, funding.spotSzDecimals);
61
+ await client.deposit(usdcAmount);
62
+ ```
63
+
64
+ ## API Reference
65
+
66
+ ### Constructor
67
+
68
+ ```typescript
69
+ new PerpsClient(options: PerpsClientOptions)
70
+ ```
71
+
72
+ | Option | Type | Description |
73
+ | --------------- | ------------------------------------------------- | --------------------------------------------------------- |
74
+ | `evmAddress` | `string` | The wallet's EVM address (0x-prefixed) |
75
+ | `signTypedData` | `(typedData: Eip712TypedData) => Promise<string>` | Signs EIP-712 data, returns 0x-prefixed hex signature |
76
+ | `apiBaseUrl` | `string?` | Phantom API base URL (default: `https://api.phantom.app`) |
77
+
78
+ ### Read Methods
79
+
80
+ | Method | Returns | Endpoint |
81
+ | --------------------- | -------------------- | --------------------------------------------- |
82
+ | `getBalance()` | `PerpAccountBalance` | `GET /swap/v2/perp/balance` |
83
+ | `getPositions()` | `PerpPosition[]` | `GET /swap/v2/perp/positions-and-open-orders` |
84
+ | `getOpenOrders()` | `PerpOrder[]` | `GET /swap/v2/perp/positions-and-open-orders` |
85
+ | `getMarkets()` | `PerpMarket[]` | `GET /swap/v2/perp/markets` |
86
+ | `getTradeHistory()` | `HistoricalOrder[]` | `GET /swap/v2/perp/trade-history` |
87
+ | `getFundingHistory()` | `FundingActivity[]` | `GET /swap/v2/perp/deposits-and-withdrawals` |
88
+
89
+ ### Write Methods
90
+
91
+ | Method | Signs | Endpoint |
92
+ | ------------------------ | ---------------------------------- | --------------------------------------- |
93
+ | `openPosition(params)` | Exchange/Agent EIP-712 | `POST /swap/v2/exchange` |
94
+ | `closePosition(params)` | Exchange/Agent EIP-712 | `POST /swap/v2/exchange` |
95
+ | `cancelOrder(params)` | Exchange/Agent EIP-712 | `POST /swap/v2/exchange` |
96
+ | `updateLeverage(params)` | Exchange/Agent EIP-712 | `POST /swap/v2/exchange` |
97
+ | `deposit(amountUsdc)` | HyperliquidSignTransaction EIP-712 | `POST /swap/v2/transfer-usdc-spot-perp` |
98
+ | `withdraw(amountUsdc)` | HyperliquidSignTransaction EIP-712 | `POST /swap/v2/transfer-usdc-spot-perp` |
99
+
100
+ ### Deposit Flow Helpers
101
+
102
+ | Method | Description |
103
+ | ------------------------------------------------------------------ | ---------------------------------------------------- |
104
+ | `getFundingAddress(sourceNetworkId)` | Gets the deposit address on the source chain |
105
+ | `pollBridgeDeposit(txHash, depositAddress, startedAt, timeoutMs?)` | Polls until bridge confirms (default 10 min timeout) |
106
+ | `sellSpotToUsdc(assetId, amount, szDecimals)` | Sells bridged token on Hyperliquid spot for USDC |
107
+
108
+ ## EIP-712 Signing
109
+
110
+ Hyperliquid uses two EIP-712 signing patterns:
111
+
112
+ **Exchange actions** (orders, cancel, leverage): The action is msgpack-encoded and keccak256-hashed to produce a `connectionId`. The typed data has `primaryType: "Agent"` with `domain.chainId: 1337` ("off-chain").
113
+
114
+ **Transfer actions** (deposit/withdraw): Direct EIP-712 with `primaryType: "HyperliquidTransaction:UsdClassTransfer"` and `domain.chainId: 42161` (Arbitrum mainnet).
115
+
116
+ In both cases, the signed message is sent to the Phantom backend which proxies it to Hyperliquid.
117
+
118
+ ## User Address Format
119
+
120
+ The Phantom perps API identifies users by their EVM address in CAIP-19 format:
121
+
122
+ ```
123
+ hypercore:mainnet/address:0xyourevmaddress
124
+ ```
125
+
126
+ This is derived from `evmAddress` automatically.
127
+
128
+ ## Deposit Flow
129
+
130
+ Bridging funds from an external chain to Hyperliquid perps requires five steps:
131
+
132
+ ```
133
+ Source chain (Solana/EVM)
134
+ ↓ POST /swap/v2/spot/funding
135
+ ↓ → deposit address on source chain
136
+
137
+ Send tokens to deposit address
138
+
139
+ Hyperunit/Relay bridge completes
140
+ ↓ GET /swap/v2/spot/bridge-operations (polled every 2s)
141
+
142
+ [If non-USDC] Sell on Hyperliquid spot → USDC
143
+
144
+ POST /swap/v2/transfer-usdc-spot-perp (spot → perp)
145
+ ```
146
+
147
+ The `deposit_to_hyperliquid` MCP tool orchestrates this entire flow.
package/jest.config.js ADDED
@@ -0,0 +1,10 @@
1
+ module.exports = {
2
+ preset: "ts-jest",
3
+ testEnvironment: "node",
4
+ testMatch: ["**/*.test.ts"],
5
+ moduleNameMapper: {
6
+ "^(\\.{1,2}/.*)\\.js$": "$1",
7
+ },
8
+ // Allow Jest to transform @msgpack/msgpack (ESM package)
9
+ transformIgnorePatterns: ["node_modules/(?!(@msgpack)/)"],
10
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@phantom/perps-client",
3
+ "version": "0.1.1",
4
+ "description": "Hyperliquid perpetuals client for Phantom Wallet",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.mjs",
11
+ "require": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "rimraf ./dist && tsup src/index.ts --format cjs,esm --dts",
17
+ "dev": "tsc --watch",
18
+ "test": "jest",
19
+ "lint": "tsc --noEmit && eslint --cache src --ext .ts,.tsx",
20
+ "check-types": "tsc --noEmit"
21
+ },
22
+ "devDependencies": {
23
+ "@types/jest": "^29.5.12",
24
+ "@types/node": "^20.11.0",
25
+ "eslint": "8.53.0",
26
+ "jest": "^29.7.0",
27
+ "rimraf": "^5.0.5",
28
+ "ts-jest": "^29.1.2",
29
+ "tsup": "^6.7.0",
30
+ "typescript": "^5.0.4"
31
+ },
32
+ "dependencies": {
33
+ "@msgpack/msgpack": "^3.0.0",
34
+ "@phantom/client": "workspace:^",
35
+ "axios": "^1.6.7",
36
+ "js-sha3": "^0.9.3"
37
+ }
38
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Unit tests for PerpsClient.
3
+ *
4
+ * PerpsApi is mocked so tests run without HTTP. Focuses on order-size computation
5
+ * and API response handling — input validation is the responsibility of callers.
6
+ */
7
+
8
+ import { PerpsClient } from "./PerpsClient.js";
9
+
10
+ // ── PerpsApi mock ─────────────────────────────────────────────────────────────
11
+
12
+ const mockApi = {
13
+ getAccountBalance: jest.fn(),
14
+ getPositionsAndOpenOrders: jest.fn(),
15
+ getTradeHistory: jest.fn(),
16
+ getFundingHistory: jest.fn(),
17
+ getMarkets: jest.fn(),
18
+ getAllMarkets: jest.fn(),
19
+ getTrendingMarkets: jest.fn(),
20
+ postPlaceOrder: jest.fn(),
21
+ postCancelOrder: jest.fn(),
22
+ postUpdateLeverage: jest.fn(),
23
+ postTransferUsdcSpotPerp: jest.fn(),
24
+ };
25
+
26
+ jest.mock("./api.js", () => ({
27
+ PerpsApi: jest.fn().mockImplementation(() => mockApi),
28
+ }));
29
+
30
+ // ── Helpers ───────────────────────────────────────────────────────────────────
31
+
32
+ const MOCK_MARKET = {
33
+ symbol: "BTC",
34
+ assetId: 0,
35
+ maxLeverage: 50,
36
+ szDecimals: 5,
37
+ price: "50000",
38
+ fundingRate: "0.0001",
39
+ openInterest: "1000000",
40
+ volume24h: "5000000",
41
+ };
42
+
43
+ const MOCK_POSITION = {
44
+ coin: "BTC",
45
+ direction: "long" as const,
46
+ size: "0.01",
47
+ margin: "500",
48
+ entryPrice: "50000",
49
+ leverage: { type: "unknown" as const, value: 10 },
50
+ unrealizedPnl: "0",
51
+ liquidationPrice: null,
52
+ };
53
+
54
+ const OK_RESPONSE = { status: "ok", response: { type: "order", data: { statuses: [] } } };
55
+
56
+ function makeClient(): PerpsClient {
57
+ return new PerpsClient({
58
+ evmAddress: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
59
+ signTypedData: () => Promise.resolve("0x" + "a".repeat(64) + "b".repeat(64) + "1b"),
60
+ });
61
+ }
62
+
63
+ beforeEach(() => {
64
+ jest.clearAllMocks();
65
+ mockApi.getMarkets.mockResolvedValue([MOCK_MARKET]);
66
+ mockApi.getPositionsAndOpenOrders.mockResolvedValue({ positions: [MOCK_POSITION], openOrders: [] });
67
+ mockApi.postPlaceOrder.mockResolvedValue(OK_RESPONSE);
68
+ mockApi.postCancelOrder.mockResolvedValue({ status: "ok" });
69
+ mockApi.postUpdateLeverage.mockResolvedValue({ status: "ok" });
70
+ mockApi.postTransferUsdcSpotPerp.mockResolvedValue({ status: "ok" });
71
+ });
72
+
73
+ // ── openPosition ──────────────────────────────────────────────────────────────
74
+
75
+ describe("openPosition", () => {
76
+ const VALID: Parameters<PerpsClient["openPosition"]>[0] = {
77
+ market: "BTC",
78
+ direction: "long",
79
+ sizeUsd: "100",
80
+ leverage: 10,
81
+ orderType: "market",
82
+ };
83
+
84
+ it("succeeds with valid params", async () => {
85
+ const client = makeClient();
86
+ await expect(client.openPosition(VALID)).resolves.toBeDefined();
87
+ });
88
+
89
+ it.each([["0"], ["-100"], ["abc"], ["1e5"], [" 100"], ["100 "]])("rejects sizeUsd=%j", async bad => {
90
+ const client = makeClient();
91
+ await expect(client.openPosition({ ...VALID, sizeUsd: bad })).rejects.toThrow("sizeUsd");
92
+ });
93
+
94
+ it("rejects when market price is zero/NaN", async () => {
95
+ mockApi.getMarkets.mockResolvedValue([{ ...MOCK_MARKET, price: "0" }]);
96
+ const client = makeClient();
97
+ await expect(client.openPosition(VALID)).rejects.toThrow("Invalid market price");
98
+ });
99
+
100
+ it("rejects when order size rounds to zero", async () => {
101
+ // sizeUsd=0.000001 at price=50000 → rawSize ~2e-11 → rounds to 0 at 5 decimals
102
+ mockApi.getMarkets.mockResolvedValue([{ ...MOCK_MARKET, price: "50000" }]);
103
+ const client = makeClient();
104
+ await expect(client.openPosition({ ...VALID, sizeUsd: "0.000001" })).rejects.toThrow("Order size rounds to zero");
105
+ });
106
+ });
107
+
108
+ // ── closePosition ─────────────────────────────────────────────────────────────
109
+
110
+ describe("closePosition", () => {
111
+ it("succeeds with no sizePercent (full close)", async () => {
112
+ const client = makeClient();
113
+ await expect(client.closePosition({ market: "BTC" })).resolves.toBeDefined();
114
+ });
115
+
116
+ it("succeeds with valid sizePercent", async () => {
117
+ const client = makeClient();
118
+ await expect(client.closePosition({ market: "BTC", sizePercent: 50 })).resolves.toBeDefined();
119
+ });
120
+
121
+ it("rejects when no open position exists", async () => {
122
+ mockApi.getPositionsAndOpenOrders.mockResolvedValue({ positions: [], openOrders: [] });
123
+ const client = makeClient();
124
+ await expect(client.closePosition({ market: "BTC" })).rejects.toThrow("No open position for market: BTC");
125
+ });
126
+
127
+ it("rejects when position.size is '0' (zero position)", async () => {
128
+ mockApi.getPositionsAndOpenOrders.mockResolvedValue({
129
+ positions: [{ ...MOCK_POSITION, size: "0" }],
130
+ openOrders: [],
131
+ });
132
+ const client = makeClient();
133
+ await expect(client.closePosition({ market: "BTC" })).rejects.toThrow("Invalid position size");
134
+ });
135
+
136
+ it("rejects when position.size is malformed (NaN)", async () => {
137
+ mockApi.getPositionsAndOpenOrders.mockResolvedValue({
138
+ positions: [{ ...MOCK_POSITION, size: "not-a-number" }],
139
+ openOrders: [],
140
+ });
141
+ const client = makeClient();
142
+ await expect(client.closePosition({ market: "BTC" })).rejects.toThrow("Invalid position size");
143
+ });
144
+
145
+ it("handles short positions (negative size) correctly via Math.abs", async () => {
146
+ // Shorts are represented with negative size; Math.abs must normalise it
147
+ mockApi.getPositionsAndOpenOrders.mockResolvedValue({
148
+ positions: [{ ...MOCK_POSITION, size: "-0.01", direction: "short" }],
149
+ openOrders: [],
150
+ });
151
+ const client = makeClient();
152
+ await expect(client.closePosition({ market: "BTC" })).resolves.toBeDefined();
153
+ });
154
+
155
+ it("rejects when sizePercent is so small the close size rounds to zero", async () => {
156
+ // position.size = 0.000001 → formatSize(0.000001, 5) = "0.00000" → rejects
157
+ mockApi.getPositionsAndOpenOrders.mockResolvedValue({
158
+ positions: [{ ...MOCK_POSITION, size: "0.000001" }],
159
+ openOrders: [],
160
+ });
161
+ const client = makeClient();
162
+ await expect(client.closePosition({ market: "BTC" })).rejects.toThrow("Close size rounds to zero");
163
+ });
164
+ });
165
+
166
+ // ── cancelOrder ───────────────────────────────────────────────────────────────
167
+
168
+ describe("cancelOrder", () => {
169
+ it("succeeds with a valid safe integer orderId", async () => {
170
+ const client = makeClient();
171
+ await expect(client.cancelOrder({ market: "BTC", orderId: 42 })).resolves.toBeDefined();
172
+ });
173
+ });
174
+
175
+ // ── updateLeverage ────────────────────────────────────────────────────────────
176
+
177
+ describe("updateLeverage", () => {
178
+ const VALID: Parameters<PerpsClient["updateLeverage"]>[0] = {
179
+ market: "BTC",
180
+ leverage: 10,
181
+ marginType: "isolated",
182
+ };
183
+
184
+ it("succeeds with valid params", async () => {
185
+ const client = makeClient();
186
+ await expect(client.updateLeverage(VALID)).resolves.toBeDefined();
187
+ });
188
+ });
189
+
190
+ // ── deposit ───────────────────────────────────────────────────────────────────
191
+
192
+ describe("deposit", () => {
193
+ it("succeeds with a valid amount", async () => {
194
+ const client = makeClient();
195
+ await expect(client.deposit("100")).resolves.toBeDefined();
196
+ });
197
+
198
+ it.each([["0"], ["-50"], ["all"], ["abc"], [" "], [""], [" 100"], ["1e5"]])("rejects amountUsdc=%j", async bad => {
199
+ const client = makeClient();
200
+ await expect(client.deposit(bad)).rejects.toThrow("amountUsdc");
201
+ });
202
+ });
203
+
204
+ // ── withdraw ──────────────────────────────────────────────────────────────────
205
+
206
+ describe("withdraw", () => {
207
+ it("succeeds with a valid amount", async () => {
208
+ const client = makeClient();
209
+ await expect(client.withdraw("50.5")).resolves.toBeDefined();
210
+ });
211
+
212
+ it.each([["0"], ["-10"], ["all"], ["abc"], [" 50"], ["1e5"]])("rejects amountUsdc=%j", async bad => {
213
+ const client = makeClient();
214
+ await expect(client.withdraw(bad)).rejects.toThrow("amountUsdc");
215
+ });
216
+ });