@metaflux-dex/client 0.0.6 → 0.1.0

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.
Files changed (58) hide show
  1. package/dist/client.d.ts +33 -1
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +74 -3
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/native/actions.d.ts +7 -1
  10. package/dist/native/actions.d.ts.map +1 -1
  11. package/dist/native/actions.js +113 -1
  12. package/dist/native/actions.js.map +1 -1
  13. package/dist/native/digest.d.ts +1 -0
  14. package/dist/native/digest.d.ts.map +1 -1
  15. package/dist/native/digest.js +11 -0
  16. package/dist/native/digest.js.map +1 -1
  17. package/dist/types/cross-chain.d.ts +8 -0
  18. package/dist/types/cross-chain.d.ts.map +1 -0
  19. package/dist/types/cross-chain.js +9 -0
  20. package/dist/types/cross-chain.js.map +1 -0
  21. package/dist/types/encrypted.d.ts +5 -0
  22. package/dist/types/encrypted.d.ts.map +1 -1
  23. package/dist/types/fba.d.ts +9 -0
  24. package/dist/types/fba.d.ts.map +1 -0
  25. package/dist/types/fba.js +8 -0
  26. package/dist/types/fba.js.map +1 -0
  27. package/dist/types/index.d.ts +5 -2
  28. package/dist/types/index.d.ts.map +1 -1
  29. package/dist/types/info/core.d.ts +5 -1
  30. package/dist/types/info/core.d.ts.map +1 -1
  31. package/dist/types/info/index.d.ts +1 -1
  32. package/dist/types/info/index.d.ts.map +1 -1
  33. package/dist/types/info/reads.d.ts.map +1 -1
  34. package/dist/types/rfq.d.ts +15 -0
  35. package/dist/types/rfq.d.ts.map +1 -0
  36. package/dist/types/rfq.js +9 -0
  37. package/dist/types/rfq.js.map +1 -0
  38. package/dist/types/vault.d.ts +4 -0
  39. package/dist/types/vault.d.ts.map +1 -1
  40. package/dist/ws/ws.d.ts +33 -2
  41. package/dist/ws/ws.d.ts.map +1 -1
  42. package/dist/ws/ws.js +221 -2
  43. package/dist/ws/ws.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/client.ts +120 -2
  46. package/src/index.ts +17 -0
  47. package/src/native/actions.ts +135 -0
  48. package/src/native/digest.ts +12 -0
  49. package/src/types/cross-chain.ts +32 -0
  50. package/src/types/encrypted.ts +23 -0
  51. package/src/types/fba.ts +32 -0
  52. package/src/types/index.ts +5 -1
  53. package/src/types/info/core.ts +28 -13
  54. package/src/types/info/index.ts +1 -0
  55. package/src/types/info/reads.ts +17 -5
  56. package/src/types/rfq.ts +55 -0
  57. package/src/types/vault.ts +21 -0
  58. package/src/ws/ws.ts +313 -6
package/src/ws/ws.ts CHANGED
@@ -22,9 +22,22 @@
22
22
  // SDK dependency-free for both runtimes.
23
23
 
24
24
  import type { Funding } from '../types/info/core.js';
25
+ import {
26
+ buildNativeCancelAction,
27
+ buildNativeOrderAction,
28
+ } from '../native/actions.js';
29
+ import { nextNonce, recoverNativeSigner, signNativeAction } from '../native/digest.js';
30
+ import type {
31
+ NativeCancel,
32
+ NativeExchangeAck,
33
+ NativeOrder,
34
+ } from '../types/index.js';
25
35
 
26
- /// Channel names exactly as the server's `Channel::from_wire` accepts them
27
- /// (snake_case MTF-native).
36
+ /// Channel names exactly as the gateway's native `/ws` surface accepts them
37
+ /// (snake_case MTF-native). These are the 17 channels the gateway serves
38
+ /// natively (per `api-gateway::ws::subscriptions::native_name`); SDK-only
39
+ /// channels the gateway never serves (`user_fills`, `user_state`, `vault_nav`,
40
+ /// `rfq`, `mark`, `order_events`, …) are deliberately absent.
28
41
  export type WsChannel =
29
42
  // per-market (require `coin`)
30
43
  | 'l2_book'
@@ -44,10 +57,13 @@ export type WsChannel =
44
57
  | 'user_fundings'
45
58
  | 'user_twap_slice_fills'
46
59
  | 'user_twap_history'
60
+ | 'account_state'
61
+ | 'spot_state'
47
62
  // per-account + market (`active_asset_data` needs `user` + `coin`)
48
63
  | 'active_asset_data';
49
64
 
50
- /// All known channels — handy for callers that want to subscribe broadly.
65
+ /// All known channels — handy for callers that want to subscribe broadly. The
66
+ /// exact 17 native gateway channels.
51
67
  export const WS_CHANNELS: readonly WsChannel[] = [
52
68
  'l2_book',
53
69
  'bbo',
@@ -63,6 +79,8 @@ export const WS_CHANNELS: readonly WsChannel[] = [
63
79
  'user_fundings',
64
80
  'user_twap_slice_fills',
65
81
  'user_twap_history',
82
+ 'account_state',
83
+ 'spot_state',
66
84
  'active_asset_data',
67
85
  ] as const;
68
86
 
@@ -70,15 +88,23 @@ export const WS_CHANNELS: readonly WsChannel[] = [
70
88
  /// subscribe / unsubscribe frame. The routing key is the combination of the
71
89
  /// fields a channel uses:
72
90
  /// - `coin` — per-market channels (`l2_book`, `bbo`, `trades`,
73
- /// `active_asset_ctx`, `candles`, `active_asset_data`)
91
+ /// `active_asset_ctx`, `candles`, `active_asset_data`). A
92
+ /// **decimal asset-id STRING** (`"1"`), NOT a number: the node
93
+ /// resolves it via `str::parse::<u32>`, so a bare JSON number
94
+ /// is "unknown market" (symbol resolution like `"BTC"` is not
95
+ /// wired on the native `/ws` surface). Use the `subscribe*`
96
+ /// helpers to format a numeric market id into the right shape.
74
97
  /// - `user` — per-account channels (`fills`, `user_events`,
75
- /// `order_updates`, `active_asset_data`, …); the 0x address
98
+ /// `order_updates`, `active_asset_data`, …); the 0x address.
76
99
  /// - `interval` — `candles` only (`1m`/`5m`/`15m`/`1h`/`4h`/`1d`)
77
100
  /// Global channels (`all_mids`) take none.
78
101
  export interface WsSubscription {
79
102
  type: WsChannel;
103
+ /// Market asset-id as a decimal STRING (`"1"`) — see the interface note.
80
104
  coin?: string;
105
+ /// User `0x`-hex address (per-account channels).
81
106
  user?: string;
107
+ /// Bar interval token (`candles` only).
82
108
  interval?: string;
83
109
  }
84
110
 
@@ -121,6 +147,9 @@ export interface WsConfig {
121
147
  maxBackoffMs: number;
122
148
  /// Auto-reconnect on unexpected close. Default: true.
123
149
  autoReconnect: boolean;
150
+ /// How long a `post` request waits for its correlated response before failing
151
+ /// (ms). Mirrors the Rust `post_timeout` (10 s). Default: 10_000.
152
+ postTimeoutMs: number;
124
153
  }
125
154
 
126
155
  const DEFAULT_CONFIG: WsConfig = {
@@ -128,8 +157,20 @@ const DEFAULT_CONFIG: WsConfig = {
128
157
  initialBackoffMs: 250,
129
158
  maxBackoffMs: 30_000,
130
159
  autoReconnect: true,
160
+ postTimeoutMs: 10_000,
131
161
  };
132
162
 
163
+ /// Signing context for the WS `post` exchange path — a 32-byte private key and
164
+ /// the EIP-712 chain id to sign against. When absent, `postAction` / `submitOrder`
165
+ /// / `cancelOrder` throw; `postInfo` (an unsigned read) still works.
166
+ export interface WsSigner {
167
+ /// 32-byte ECDSA private key.
168
+ privateKey: Uint8Array;
169
+ /// EIP-712 domain chain id. Defaults to `MTF_CHAIN_ID` (testnet 114514) when
170
+ /// omitted, matching the REST `/exchange` path.
171
+ chainId?: number;
172
+ }
173
+
133
174
  /// Subscription set equality key — `(channel, coin, user, interval)` is the
134
175
  /// server's routing key, so two subscriptions are identical iff all match
135
176
  /// (e.g. `candles` `1m` vs `5m`, or `fills` for two different users, are
@@ -155,6 +196,7 @@ function subKey(s: WsSubscription): string {
155
196
  export class WsClient {
156
197
  private readonly url: string;
157
198
  private readonly config: WsConfig;
199
+ private readonly signer: WsSigner | undefined;
158
200
  private socket: WebSocket | undefined;
159
201
  /// Active subscriptions, replayed on (re)connect. Keyed for dedupe.
160
202
  private readonly active = new Map<string, WsSubscription>();
@@ -164,13 +206,31 @@ export class WsClient {
164
206
  private backoffMs: number;
165
207
  /// True once `close()` is called — suppresses auto-reconnect.
166
208
  private closed = false;
209
+ /// Monotonic id source for `post` request/response correlation.
210
+ private postIdSeq = 1;
211
+ /// In-flight `post` requests keyed by correlation id. Resolved when the
212
+ /// `{channel:"post"}` frame with the matching `data.id` arrives, or rejected
213
+ /// on timeout. A connection drop leaves them pending; the per-request timeout
214
+ /// is the backstop (a signed action is one-shot, so we never auto-retry).
215
+ private readonly pendingPosts = new Map<
216
+ number,
217
+ {
218
+ resolve: (response: unknown) => void;
219
+ reject: (err: Error) => void;
220
+ timer: ReturnType<typeof setTimeout>;
221
+ }
222
+ >();
167
223
 
168
- constructor(url: string, config: Partial<WsConfig> = {}) {
224
+ constructor(url: string, config: Partial<WsConfig> = {}, signer?: WsSigner) {
169
225
  if (url.length === 0) {
170
226
  throw new RangeError('WsClient url must be non-empty');
171
227
  }
228
+ if (signer !== undefined && signer.privateKey.length !== 32) {
229
+ throw new RangeError('WsClient signer privateKey must be exactly 32 bytes');
230
+ }
172
231
  this.url = url;
173
232
  this.config = { ...DEFAULT_CONFIG, ...config };
233
+ this.signer = signer;
174
234
  this.backoffMs = this.config.initialBackoffMs;
175
235
  }
176
236
 
@@ -209,6 +269,224 @@ export class WsClient {
209
269
  this.send({ method: 'unsubscribe', subscription: sub });
210
270
  }
211
271
 
272
+ // ── convenience subscribe helpers ─────────────────────────────────────────
273
+ //
274
+ // Format a numeric market id into the `coin` decimal-string the node parses
275
+ // (`str::parse::<u32>`), so callers never accidentally send a JSON number that
276
+ // the node drops as "unknown market". Mirrors the Rust `subscribe_*` helpers.
277
+
278
+ /// Subscribe to L2 book updates for a market id.
279
+ async subscribeL2Book(marketId: number): Promise<void> {
280
+ return this.subscribe({ type: 'l2_book', coin: `${marketId}` });
281
+ }
282
+
283
+ /// Subscribe to public trades for a market id.
284
+ async subscribeTrades(marketId: number): Promise<void> {
285
+ return this.subscribe({ type: 'trades', coin: `${marketId}` });
286
+ }
287
+
288
+ /// Subscribe to best-bid-best-offer ticks for a market id.
289
+ async subscribeBbo(marketId: number): Promise<void> {
290
+ return this.subscribe({ type: 'bbo', coin: `${marketId}` });
291
+ }
292
+
293
+ /// Subscribe to per-market mark / oracle / funding / OI context.
294
+ async subscribeActiveAssetCtx(marketId: number): Promise<void> {
295
+ return this.subscribe({ type: 'active_asset_ctx', coin: `${marketId}` });
296
+ }
297
+
298
+ /// Subscribe to OHLCV candles for a market id + interval token.
299
+ async subscribeCandles(marketId: number, interval: string): Promise<void> {
300
+ return this.subscribe({ type: 'candles', coin: `${marketId}`, interval });
301
+ }
302
+
303
+ /// Subscribe to the global all-market mids stream.
304
+ async subscribeAllMids(): Promise<void> {
305
+ return this.subscribe({ type: 'all_mids' });
306
+ }
307
+
308
+ /// Subscribe to per-user fills (0x address).
309
+ async subscribeFills(user: string): Promise<void> {
310
+ return this.subscribe({ type: 'fills', user });
311
+ }
312
+
313
+ /// Subscribe to per-user order lifecycle updates (0x address).
314
+ async subscribeOrderUpdates(user: string): Promise<void> {
315
+ return this.subscribe({ type: 'order_updates', user });
316
+ }
317
+
318
+ /// Subscribe to per-user account / margin events (0x address).
319
+ async subscribeUserEvents(user: string): Promise<void> {
320
+ return this.subscribe({ type: 'user_events', user });
321
+ }
322
+
323
+ /// Subscribe to the per-user live PERP account-state stream (0x address).
324
+ async subscribeAccountState(user: string): Promise<void> {
325
+ return this.subscribe({ type: 'account_state', user });
326
+ }
327
+
328
+ /// Subscribe to the per-user live SPOT clearinghouse-state stream (0x address).
329
+ async subscribeSpotState(user: string): Promise<void> {
330
+ return this.subscribe({ type: 'spot_state', user });
331
+ }
332
+
333
+ /// Subscribe to per-(user, market) leverage / margin-mode context.
334
+ async subscribeActiveAssetData(user: string, marketId: number): Promise<void> {
335
+ return this.subscribe({
336
+ type: 'active_asset_data',
337
+ coin: `${marketId}`,
338
+ user,
339
+ });
340
+ }
341
+
342
+ // ── `post` request/response (signed exchange actions + info reads) ─────────
343
+ //
344
+ // The WS analogue of `POST /exchange` and `POST /info`: multiplex one-off
345
+ // writes / reads over the existing socket instead of opening a REST request.
346
+ //
347
+ // client → server:
348
+ // {"method":"post","id":N,"request":{"type":"action"|"info","payload":{...}}}
349
+ // server → client:
350
+ // {"channel":"post","data":{"id":N,"response":{"type":...,"payload":{...}}}}
351
+ //
352
+ // For an `action`, payload is the signed envelope `{signature, nonce, action}`
353
+ // — signed with the SAME EIP-712 digest the REST `/exchange` path uses (the
354
+ // node recovers the signer over the raw `action` bytes). Correlated by `id`;
355
+ // a `{type:"error"}` response surfaces as an error; each request has a timeout.
356
+
357
+ /// Issue a signed exchange action over the WS `post` channel, returning the
358
+ /// node's action response payload. Requires a `WsSigner` (passed to the
359
+ /// constructor, or via `Client.connectWs` with a keyed client).
360
+ async postAction(actionJson: string): Promise<unknown> {
361
+ if (this.signer === undefined) {
362
+ throw new Error(
363
+ 'postAction requires a WsSigner (this WsClient was opened read-only)',
364
+ );
365
+ }
366
+ const nonce = nextNonce();
367
+ const signed = await signNativeAction(
368
+ this.signer.privateKey,
369
+ actionJson,
370
+ nonce,
371
+ this.signer.chainId,
372
+ );
373
+ // The signed envelope mirrors the REST body shape, but the `action` rides as
374
+ // a parsed object inside the JSON `request.payload`. The server still
375
+ // verifies over the raw `action` bytes; since the bytes we signed are valid
376
+ // JSON, re-embedding them as `JSON.parse(actionJson)` is byte-equivalent to
377
+ // the canonical form the server re-serializes for the digest.
378
+ const payload = {
379
+ signature: signed.signature,
380
+ nonce: Number(signed.nonce),
381
+ action: JSON.parse(actionJson) as unknown,
382
+ };
383
+ return this.postRequest('action', payload);
384
+ }
385
+
386
+ /// Issue an `info` read over the WS `post` channel, returning the info response
387
+ /// payload. `payload` is the usual `{"type":"<info>",...}` body. No signing.
388
+ async postInfo(payload: { type: string; [k: string]: unknown }): Promise<unknown> {
389
+ return this.postRequest('info', payload);
390
+ }
391
+
392
+ /// Submit a limit / market / trigger order over the WS `post` channel.
393
+ /// Mirrors `Client.submitOrderNative`: `order.owner` MUST equal the signing
394
+ /// wallet (recovered locally and rejected on mismatch).
395
+ async submitOrder(order: NativeOrder): Promise<NativeExchangeAck> {
396
+ if (this.signer === undefined) {
397
+ throw new Error('submitOrder requires a WsSigner (read-only WsClient)');
398
+ }
399
+ const actionJson = buildNativeOrderAction(order);
400
+ await this.assertOwner(actionJson, order.owner, 'order.owner');
401
+ return (await this.postAction(actionJson)) as NativeExchangeAck;
402
+ }
403
+
404
+ /// Cancel an order over the WS `post` channel. Mirrors
405
+ /// `Client.cancelOrderNative`: `cancel.owner` MUST equal the signing wallet.
406
+ async cancelOrder(cancel: NativeCancel): Promise<NativeExchangeAck> {
407
+ if (this.signer === undefined) {
408
+ throw new Error('cancelOrder requires a WsSigner (read-only WsClient)');
409
+ }
410
+ const actionJson = buildNativeCancelAction(cancel);
411
+ await this.assertOwner(actionJson, cancel.owner, 'cancel.owner');
412
+ return (await this.postAction(actionJson)) as NativeExchangeAck;
413
+ }
414
+
415
+ /// Recover the signer over the action's own digest and reject unless it equals
416
+ /// `owner`. Saves a round-trip on an obvious key/owner mismatch (the server
417
+ /// enforces the same). Shares the nonce-agnostic recover path with the REST
418
+ /// client.
419
+ private async assertOwner(
420
+ actionJson: string,
421
+ owner: string,
422
+ field: string,
423
+ ): Promise<void> {
424
+ // recoverNativeSigner is nonce-agnostic for the address it yields; use a
425
+ // throwaway nonce of 0 just to drive the digest+recover.
426
+ const signed = await signNativeAction(
427
+ this.signer!.privateKey,
428
+ actionJson,
429
+ 0n,
430
+ this.signer!.chainId,
431
+ );
432
+ const signer = await recoverNativeSigner(signed, this.signer!.chainId);
433
+ if (signer.toLowerCase() !== owner.toLowerCase()) {
434
+ throw new Error(`${field} ${owner} != recovered signer ${signer}`);
435
+ }
436
+ }
437
+
438
+ /// Core `post` machinery: assign a correlation id, ship the frame, and await
439
+ /// the matching response. Rejects on a `{type:"error"}` response, on timeout,
440
+ /// or if the socket is not open. Returns the inner `payload` on success.
441
+ private postRequest(
442
+ requestType: 'action' | 'info',
443
+ payload: unknown,
444
+ ): Promise<unknown> {
445
+ if (this.socket?.readyState !== 1) {
446
+ return Promise.reject(new Error('ws post: socket is not open'));
447
+ }
448
+ const id = this.postIdSeq++;
449
+ return new Promise<unknown>((resolve, reject) => {
450
+ const timer = setTimeout(() => {
451
+ this.pendingPosts.delete(id);
452
+ reject(new Error('ws post: timed out'));
453
+ }, this.config.postTimeoutMs);
454
+
455
+ this.pendingPosts.set(id, {
456
+ resolve: (response: unknown) => {
457
+ // The node wraps every reply as `{type, payload}`; an error reply
458
+ // carries the message as a string `payload`.
459
+ if (
460
+ response !== null &&
461
+ typeof response === 'object' &&
462
+ (response as { type?: unknown }).type === 'error'
463
+ ) {
464
+ const msg = (response as { payload?: unknown }).payload;
465
+ reject(
466
+ new Error(
467
+ `ws post error: ${typeof msg === 'string' ? msg : 'unknown post error'}`,
468
+ ),
469
+ );
470
+ return;
471
+ }
472
+ const inner =
473
+ response !== null && typeof response === 'object'
474
+ ? (response as { payload?: unknown }).payload
475
+ : undefined;
476
+ resolve(inner);
477
+ },
478
+ reject,
479
+ timer,
480
+ });
481
+
482
+ this.send({
483
+ method: 'post',
484
+ id,
485
+ request: { type: requestType, payload },
486
+ });
487
+ });
488
+ }
489
+
212
490
  /// Whether the socket is currently OPEN.
213
491
  get isOpen(): boolean {
214
492
  return this.socket?.readyState === 1; // WebSocket.OPEN
@@ -219,6 +497,13 @@ export class WsClient {
219
497
  close(): void {
220
498
  this.closed = true;
221
499
  this.clearTimers();
500
+ // Fail any in-flight `post` so a caller awaiting a response on a socket we
501
+ // just closed unblocks with an error rather than hanging until timeout.
502
+ for (const [, pending] of this.pendingPosts) {
503
+ clearTimeout(pending.timer);
504
+ pending.reject(new Error('ws post: client closed'));
505
+ }
506
+ this.pendingPosts.clear();
222
507
  if (this.socket !== undefined) {
223
508
  try {
224
509
  this.socket.close();
@@ -310,11 +595,33 @@ export class WsClient {
310
595
  } catch {
311
596
  return; // ignore non-JSON frames
312
597
  }
598
+ // A `{channel:"post"}` frame correlates by id back to the waiting caller and
599
+ // is consumed here — it does NOT fan out to subscription handlers. Every
600
+ // other frame (data channels, subscriptionResponse ack, error, bare pong)
601
+ // is passed through to the registered handlers unchanged.
602
+ if (frame.channel === 'post') {
603
+ this.resolvePost(frame.data);
604
+ return;
605
+ }
313
606
  for (const h of this.handlers) {
314
607
  h(frame);
315
608
  }
316
609
  }
317
610
 
611
+ /// Resolve the pending `post` whose id matches the frame's `data.id`. The node
612
+ /// wraps every reply as `data.response = {type, payload}`; a `{type:"error"}`
613
+ /// response surfaces as a rejection.
614
+ private resolvePost(data: unknown): void {
615
+ if (data === null || typeof data !== 'object') return;
616
+ const { id, response } = data as { id?: unknown; response?: unknown };
617
+ if (typeof id !== 'number') return;
618
+ const pending = this.pendingPosts.get(id);
619
+ if (pending === undefined) return;
620
+ this.pendingPosts.delete(id);
621
+ clearTimeout(pending.timer);
622
+ pending.resolve(response);
623
+ }
624
+
318
625
  private clearTimers(): void {
319
626
  this.clearPing();
320
627
  if (this.reconnectTimer !== undefined) {