@metaflux-dex/client 0.0.5 → 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.
- package/dist/client.d.ts +33 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +74 -3
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/native/actions.d.ts +7 -1
- package/dist/native/actions.d.ts.map +1 -1
- package/dist/native/actions.js +113 -1
- package/dist/native/actions.js.map +1 -1
- package/dist/native/digest.d.ts +1 -0
- package/dist/native/digest.d.ts.map +1 -1
- package/dist/native/digest.js +11 -0
- package/dist/native/digest.js.map +1 -1
- package/dist/types/cross-chain.d.ts +8 -0
- package/dist/types/cross-chain.d.ts.map +1 -0
- package/dist/types/cross-chain.js +9 -0
- package/dist/types/cross-chain.js.map +1 -0
- package/dist/types/encrypted.d.ts +5 -0
- package/dist/types/encrypted.d.ts.map +1 -1
- package/dist/types/fba.d.ts +9 -0
- package/dist/types/fba.d.ts.map +1 -0
- package/dist/types/fba.js +8 -0
- package/dist/types/fba.js.map +1 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/info/core.d.ts +5 -1
- package/dist/types/info/core.d.ts.map +1 -1
- package/dist/types/info/index.d.ts +1 -1
- package/dist/types/info/index.d.ts.map +1 -1
- package/dist/types/info/reads.d.ts.map +1 -1
- package/dist/types/rfq.d.ts +15 -0
- package/dist/types/rfq.d.ts.map +1 -0
- package/dist/types/rfq.js +9 -0
- package/dist/types/rfq.js.map +1 -0
- package/dist/types/vault.d.ts +4 -0
- package/dist/types/vault.d.ts.map +1 -1
- package/dist/ws/ws.d.ts +46 -2
- package/dist/ws/ws.d.ts.map +1 -1
- package/dist/ws/ws.js +237 -7
- package/dist/ws/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +120 -2
- package/src/index.ts +19 -0
- package/src/native/actions.ts +135 -0
- package/src/native/digest.ts +12 -0
- package/src/types/cross-chain.ts +32 -0
- package/src/types/encrypted.ts +23 -0
- package/src/types/fba.ts +32 -0
- package/src/types/index.ts +5 -1
- package/src/types/info/core.ts +28 -13
- package/src/types/info/index.ts +1 -0
- package/src/types/info/reads.ts +17 -5
- package/src/types/rfq.ts +55 -0
- package/src/types/vault.ts +21 -0
- package/src/ws/ws.ts +372 -14
package/src/ws/ws.ts
CHANGED
|
@@ -21,32 +21,107 @@
|
|
|
21
21
|
// it globally, which is the SDK's floor). No `ws` npm dependency — keeping the
|
|
22
22
|
// SDK dependency-free for both runtimes.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
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';
|
|
35
|
+
|
|
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.
|
|
26
41
|
export type WsChannel =
|
|
42
|
+
// per-market (require `coin`)
|
|
27
43
|
| 'l2_book'
|
|
28
|
-
| 'trades'
|
|
29
44
|
| 'bbo'
|
|
30
|
-
| '
|
|
45
|
+
| 'trades'
|
|
46
|
+
| 'active_asset_ctx'
|
|
47
|
+
// global (no params)
|
|
48
|
+
| 'all_mids'
|
|
49
|
+
// per-market + interval (`candles` needs `coin` + `interval`)
|
|
31
50
|
| 'candles'
|
|
32
|
-
|
|
51
|
+
// per-account (require `user`)
|
|
52
|
+
| 'fills'
|
|
53
|
+
| 'user_events'
|
|
54
|
+
| 'order_updates'
|
|
55
|
+
| 'notifications'
|
|
56
|
+
| 'ledger_updates'
|
|
57
|
+
| 'user_fundings'
|
|
58
|
+
| 'user_twap_slice_fills'
|
|
59
|
+
| 'user_twap_history'
|
|
60
|
+
| 'account_state'
|
|
61
|
+
| 'spot_state'
|
|
62
|
+
// per-account + market (`active_asset_data` needs `user` + `coin`)
|
|
63
|
+
| 'active_asset_data';
|
|
33
64
|
|
|
34
|
-
/// 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.
|
|
35
67
|
export const WS_CHANNELS: readonly WsChannel[] = [
|
|
36
68
|
'l2_book',
|
|
37
|
-
'trades',
|
|
38
69
|
'bbo',
|
|
39
|
-
'
|
|
70
|
+
'trades',
|
|
71
|
+
'active_asset_ctx',
|
|
72
|
+
'all_mids',
|
|
40
73
|
'candles',
|
|
74
|
+
'fills',
|
|
41
75
|
'user_events',
|
|
76
|
+
'order_updates',
|
|
77
|
+
'notifications',
|
|
78
|
+
'ledger_updates',
|
|
79
|
+
'user_fundings',
|
|
80
|
+
'user_twap_slice_fills',
|
|
81
|
+
'user_twap_history',
|
|
82
|
+
'account_state',
|
|
83
|
+
'spot_state',
|
|
84
|
+
'active_asset_data',
|
|
42
85
|
] as const;
|
|
43
86
|
|
|
44
87
|
/// A subscription request body — the inner `subscription` object of a
|
|
45
|
-
/// subscribe / unsubscribe frame.
|
|
46
|
-
///
|
|
88
|
+
/// subscribe / unsubscribe frame. The routing key is the combination of the
|
|
89
|
+
/// fields a channel uses:
|
|
90
|
+
/// - `coin` — per-market channels (`l2_book`, `bbo`, `trades`,
|
|
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.
|
|
97
|
+
/// - `user` — per-account channels (`fills`, `user_events`,
|
|
98
|
+
/// `order_updates`, `active_asset_data`, …); the 0x address.
|
|
99
|
+
/// - `interval` — `candles` only (`1m`/`5m`/`15m`/`1h`/`4h`/`1d`)
|
|
100
|
+
/// Global channels (`all_mids`) take none.
|
|
47
101
|
export interface WsSubscription {
|
|
48
102
|
type: WsChannel;
|
|
103
|
+
/// Market asset-id as a decimal STRING (`"1"`) — see the interface note.
|
|
49
104
|
coin?: string;
|
|
105
|
+
/// User `0x`-hex address (per-account channels).
|
|
106
|
+
user?: string;
|
|
107
|
+
/// Bar interval token (`candles` only).
|
|
108
|
+
interval?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// `all_mids` payload — every market's tick-snapped whole-USDC mark, keyed by
|
|
112
|
+
/// coin (same plane as the REST `markets` read; no 1e8 scaling).
|
|
113
|
+
export interface AllMids {
|
|
114
|
+
mids: Record<string, string>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// `active_asset_ctx` payload — one market's mark/oracle/funding/OI, in the
|
|
118
|
+
/// whole-USDC plane. `funding` is `null` for an unknown market.
|
|
119
|
+
export interface ActiveAssetCtx {
|
|
120
|
+
coin: string;
|
|
121
|
+
mark_px: string;
|
|
122
|
+
oracle_px: string;
|
|
123
|
+
funding: Funding | null;
|
|
124
|
+
open_interest: string;
|
|
50
125
|
}
|
|
51
126
|
|
|
52
127
|
/// A typed inbound frame `{channel, data}`. `data` is left as `unknown` because
|
|
@@ -72,6 +147,9 @@ export interface WsConfig {
|
|
|
72
147
|
maxBackoffMs: number;
|
|
73
148
|
/// Auto-reconnect on unexpected close. Default: true.
|
|
74
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;
|
|
75
153
|
}
|
|
76
154
|
|
|
77
155
|
const DEFAULT_CONFIG: WsConfig = {
|
|
@@ -79,12 +157,26 @@ const DEFAULT_CONFIG: WsConfig = {
|
|
|
79
157
|
initialBackoffMs: 250,
|
|
80
158
|
maxBackoffMs: 30_000,
|
|
81
159
|
autoReconnect: true,
|
|
160
|
+
postTimeoutMs: 10_000,
|
|
82
161
|
};
|
|
83
162
|
|
|
84
|
-
///
|
|
85
|
-
///
|
|
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
|
+
|
|
174
|
+
/// Subscription set equality key — `(channel, coin, user, interval)` is the
|
|
175
|
+
/// server's routing key, so two subscriptions are identical iff all match
|
|
176
|
+
/// (e.g. `candles` `1m` vs `5m`, or `fills` for two different users, are
|
|
177
|
+
/// distinct subscriptions).
|
|
86
178
|
function subKey(s: WsSubscription): string {
|
|
87
|
-
return `${s.type}:${s.coin ?? ''}`;
|
|
179
|
+
return `${s.type}:${s.coin ?? ''}:${s.user ?? ''}:${s.interval ?? ''}`;
|
|
88
180
|
}
|
|
89
181
|
|
|
90
182
|
/// MTF-native WebSocket client.
|
|
@@ -104,6 +196,7 @@ function subKey(s: WsSubscription): string {
|
|
|
104
196
|
export class WsClient {
|
|
105
197
|
private readonly url: string;
|
|
106
198
|
private readonly config: WsConfig;
|
|
199
|
+
private readonly signer: WsSigner | undefined;
|
|
107
200
|
private socket: WebSocket | undefined;
|
|
108
201
|
/// Active subscriptions, replayed on (re)connect. Keyed for dedupe.
|
|
109
202
|
private readonly active = new Map<string, WsSubscription>();
|
|
@@ -113,13 +206,31 @@ export class WsClient {
|
|
|
113
206
|
private backoffMs: number;
|
|
114
207
|
/// True once `close()` is called — suppresses auto-reconnect.
|
|
115
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
|
+
>();
|
|
116
223
|
|
|
117
|
-
constructor(url: string, config: Partial<WsConfig> = {}) {
|
|
224
|
+
constructor(url: string, config: Partial<WsConfig> = {}, signer?: WsSigner) {
|
|
118
225
|
if (url.length === 0) {
|
|
119
226
|
throw new RangeError('WsClient url must be non-empty');
|
|
120
227
|
}
|
|
228
|
+
if (signer !== undefined && signer.privateKey.length !== 32) {
|
|
229
|
+
throw new RangeError('WsClient signer privateKey must be exactly 32 bytes');
|
|
230
|
+
}
|
|
121
231
|
this.url = url;
|
|
122
232
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
233
|
+
this.signer = signer;
|
|
123
234
|
this.backoffMs = this.config.initialBackoffMs;
|
|
124
235
|
}
|
|
125
236
|
|
|
@@ -158,6 +269,224 @@ export class WsClient {
|
|
|
158
269
|
this.send({ method: 'unsubscribe', subscription: sub });
|
|
159
270
|
}
|
|
160
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
|
+
|
|
161
490
|
/// Whether the socket is currently OPEN.
|
|
162
491
|
get isOpen(): boolean {
|
|
163
492
|
return this.socket?.readyState === 1; // WebSocket.OPEN
|
|
@@ -168,6 +497,13 @@ export class WsClient {
|
|
|
168
497
|
close(): void {
|
|
169
498
|
this.closed = true;
|
|
170
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();
|
|
171
507
|
if (this.socket !== undefined) {
|
|
172
508
|
try {
|
|
173
509
|
this.socket.close();
|
|
@@ -259,11 +595,33 @@ export class WsClient {
|
|
|
259
595
|
} catch {
|
|
260
596
|
return; // ignore non-JSON frames
|
|
261
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
|
+
}
|
|
262
606
|
for (const h of this.handlers) {
|
|
263
607
|
h(frame);
|
|
264
608
|
}
|
|
265
609
|
}
|
|
266
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
|
+
|
|
267
625
|
private clearTimers(): void {
|
|
268
626
|
this.clearPing();
|
|
269
627
|
if (this.reconnectTimer !== undefined) {
|