@metaflux-dex/client 0.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/client.d.ts +32 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +344 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/http.d.ts +17 -0
  8. package/dist/http.d.ts.map +1 -0
  9. package/dist/http.js +106 -0
  10. package/dist/http.js.map +1 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +26 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/info-types.d.ts +380 -0
  16. package/dist/info-types.d.ts.map +1 -0
  17. package/dist/info-types.js +16 -0
  18. package/dist/info-types.js.map +1 -0
  19. package/dist/info.d.ts +65 -0
  20. package/dist/info.d.ts.map +1 -0
  21. package/dist/info.js +252 -0
  22. package/dist/info.js.map +1 -0
  23. package/dist/native.d.ts +10 -0
  24. package/dist/native.d.ts.map +1 -0
  25. package/dist/native.js +252 -0
  26. package/dist/native.js.map +1 -0
  27. package/dist/types.d.ts +143 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +13 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/wasm.d.ts +28 -0
  32. package/dist/wasm.d.ts.map +1 -0
  33. package/dist/wasm.js +279 -0
  34. package/dist/wasm.js.map +1 -0
  35. package/dist/ws.d.ts +43 -0
  36. package/dist/ws.d.ts.map +1 -0
  37. package/dist/ws.js +221 -0
  38. package/dist/ws.js.map +1 -0
  39. package/package.json +65 -0
  40. package/src/client.ts +454 -0
  41. package/src/http.ts +153 -0
  42. package/src/index.ts +144 -0
  43. package/src/info-types.ts +783 -0
  44. package/src/info.ts +355 -0
  45. package/src/native.ts +307 -0
  46. package/src/types.ts +305 -0
  47. package/src/wasm.ts +384 -0
  48. package/src/ws.ts +279 -0
package/src/client.ts ADDED
@@ -0,0 +1,454 @@
1
+ // `Client` — primary entry point for the @metaflux-dex/client SDK.
2
+ //
3
+ // Heavy lifting: order signing (msgpack encode -> EIP-712 hash ->
4
+ // secp256k1 sign -> address derive) runs through WASM. Pure-TS
5
+ // responsibilities: HTTP plumbing, type coercion, optional JWT
6
+ // session bookkeeping.
7
+ //
8
+ // Naming note: exported as `Client` (NOT `MtfClient`) per session
9
+ // direction. Consumers import as `import { Client } from '@metaflux-dex/client'`.
10
+
11
+ import {
12
+ deriveAddressFromPubkey,
13
+ eip712TypedDataHash,
14
+ encodeLimitOrder,
15
+ keccak256,
16
+ recoverPubkey,
17
+ signSecp256k1,
18
+ } from './wasm.js';
19
+ import { httpRequest } from './http.js';
20
+ import {
21
+ buildNativeCancelAction,
22
+ buildNativeOrderAction,
23
+ nativeRequestBody,
24
+ nextNonce,
25
+ recoverNativeSigner,
26
+ signNativeAction,
27
+ } from './native.js';
28
+ import { InfoApi } from './info.js';
29
+ import { WsClient, type WsConfig } from './ws.js';
30
+ import type {
31
+ Market,
32
+ NativeCancel,
33
+ NativeExchangeAck,
34
+ NativeOrder,
35
+ Order,
36
+ OrderAck,
37
+ Position,
38
+ SignedOrder,
39
+ } from './types.js';
40
+
41
+ /// Options accepted by the `Client` constructor.
42
+ export interface ClientOpts {
43
+ /// Gateway base URL — e.g. `https://api.metaflux.example`. The Client
44
+ /// appends CCXT-compat paths (`/ccxt/...`) and MTF-native paths
45
+ /// (`/v1/...`) under this root.
46
+ baseUrl: string;
47
+ /// Optional 32-byte ECDSA private key. Required for any signing
48
+ /// operation (`signOrder`); read-only data calls (`getMarkets`,
49
+ /// `getPositions`) work without it.
50
+ privateKey?: Uint8Array;
51
+ /// EVM chain ID used for the EIP-712 domain. Defaults to a
52
+ /// devnet-style placeholder (`31337`); production deployments override.
53
+ chainId?: number;
54
+ }
55
+
56
+ /// Default chain ID for the EIP-712 domain. The MetaFlux mainnet ID is
57
+ /// reserved-but-not-yet-assigned (PLAN.md §M.1 — "TBD pre-mainnet, avoid
58
+ /// 999 which is HL"); the devnet default `31337` matches what the
59
+ /// existing core-state tests use.
60
+ const DEFAULT_CHAIN_ID = 31337;
61
+
62
+ /// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`
63
+ /// pre-computed at module load. Static across the SDK lifetime so we
64
+ /// don't re-keccak it for every signing call.
65
+ let cachedDomainTypeHash: Uint8Array | undefined;
66
+ async function domainTypeHash(): Promise<Uint8Array> {
67
+ if (cachedDomainTypeHash === undefined) {
68
+ cachedDomainTypeHash = await keccak256(
69
+ new TextEncoder().encode(
70
+ 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)',
71
+ ),
72
+ );
73
+ }
74
+ return cachedDomainTypeHash;
75
+ }
76
+
77
+ /// Pre-computed type-hash for the limit-order action. The string mirrors
78
+ /// what the node will register for the `Order` action — locked in by
79
+ /// RFC-001 §D once the on-chain registry lands. The shape here matches
80
+ /// what `encode_limit_order` produces.
81
+ let cachedOrderTypeHash: Uint8Array | undefined;
82
+ async function orderTypeHash(): Promise<Uint8Array> {
83
+ if (cachedOrderTypeHash === undefined) {
84
+ cachedOrderTypeHash = await keccak256(
85
+ new TextEncoder().encode(
86
+ 'Order(uint32 asset,uint8 side,uint128 px,uint128 size,uint8 tif)',
87
+ ),
88
+ );
89
+ }
90
+ return cachedOrderTypeHash;
91
+ }
92
+
93
+ /// Compute the EIP-712 domain separator for a given chain id.
94
+ ///
95
+ /// Mirrors `core_state::signing::EipDomain::separator` — the canonical
96
+ /// 5-segment keccak input is
97
+ /// `(domain_type_hash, name_hash, version_hash, chain_id_be32, verifying_contract_padded)`.
98
+ async function computeDomainSeparator(chainId: number): Promise<Uint8Array> {
99
+ const dth = await domainTypeHash();
100
+ const nameHash = await keccak256(new TextEncoder().encode('MetaFlux'));
101
+ const versionHash = await keccak256(new TextEncoder().encode('1'));
102
+
103
+ // uint256 chainId big-endian, 32 bytes.
104
+ const chainIdBe = new Uint8Array(32);
105
+ // JS bitwise ops are signed 32-bit; use BigInt for the conversion.
106
+ const view = new DataView(chainIdBe.buffer);
107
+ view.setBigUint64(24, BigInt(chainId)); // low 8 bytes carry the value.
108
+
109
+ // Verifying contract == Address::ZERO, left-padded to 32 bytes -> all zeros.
110
+ const verifyingPadded = new Uint8Array(32);
111
+
112
+ // Concat + keccak. Allocating one Uint8Array beats 5 hasher.update calls
113
+ // because the WASM keccak primitive takes a single slice — the
114
+ // call-overhead of multiple FFI hops would outweigh the copy.
115
+ const concat = new Uint8Array(5 * 32);
116
+ concat.set(dth, 0);
117
+ concat.set(nameHash, 32);
118
+ concat.set(versionHash, 64);
119
+ concat.set(chainIdBe, 96);
120
+ concat.set(verifyingPadded, 128);
121
+ return keccak256(concat);
122
+ }
123
+
124
+ /// Primary client surface. Construct once per session.
125
+ ///
126
+ /// Read-only example:
127
+ /// ```ts
128
+ /// const c = new Client({ baseUrl: 'http://localhost:8080' });
129
+ /// const markets = await c.getMarkets();
130
+ /// ```
131
+ ///
132
+ /// Signing example:
133
+ /// ```ts
134
+ /// const c = new Client({
135
+ /// baseUrl: 'http://localhost:8080',
136
+ /// privateKey: hexToBytes('...'),
137
+ /// });
138
+ /// const signed = await c.signOrder({ asset: 0, side: 0, sizeE8: 100_000_000n,
139
+ /// priceE8: 50_000_00000000n, tif: 0 });
140
+ /// const ack = await c.submitOrder(signed);
141
+ /// ```
142
+ export class Client {
143
+ private readonly baseUrl: string;
144
+ private readonly privateKey: Uint8Array | undefined;
145
+ private readonly chainId: number;
146
+ /// Cached gateway-issued JWT (`/auth`). The session is established
147
+ /// lazily on the first authenticated call.
148
+ private jwt: string | undefined;
149
+ /// MTF-native read API (`POST /info`). Read-only; no key required.
150
+ readonly info: InfoApi;
151
+
152
+ constructor(opts: ClientOpts) {
153
+ if (opts.baseUrl.length === 0) {
154
+ throw new RangeError('Client baseUrl must be non-empty');
155
+ }
156
+ if (opts.privateKey !== undefined && opts.privateKey.length !== 32) {
157
+ throw new RangeError('Client privateKey must be exactly 32 bytes');
158
+ }
159
+ this.baseUrl = opts.baseUrl;
160
+ this.privateKey = opts.privateKey;
161
+ this.chainId = opts.chainId ?? DEFAULT_CHAIN_ID;
162
+ this.info = new InfoApi(this.baseUrl);
163
+ }
164
+
165
+ /// Whether this client has a private key available for signing
166
+ /// operations. Read-only data calls work regardless.
167
+ get canSign(): boolean {
168
+ return this.privateKey !== undefined;
169
+ }
170
+
171
+ /// Sign an order body. Returns the `(payload, signature, signer)`
172
+ /// triplet ready for `submitOrder`.
173
+ ///
174
+ /// All heavy lifting (msgpack encode, keccak, ECDSA) is in WASM.
175
+ /// The signing flow:
176
+ ///
177
+ /// 1. `payload = encode_limit_order(...)` — msgpack body matching the
178
+ /// node's `OrderParams` decoder.
179
+ /// 2. `messageHash = keccak256(orderTypeHash || payload)` — the
180
+ /// EIP-712 "struct hash" of the action.
181
+ /// 3. `domainSeparator = keccak256(EIP712Domain(...))` — cached.
182
+ /// 4. `digest = keccak256(0x1901 || domainSeparator || messageHash)`.
183
+ /// 5. `signature = sign_secp256k1(privateKey, digest)`.
184
+ /// 6. `signer = derive_address_from_pubkey(recover_pubkey(signature, digest))`.
185
+ ///
186
+ /// Step 6 derives the signer locally rather than trusting the gateway
187
+ /// to recover it; that way the SDK ships the address upfront and the
188
+ /// gateway can reject obviously-replayed envelopes before doing ECDSA.
189
+ async signOrder(order: Order): Promise<SignedOrder> {
190
+ if (this.privateKey === undefined) {
191
+ throw new Error(
192
+ 'signOrder requires a privateKey in ClientOpts (this Client is read-only)',
193
+ );
194
+ }
195
+ const payload = await encodeLimitOrder(
196
+ order.asset,
197
+ order.side,
198
+ order.sizeE8,
199
+ order.priceE8,
200
+ order.tif,
201
+ order.stp ?? 0,
202
+ order.cloid,
203
+ order.reduceOnly ?? false,
204
+ order.builder,
205
+ );
206
+ const typeHash = await orderTypeHash();
207
+ // message_hash = keccak256(type_hash || payload).
208
+ const msgBuffer = new Uint8Array(typeHash.length + payload.length);
209
+ msgBuffer.set(typeHash, 0);
210
+ msgBuffer.set(payload, typeHash.length);
211
+ const messageHash = await keccak256(msgBuffer);
212
+
213
+ const domainSeparator = await computeDomainSeparator(this.chainId);
214
+ const digest = await eip712TypedDataHash(domainSeparator, messageHash);
215
+
216
+ const signature = await signSecp256k1(this.privateKey, digest);
217
+ const pubkey = await recoverPubkey(signature, digest);
218
+ const signer = await deriveAddressFromPubkey(pubkey);
219
+
220
+ return { payload, signature, signer };
221
+ }
222
+
223
+ /// Submit a pre-signed order to the gateway.
224
+ ///
225
+ /// DEPRECATED / LEGACY: this targets the old `{payload,signature,signer}` →
226
+ /// `/v1/orders` envelope with a msgpack body and an `Order(...)` typehash.
227
+ /// The server now accepts the MTF-native `{action,nonce,signature}` →
228
+ /// `/exchange` envelope instead — use `submitOrderNative`. Retained
229
+ /// only for any consumer still on the old gateway adapter.
230
+ ///
231
+ /// Wire shape: POSTs the `SignedOrder` as a msgpack-friendly JSON
232
+ /// envelope — `payload` and `signature` go as base64url strings (the
233
+ /// gateway's existing `LoginEnvelope` shape uses base64; we mirror
234
+ /// that), `signer` goes as a 0x-hex address. The gateway adapter
235
+ /// (TODO on the server side) decodes and forwards to the node via
236
+ /// gRPC; for now the SDK targets the CCXT createOrder response shape.
237
+ async submitOrder(signed: SignedOrder): Promise<OrderAck> {
238
+ return httpRequest<OrderAck>(this.baseUrl, '/v1/orders', {
239
+ method: 'POST',
240
+ json: {
241
+ payload: bytesToBase64Url(signed.payload),
242
+ signature: bytesToBase64Url(signed.signature),
243
+ signer: bytesToHex(signed.signer, '0x'),
244
+ },
245
+ bearer: this.jwt,
246
+ });
247
+ }
248
+
249
+ /// Submit an order via the MTF-native signed-action front door
250
+ /// (`POST /exchange`).
251
+ ///
252
+ /// This is the path the server now accepts. It supersedes the legacy
253
+ /// `signOrder` + `submitOrder` flow (msgpack body + `Order(...)` typehash +
254
+ /// `{payload,signature,signer}` → `/v1/orders`), which targeted an
255
+ /// envelope the node no longer recognizes.
256
+ ///
257
+ /// Flow:
258
+ /// 1. `buildNativeOrderAction` produces the canonical snake_case action JSON
259
+ /// string (`{"type":"submit_order","order":{...}}`), field order matching
260
+ /// the server `NativeOrder`.
261
+ /// 2. `signNativeAction` computes the native EIP-712 digest over the EXACT
262
+ /// action bytes (`MetaFluxAction(string action,uint64 nonce)` struct hash,
263
+ /// 5-field domain) and signs it.
264
+ /// 3. The action string is POSTed VERBATIM inside `{action, nonce, signature}`
265
+ /// — the server recovers the signer over the raw `action` bytes, so the
266
+ /// signed bytes and the sent bytes are identical.
267
+ ///
268
+ /// `order.owner` MUST equal the signing wallet's address; we recover the
269
+ /// signer locally and reject a mismatch before hitting the network (the
270
+ /// server enforces the same).
271
+ ///
272
+ /// `nonce` is the per-owner replay nonce bound into the digest. Defaults to
273
+ /// `Date.now()` (unix-ms) — supply an explicit monotonically-increasing
274
+ /// value for back-to-back submissions in the same millisecond.
275
+ ///
276
+ /// `chainId` defaults to the MTF-native chain id (998), independent of the
277
+ /// legacy `ClientOpts.chainId` (which is the wrong domain for this path).
278
+ async submitOrderNative(
279
+ order: NativeOrder,
280
+ opts: { nonce?: bigint; chainId?: number } = {},
281
+ ): Promise<NativeExchangeAck> {
282
+ if (this.privateKey === undefined) {
283
+ throw new Error(
284
+ 'submitOrderNative requires a privateKey in ClientOpts (this Client is read-only)',
285
+ );
286
+ }
287
+ const nonce = opts.nonce ?? nextNonce();
288
+ const actionJson = buildNativeOrderAction(order);
289
+ const signed = await signNativeAction(
290
+ this.privateKey,
291
+ actionJson,
292
+ nonce,
293
+ opts.chainId,
294
+ );
295
+
296
+ // Local guard: the recovered signer must equal the claimed owner. The
297
+ // server enforces this too (401 on mismatch), but failing here saves a
298
+ // round-trip and surfaces a key/owner mismatch with a clear message.
299
+ const signer = await recoverNativeSigner(signed, opts.chainId);
300
+ if (signer.toLowerCase() !== order.owner.toLowerCase()) {
301
+ throw new Error(
302
+ `order.owner ${order.owner} != recovered signer ${signer}`,
303
+ );
304
+ }
305
+
306
+ return httpRequest<NativeExchangeAck>(this.baseUrl, '/exchange', {
307
+ method: 'POST',
308
+ rawJson: nativeRequestBody(signed),
309
+ bearer: this.jwt,
310
+ });
311
+ }
312
+
313
+ /// Cancel an order via the MTF-native signed-action front door
314
+ /// (`POST /exchange`).
315
+ ///
316
+ /// Same envelope + verification model as `submitOrderNative`: the
317
+ /// `cancel_order` action JSON is built canonically, signed over the EIP-712
318
+ /// native digest, and POSTed verbatim. The server cancels by `oid`, so
319
+ /// `cancel.oid` must be set (a `cloid`-only cancel is rejected at lowering).
320
+ ///
321
+ /// `cancel.owner` MUST equal the signing wallet; we recover the signer
322
+ /// locally and reject a mismatch before hitting the network.
323
+ async cancelOrderNative(
324
+ cancel: NativeCancel,
325
+ opts: { nonce?: bigint; chainId?: number } = {},
326
+ ): Promise<NativeExchangeAck> {
327
+ if (this.privateKey === undefined) {
328
+ throw new Error(
329
+ 'cancelOrderNative requires a privateKey in ClientOpts (this Client is read-only)',
330
+ );
331
+ }
332
+ const nonce = opts.nonce ?? nextNonce();
333
+ const actionJson = buildNativeCancelAction(cancel);
334
+ const signed = await signNativeAction(
335
+ this.privateKey,
336
+ actionJson,
337
+ nonce,
338
+ opts.chainId,
339
+ );
340
+
341
+ const signer = await recoverNativeSigner(signed, opts.chainId);
342
+ if (signer.toLowerCase() !== cancel.owner.toLowerCase()) {
343
+ throw new Error(
344
+ `cancel.owner ${cancel.owner} != recovered signer ${signer}`,
345
+ );
346
+ }
347
+
348
+ return httpRequest<NativeExchangeAck>(this.baseUrl, '/exchange', {
349
+ method: 'POST',
350
+ rawJson: nativeRequestBody(signed),
351
+ bearer: this.jwt,
352
+ });
353
+ }
354
+
355
+ /// Open an MTF-native WebSocket connection to `<baseUrl>/ws`.
356
+ ///
357
+ /// Derives the `ws(s)://` URL from the client's `http(s)://` base, mounts the
358
+ /// `/ws` path (the node's upgrade route), and returns a connected
359
+ /// [`WsClient`]. Register handlers via `ws.onMessage` and subscribe with
360
+ /// `ws.subscribe({ type: 'l2Book', coin: 'BTC' })`.
361
+ async connectWs(config: Partial<WsConfig> = {}): Promise<WsClient> {
362
+ const ws = new WsClient(httpToWsUrl(this.baseUrl), config);
363
+ await ws.connect();
364
+ return ws;
365
+ }
366
+
367
+ /// `fetchMarkets` — list of all CCXT-compat market descriptors.
368
+ /// Unauthenticated.
369
+ async getMarkets(): Promise<Market[]> {
370
+ return httpRequest<Market[]>(this.baseUrl, '/ccxt/markets');
371
+ }
372
+
373
+ /// `fetchPositions` for a given account. Authenticated. The CCXT
374
+ /// surface defines `fetchPositions(symbols?)`; the MTF gateway
375
+ /// adapter accepts an explicit `account` query parameter so the
376
+ /// caller can fetch positions for sub-accounts they control.
377
+ async getPositions(account: string): Promise<Position[]> {
378
+ if (!isHexAddress(account)) {
379
+ throw new RangeError(
380
+ `getPositions: account must be a 0x-prefixed 20-byte hex string, got '${account}'`,
381
+ );
382
+ }
383
+ return httpRequest<Position[]>(this.baseUrl, '/ccxt/positions', {
384
+ query: { account },
385
+ bearer: this.jwt,
386
+ });
387
+ }
388
+
389
+ /// Internal: set the JWT after a successful `/auth` exchange. Exposed
390
+ /// so an external auth flow (wallet popup, etc.) can plant a token.
391
+ setJwt(token: string): void {
392
+ this.jwt = token;
393
+ }
394
+ }
395
+
396
+ // ============================================================================
397
+ // Encoding helpers — narrow + private
398
+ // ============================================================================
399
+
400
+ /// Encode a `Uint8Array` as a base64url string (no padding).
401
+ ///
402
+ /// Matches the encoding the gateway's `LoginEnvelope.signature` field
403
+ /// expects (see `metaflux/crates/api-gateway/src/ccxt/auth.rs`). We
404
+ /// avoid Buffer/global polyfills here because the SDK targets both
405
+ /// node and the browser; manual base64 is small and avoids the
406
+ /// dependency.
407
+ function bytesToBase64Url(bytes: Uint8Array): string {
408
+ // Build a binary string of the bytes. `String.fromCharCode` is
409
+ // bounded to ~65k arguments per call — safe for any signature
410
+ // (65 bytes) or payload (under 1KB).
411
+ let binary = '';
412
+ for (const b of bytes) binary += String.fromCharCode(b);
413
+ const b64 = btoa(binary);
414
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
415
+ }
416
+
417
+ /// Encode a `Uint8Array` as a hex string with optional `0x` prefix.
418
+ function bytesToHex(bytes: Uint8Array, prefix: string = ''): string {
419
+ let out = prefix;
420
+ for (const b of bytes) {
421
+ out += b.toString(16).padStart(2, '0');
422
+ }
423
+ return out;
424
+ }
425
+
426
+ /// Derive the WS endpoint URL from the client's HTTP base URL: map the scheme
427
+ /// (`http`→`ws`, `https`→`wss`), strip any trailing slash, and append `/ws`
428
+ /// (the node's upgrade route). A base that is already `ws(s)://` is passed
429
+ /// through (only the `/ws` suffix is ensured).
430
+ function httpToWsUrl(baseUrl: string): string {
431
+ let url = baseUrl;
432
+ if (url.startsWith('https://')) {
433
+ url = `wss://${url.slice('https://'.length)}`;
434
+ } else if (url.startsWith('http://')) {
435
+ url = `ws://${url.slice('http://'.length)}`;
436
+ }
437
+ if (url.endsWith('/')) url = url.slice(0, -1);
438
+ return url.endsWith('/ws') ? url : `${url}/ws`;
439
+ }
440
+
441
+ /// Validate that a string is a `0x` + 40 hex chars EVM address.
442
+ function isHexAddress(s: string): boolean {
443
+ if (s.length !== 42) return false;
444
+ if (!s.startsWith('0x')) return false;
445
+ for (let i = 2; i < s.length; i++) {
446
+ const c = s.charCodeAt(i);
447
+ const isHex =
448
+ (c >= 0x30 && c <= 0x39) ||
449
+ (c >= 0x41 && c <= 0x46) ||
450
+ (c >= 0x61 && c <= 0x66);
451
+ if (!isHex) return false;
452
+ }
453
+ return true;
454
+ }
package/src/http.ts ADDED
@@ -0,0 +1,153 @@
1
+ // Thin fetch wrapper used by the Client class.
2
+ //
3
+ // All HTTP logic is intentionally pure-TS — the WASM module is for
4
+ // crypto + canonical encoding only. The wrapper centralises three
5
+ // concerns: (a) base-URL composition, (b) JWT bearer header (when the
6
+ // caller has authenticated), and (c) translating the gateway's CCXT-
7
+ // compat error envelope `{ "error": "..." }` into a typed exception.
8
+
9
+ import type { ErrorEnvelope } from './types.js';
10
+
11
+ /// Thrown when the gateway responds with a non-2xx status. Carries the
12
+ /// status code + the message extracted from `{ "error": "..." }` (or
13
+ /// the raw body if the response was not JSON).
14
+ export class MetaFluxApiError extends Error {
15
+ constructor(
16
+ public readonly status: number,
17
+ public readonly bodyText: string,
18
+ message: string,
19
+ ) {
20
+ super(`MetaFlux gateway error ${status}: ${message}`);
21
+ this.name = 'MetaFluxApiError';
22
+ }
23
+ }
24
+
25
+ /// Internal fetch options accepted by `httpRequest`. Mirrors a subset
26
+ /// of the standard `RequestInit` plus auth-aware fields.
27
+ export interface HttpRequestInit {
28
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
29
+ /// Object that will be `JSON.stringify`-d into the body. Sets
30
+ /// `Content-Type: application/json` automatically.
31
+ json?: unknown;
32
+ /// Raw `Uint8Array` body — `Content-Type` defaults to
33
+ /// `application/octet-stream` unless overridden. Used by the
34
+ /// signed-action POST surface that carries msgpack bytes.
35
+ bytes?: Uint8Array;
36
+ /// Pre-serialized JSON STRING body — sent verbatim (not re-stringified).
37
+ /// `Content-Type` defaults to `application/json`. Used by the MTF-native
38
+ /// signed-action path, where the `action` field MUST carry the exact bytes
39
+ /// that were signed (the server verifies over `serde_json::RawValue`); a
40
+ /// `JSON.parse`→`JSON.stringify` round-trip would risk reordering / spacing
41
+ /// drift and break every signature.
42
+ rawJson?: string;
43
+ /// JWT bearer token (gateway-issued; persisted by the Client class
44
+ /// after `/auth`). Adds `Authorization: Bearer <jwt>`.
45
+ bearer?: string;
46
+ /// Override / supplement headers.
47
+ headers?: Record<string, string>;
48
+ /// Query-string params. Strings only (`number` -> `String(n)` at
49
+ /// call site so we don't accidentally serialise NaN).
50
+ query?: Record<string, string>;
51
+ /// AbortSignal for cancellation. Useful for the WebSocket-style
52
+ /// long-polling routes the Client adds in later phases.
53
+ signal?: AbortSignal;
54
+ }
55
+
56
+ /// Single fetch wrapper everything routes through.
57
+ ///
58
+ /// Concatenates `baseUrl + path`, applies the query string, sets headers
59
+ /// + body, awaits the response, and either:
60
+ /// - returns the parsed JSON (when the response is 2xx + JSON), or
61
+ /// - returns the raw `Response` (when the caller passed `rawResponse`),
62
+ /// or
63
+ /// - throws `MetaFluxApiError` (non-2xx).
64
+ export async function httpRequest<T>(
65
+ baseUrl: string,
66
+ path: string,
67
+ init: HttpRequestInit = {},
68
+ ): Promise<T> {
69
+ const url = buildUrl(baseUrl, path, init.query);
70
+ const headers: Record<string, string> = { ...(init.headers ?? {}) };
71
+ let body: BodyInit | undefined;
72
+
73
+ if (init.rawJson !== undefined) {
74
+ headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
75
+ body = init.rawJson;
76
+ } else if (init.json !== undefined) {
77
+ headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
78
+ body = JSON.stringify(init.json);
79
+ } else if (init.bytes !== undefined) {
80
+ headers['Content-Type'] =
81
+ headers['Content-Type'] ?? 'application/octet-stream';
82
+ // Re-allocate to a fresh ArrayBuffer slice so a shared/transferred
83
+ // buffer can't be mutated underneath fetch.
84
+ const fresh = new Uint8Array(init.bytes);
85
+ body = fresh;
86
+ }
87
+ if (init.bearer !== undefined) {
88
+ headers['Authorization'] = `Bearer ${init.bearer}`;
89
+ }
90
+
91
+ const res = await fetch(url, {
92
+ method: init.method ?? 'GET',
93
+ headers,
94
+ body,
95
+ signal: init.signal,
96
+ });
97
+
98
+ // Read body once — fetch's body is single-use.
99
+ const text = await res.text();
100
+ if (!res.ok) {
101
+ const msg = extractErrorMessage(text);
102
+ throw new MetaFluxApiError(res.status, text, msg);
103
+ }
104
+ if (text.length === 0) {
105
+ // 204 / empty body — caller asked for a `T`, return undefined cast.
106
+ // The Client never relies on this path; documented here so an
107
+ // accidental schema change surfaces as a runtime cast failure.
108
+ return undefined as unknown as T;
109
+ }
110
+ try {
111
+ return JSON.parse(text) as T;
112
+ } catch {
113
+ throw new MetaFluxApiError(
114
+ res.status,
115
+ text,
116
+ 'response was not valid JSON',
117
+ );
118
+ }
119
+ }
120
+
121
+ /// URL builder. Tolerates `baseUrl` with or without a trailing slash and
122
+ /// `path` with or without a leading slash; never doubles either.
123
+ function buildUrl(
124
+ baseUrl: string,
125
+ path: string,
126
+ query: Record<string, string> | undefined,
127
+ ): string {
128
+ const trimmedBase = baseUrl.endsWith('/')
129
+ ? baseUrl.slice(0, -1)
130
+ : baseUrl;
131
+ const trimmedPath = path.startsWith('/') ? path : `/${path}`;
132
+ const joined = `${trimmedBase}${trimmedPath}`;
133
+ if (query === undefined || Object.keys(query).length === 0) {
134
+ return joined;
135
+ }
136
+ const qs = new URLSearchParams(query).toString();
137
+ return `${joined}?${qs}`;
138
+ }
139
+
140
+ /// Extract the `error` field from a CCXT-compat error envelope. Falls
141
+ /// back to the raw text if parsing fails.
142
+ function extractErrorMessage(text: string): string {
143
+ try {
144
+ const parsed = JSON.parse(text) as Partial<ErrorEnvelope>;
145
+ if (typeof parsed.error === 'string' && parsed.error.length > 0) {
146
+ return parsed.error;
147
+ }
148
+ } catch {
149
+ // Fall through.
150
+ }
151
+ // Truncate to keep stack traces readable.
152
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
153
+ }