@0xmonaco/core 1.0.51 → 1.0.53

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.
@@ -69,7 +69,9 @@ export class MarginAccountsAPIImpl extends BaseAPI {
69
69
  tradingPairId: request.tradingPairId,
70
70
  strategyKey: request.strategyKey,
71
71
  marginMode: request.marginMode,
72
- selectedTradingPairIds: request.selectedTradingPairIds,
72
+ // No selectedTradingPairIds: cross scope is derived from trading, and
73
+ // the server ignores the deprecated field. Not sending it keeps a
74
+ // pre-upgrade caller's stale list from reaching the wire at all.
73
75
  // JSON.stringify drops it when omitted; an explicit false MUST reach
74
76
  // the wire or the server applies its Adjust Margin default and every
75
77
  // funding-loop top-up converts into initial margin requirement.
@@ -89,6 +89,8 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
89
89
  reduceOnly?: boolean;
90
90
  postOnly?: boolean;
91
91
  clientOrderId?: string;
92
+ /** Generated once per call when omitted. Supply the same key to retry a logical order within 24 hours. */
93
+ idempotencyKey?: string;
92
94
  takeProfit?: ParentTpSlLegParams;
93
95
  stopLoss?: ParentTpSlLegParams;
94
96
  }): Promise<CreateOrderResponse>;
@@ -149,6 +151,8 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
149
151
  takeProfit?: ParentTpSlLegParams;
150
152
  stopLoss?: ParentTpSlLegParams;
151
153
  clientOrderId?: string;
154
+ /** Generated once per call when omitted. Supply the same key to retry a logical order within 24 hours. */
155
+ idempotencyKey?: string;
152
156
  }): Promise<CreateOrderResponse>;
153
157
  /**
154
158
  * Cancels an existing order.
@@ -121,6 +121,7 @@ export class TradingAPIImpl extends BaseAPI {
121
121
  reduceOnly: options?.reduceOnly,
122
122
  postOnly: options?.postOnly,
123
123
  clientOrderId: options?.clientOrderId,
124
+ idempotencyKey: options?.idempotencyKey === undefined ? crypto.randomUUID() : options.idempotencyKey,
124
125
  takeProfit: parentTpSlLegToRequest(options?.takeProfit),
125
126
  stopLoss: parentTpSlLegToRequest(options?.stopLoss),
126
127
  };
@@ -199,6 +200,7 @@ export class TradingAPIImpl extends BaseAPI {
199
200
  takeProfit: parentTpSlLegToRequest(options?.takeProfit),
200
201
  stopLoss: parentTpSlLegToRequest(options?.stopLoss),
201
202
  clientOrderId: options?.clientOrderId,
203
+ idempotencyKey: options?.idempotencyKey === undefined ? crypto.randomUUID() : options.idempotencyKey,
202
204
  };
203
205
  return await this.makeAuthenticatedRequest(perpRoutes.orders.create(), {
204
206
  method: "POST",
@@ -387,6 +389,7 @@ export class TradingAPIImpl extends BaseAPI {
387
389
  reduceOnly: order.reduceOnly,
388
390
  postOnly: order.postOnly,
389
391
  clientOrderId: order.clientOrderId,
392
+ idempotencyKey: order.idempotencyKey === undefined ? crypto.randomUUID() : order.idempotencyKey,
390
393
  })),
391
394
  };
392
395
  return this.makeAuthenticatedRequest(perpRoutes.orders.batchCreate(), {
@@ -26,6 +26,33 @@ function readOptionalString(source, field) {
26
26
  }
27
27
  return value;
28
28
  }
29
+ /**
30
+ * Read a field the server serializes as an explicit `null` when unset.
31
+ *
32
+ * Distinct from {@link readOptionalString}, which normalizes absent to
33
+ * `undefined`: a Rust `Option` WITHOUT `skip_serializing_if` always puts the key
34
+ * on the wire, so the declared type is `string | null` and `null` has to survive
35
+ * the parse rather than being laundered into `undefined`.
36
+ *
37
+ * An ABSENT key is a malformed frame, not "unset", and is rejected. The
38
+ * distinction matters because these readers feed the snapshot parsers, whose
39
+ * output is an authoritative complete baseline: reading a missing key as `null`
40
+ * would let a truncated row through as though the server had said the field was
41
+ * empty, and a consumer would clear real state on it. Rejecting withholds the
42
+ * whole snapshot instead, which is the delivery contract's failure mode.
43
+ */
44
+ function readNullableString(source, field) {
45
+ if (!Object.hasOwn(source, field)) {
46
+ throw new Error(`Invalid WebSocket event: ${field} is missing`);
47
+ }
48
+ const value = source[field];
49
+ if (value === null || value === undefined)
50
+ return null;
51
+ if (typeof value !== "string") {
52
+ throw new Error(`Invalid WebSocket event: ${field} must be a string`);
53
+ }
54
+ return value;
55
+ }
29
56
  function readBoolean(source, field) {
30
57
  const value = source[field];
31
58
  if (typeof value !== "boolean") {
@@ -84,152 +111,183 @@ function readOptionalEnumValue(source, field) {
84
111
  const value = readOptionalString(source, field);
85
112
  return value === undefined || value.length === 0 ? undefined : value;
86
113
  }
114
+ /**
115
+ * Parse a conditional order's terminal-state payload.
116
+ *
117
+ * Shared by the live event and the subscribe-time snapshot: a snapshot row IS
118
+ * this payload with no envelope around it, so parsing both through one function
119
+ * is what stops the baseline and the diffs describing state differently.
120
+ */
121
+ function parseConditionalOrderEventData(raw) {
122
+ if (!isRecord(raw)) {
123
+ throw new Error("Invalid conditional order event: data must be an object");
124
+ }
125
+ const data = keysToCamelCase(raw);
126
+ return {
127
+ conditionalOrderId: readString(data, "conditionalOrderId"),
128
+ tradingPairId: readString(data, "tradingPairId"),
129
+ marginAccountId: readString(data, "marginAccountId"),
130
+ positionId: readOptionalString(data, "positionId"),
131
+ parentOrderId: readOptionalString(data, "parentOrderId"),
132
+ associationType: readOptionalEnumValue(data, "associationType"),
133
+ linkedGroupId: readOptionalString(data, "linkedGroupId"),
134
+ conditionType: readString(data, "conditionType"),
135
+ triggerSource: readString(data, "triggerSource"),
136
+ triggerPrice: readString(data, "triggerPrice"),
137
+ side: readString(data, "side"),
138
+ positionSide: readString(data, "positionSide"),
139
+ orderType: readString(data, "orderType"),
140
+ limitPrice: readOptionalString(data, "limitPrice"),
141
+ quantity: readOptionalString(data, "quantity"),
142
+ slippageToleranceBps: readOptionalNumber(data, "slippageToleranceBps"),
143
+ reduceOnly: readBoolean(data, "reduceOnly"),
144
+ timeInForce: readOptionalEnumValue(data, "timeInForce"),
145
+ state: readString(data, "state"),
146
+ triggeredOrderId: readOptionalString(data, "triggeredOrderId"),
147
+ triggeredAt: readOptionalString(data, "triggeredAt"),
148
+ activatedAt: readOptionalString(data, "activatedAt"),
149
+ cancelledAt: readOptionalString(data, "cancelledAt"),
150
+ expiresAt: readOptionalString(data, "expiresAt"),
151
+ failureReason: readOptionalString(data, "failureReason"),
152
+ // Free-form on the wire (the server field is a plain string), so it is
153
+ // passed through untouched rather than matched against a value list.
154
+ reason: readString(data, "reason"),
155
+ updatedAt: readString(data, "updatedAt"),
156
+ // Populated on the subscribe-time snapshot frame and absent on live
157
+ // updates (0XM-2440). Reading it optionally is what keeps the snapshot's
158
+ // value from being silently dropped before the subscriber.
159
+ version: readOptionalNumber(data, "version"),
160
+ };
161
+ }
87
162
  function parseConditionalOrderEvent(rawData) {
88
163
  if (!isRecord(rawData)) {
89
164
  throw new Error("Invalid conditional order event: payload must be an object");
90
165
  }
91
- if (!isRecord(rawData.data)) {
92
- throw new Error("Invalid conditional order event: data must be an object");
93
- }
94
- const data = keysToCamelCase(rawData.data);
95
166
  return {
96
167
  // event_type is the event-class discriminator, validated strictly so a
97
168
  // mis-routed frame is rejected rather than parsed as a conditional order.
98
- // The value fields below stay lenient — read as strings and cast, never
99
- // checked against an allow-list — so an unexpected *value* never drops the
100
- // whole update (0XM-2627). The parse throw is swallowed by the
169
+ // The value fields in the payload stay lenient — read as strings and cast,
170
+ // never checked against an allow-list — so an unexpected *value* never
171
+ // drops the whole update (0XM-2627). The parse throw is swallowed by the
101
172
  // message-handler catch, so a strict enum here means a silently lost frame
102
173
  // with nothing surfaced to the consumer.
103
174
  eventType: readEnum(rawData, "event_type", ["conditional_order_update"]),
104
175
  userId: readString(rawData, "user_id"),
105
- data: {
106
- conditionalOrderId: readString(data, "conditionalOrderId"),
107
- tradingPairId: readString(data, "tradingPairId"),
108
- marginAccountId: readString(data, "marginAccountId"),
109
- positionId: readOptionalString(data, "positionId"),
110
- parentOrderId: readOptionalString(data, "parentOrderId"),
111
- associationType: readOptionalEnumValue(data, "associationType"),
112
- linkedGroupId: readOptionalString(data, "linkedGroupId"),
113
- conditionType: readString(data, "conditionType"),
114
- triggerSource: readString(data, "triggerSource"),
115
- triggerPrice: readString(data, "triggerPrice"),
116
- side: readString(data, "side"),
117
- positionSide: readString(data, "positionSide"),
118
- orderType: readString(data, "orderType"),
119
- limitPrice: readOptionalString(data, "limitPrice"),
120
- quantity: readOptionalString(data, "quantity"),
121
- slippageToleranceBps: readOptionalNumber(data, "slippageToleranceBps"),
122
- reduceOnly: readBoolean(data, "reduceOnly"),
123
- timeInForce: readOptionalEnumValue(data, "timeInForce"),
124
- state: readString(data, "state"),
125
- triggeredOrderId: readOptionalString(data, "triggeredOrderId"),
126
- triggeredAt: readOptionalString(data, "triggeredAt"),
127
- activatedAt: readOptionalString(data, "activatedAt"),
128
- cancelledAt: readOptionalString(data, "cancelledAt"),
129
- expiresAt: readOptionalString(data, "expiresAt"),
130
- failureReason: readOptionalString(data, "failureReason"),
131
- // Free-form on the wire (the server field is a plain string), so it is
132
- // passed through untouched rather than matched against a value list.
133
- reason: readString(data, "reason"),
134
- updatedAt: readString(data, "updatedAt"),
135
- // Populated on the subscribe-time snapshot frame and absent on live
136
- // updates (0XM-2440). Reading it optionally is what keeps the
137
- // snapshot's value from being silently dropped before the subscriber.
138
- version: readOptionalNumber(data, "version"),
139
- },
176
+ data: parseConditionalOrderEventData(rawData.data),
177
+ };
178
+ }
179
+ /**
180
+ * Parse a position's terminal-state payload. Shared by the live event and the
181
+ * subscribe-time snapshot (see {@link parseConditionalOrderEventData}).
182
+ */
183
+ function parsePositionEventData(raw) {
184
+ if (!isRecord(raw)) {
185
+ throw new Error("Invalid position event: data must be an object");
186
+ }
187
+ const data = keysToCamelCase(raw);
188
+ return {
189
+ positionId: readString(data, "positionId"),
190
+ marginAccountId: readString(data, "marginAccountId"),
191
+ tradingPairId: readString(data, "tradingPairId"),
192
+ side: readString(data, "side"),
193
+ status: readString(data, "status"),
194
+ size: readString(data, "size"),
195
+ entryPrice: readString(data, "entryPrice"),
196
+ markPrice: readOptionalString(data, "markPrice"),
197
+ unrealizedPnl: readOptionalString(data, "unrealizedPnl"),
198
+ liquidationPrice: readOptionalString(data, "liquidationPrice"),
199
+ isolatedMargin: readString(data, "isolatedMargin"),
200
+ leverage: readString(data, "leverage"),
201
+ tradeId: readOptionalString(data, "tradeId"),
202
+ orderId: readOptionalString(data, "orderId"),
203
+ reason: readString(data, "reason"),
204
+ updatedAt: readString(data, "updatedAt"),
140
205
  };
141
206
  }
142
207
  function parsePositionEvent(rawData) {
143
208
  if (!isRecord(rawData)) {
144
209
  throw new Error("Invalid position event: payload must be an object");
145
210
  }
146
- if (!isRecord(rawData.data)) {
147
- throw new Error("Invalid position event: data must be an object");
148
- }
149
- const data = keysToCamelCase(rawData.data);
150
211
  return {
151
212
  // event_type is the event-class discriminator, validated strictly so a
152
213
  // mis-routed frame is rejected rather than parsed as a position. The value
153
- // fields below (side/status/reason) stay lenient so an unexpected *value*
154
- // never drops a risk-critical update.
214
+ // fields in the payload (side/status/reason) stay lenient so an unexpected
215
+ // *value* never drops a risk-critical update.
155
216
  eventType: readEnum(rawData, "event_type", ["position_update"]),
156
217
  userId: readString(rawData, "user_id"),
157
- data: {
158
- positionId: readString(data, "positionId"),
159
- marginAccountId: readString(data, "marginAccountId"),
160
- tradingPairId: readString(data, "tradingPairId"),
161
- side: readString(data, "side"),
162
- status: readString(data, "status"),
163
- size: readString(data, "size"),
164
- entryPrice: readString(data, "entryPrice"),
165
- markPrice: readOptionalString(data, "markPrice"),
166
- unrealizedPnl: readOptionalString(data, "unrealizedPnl"),
167
- liquidationPrice: readOptionalString(data, "liquidationPrice"),
168
- isolatedMargin: readString(data, "isolatedMargin"),
169
- leverage: readString(data, "leverage"),
170
- tradeId: readOptionalString(data, "tradeId"),
171
- orderId: readOptionalString(data, "orderId"),
172
- reason: readString(data, "reason"),
173
- updatedAt: readString(data, "updatedAt"),
174
- },
218
+ data: parsePositionEventData(rawData.data),
219
+ };
220
+ }
221
+ /**
222
+ * Parse a liquidation alert's payload. Shared by the live event and the
223
+ * subscribe-time snapshot (see {@link parseConditionalOrderEventData}).
224
+ */
225
+ function parseLiquidationEventData(raw) {
226
+ if (!isRecord(raw)) {
227
+ throw new Error("Invalid liquidation event: data must be an object");
228
+ }
229
+ const data = keysToCamelCase(raw);
230
+ return {
231
+ liquidationId: readString(data, "liquidationId"),
232
+ marginAccountId: readString(data, "marginAccountId"),
233
+ positionId: readOptionalString(data, "positionId"),
234
+ tradingPairId: readOptionalString(data, "tradingPairId"),
235
+ status: readString(data, "status"),
236
+ accountState: readString(data, "accountState"),
237
+ liquidationPrice: readOptionalString(data, "liquidationPrice"),
238
+ markPrice: readOptionalString(data, "markPrice"),
239
+ estimatedShortfall: readString(data, "estimatedShortfall"),
240
+ message: readString(data, "message"),
241
+ triggeredAt: readString(data, "triggeredAt"),
175
242
  };
176
243
  }
177
244
  function parseLiquidationEvent(rawData) {
178
245
  if (!isRecord(rawData)) {
179
246
  throw new Error("Invalid liquidation event: payload must be an object");
180
247
  }
181
- if (!isRecord(rawData.data)) {
182
- throw new Error("Invalid liquidation event: data must be an object");
183
- }
184
- const data = keysToCamelCase(rawData.data);
185
248
  return {
186
249
  // event_type is the event-class discriminator, validated strictly so a
187
250
  // mis-routed frame is rejected rather than parsed as a liquidation. The
188
- // free-form value fields below (status/accountState) stay lenient so an
189
- // unexpected *value* never drops a risk-critical alert.
251
+ // free-form value fields in the payload (status/accountState) stay lenient
252
+ // so an unexpected *value* never drops a risk-critical alert.
190
253
  eventType: readEnum(rawData, "event_type", ["liquidation_alert"]),
191
254
  userId: readString(rawData, "user_id"),
192
- data: {
193
- liquidationId: readString(data, "liquidationId"),
194
- marginAccountId: readString(data, "marginAccountId"),
195
- positionId: readOptionalString(data, "positionId"),
196
- tradingPairId: readOptionalString(data, "tradingPairId"),
197
- status: readString(data, "status"),
198
- accountState: readString(data, "accountState"),
199
- liquidationPrice: readOptionalString(data, "liquidationPrice"),
200
- markPrice: readOptionalString(data, "markPrice"),
201
- estimatedShortfall: readString(data, "estimatedShortfall"),
202
- message: readString(data, "message"),
203
- triggeredAt: readString(data, "triggeredAt"),
204
- },
255
+ data: parseLiquidationEventData(rawData.data),
256
+ };
257
+ }
258
+ /**
259
+ * Parse an account's margin-health payload. Shared by the live event and the
260
+ * subscribe-time snapshot (see {@link parseConditionalOrderEventData}).
261
+ */
262
+ function parseAccountEventData(raw) {
263
+ if (!isRecord(raw)) {
264
+ throw new Error("Invalid account event: data must be an object");
265
+ }
266
+ const data = keysToCamelCase(raw);
267
+ return {
268
+ marginAccountId: readString(data, "marginAccountId"),
269
+ // Monotonic per-account publish counter (0XM-2498): a plain JSON number on
270
+ // the wire, used to order/dedupe frames across reconnect races. Must be a
271
+ // non-negative safe integer — anything else is a malformed frame.
272
+ sequence: readSequence(data, "sequence"),
273
+ equity: readString(data, "equity"),
274
+ collateral: readString(data, "collateral"),
275
+ freeCollateral: readString(data, "freeCollateral"),
276
+ marginRatio: readString(data, "marginRatio"),
277
+ maintenanceRequirement: readString(data, "maintenanceRequirement"),
278
+ updatedAt: readString(data, "updatedAt"),
205
279
  };
206
280
  }
207
281
  function parseAccountEvent(rawData) {
208
282
  if (!isRecord(rawData)) {
209
283
  throw new Error("Invalid account event: payload must be an object");
210
284
  }
211
- if (!isRecord(rawData.data)) {
212
- throw new Error("Invalid account event: data must be an object");
213
- }
214
- const data = keysToCamelCase(rawData.data);
215
285
  return {
216
286
  // event_type is the event-class discriminator, validated strictly so a
217
287
  // mis-routed frame is rejected rather than parsed as an account update.
218
288
  eventType: readEnum(rawData, "event_type", ["account_update"]),
219
289
  userId: readString(rawData, "user_id"),
220
- data: {
221
- marginAccountId: readString(data, "marginAccountId"),
222
- // Monotonic per-account publish counter (0XM-2498): a plain JSON number on
223
- // the wire, used to order/dedupe frames across reconnect races. Must be a
224
- // non-negative safe integer — anything else is a malformed frame.
225
- sequence: readSequence(data, "sequence"),
226
- equity: readString(data, "equity"),
227
- collateral: readString(data, "collateral"),
228
- freeCollateral: readString(data, "freeCollateral"),
229
- marginRatio: readString(data, "marginRatio"),
230
- maintenanceRequirement: readString(data, "maintenanceRequirement"),
231
- updatedAt: readString(data, "updatedAt"),
232
- },
290
+ data: parseAccountEventData(rawData.data),
233
291
  };
234
292
  }
235
293
  function parseMarketStatsData(raw) {
@@ -350,12 +408,9 @@ function parseInstrumentEvent(rawData) {
350
408
  };
351
409
  }
352
410
  /**
353
- * Parse a `twap_orders` channel frame into a typed event. Only the envelope
354
- * and the snapshot's identity/state fields are validated strictly — the rest
355
- * of the TwapOrder snapshot is passed through camelCased, so additive server
356
- * fields never break older clients. The server serializes absent optional
357
- * fields as explicit `null`; those are dropped so the parsed snapshot matches
358
- * the optional (`?:`) fields of `TwapOrderEventData`.
411
+ * Parse a `twap_orders` channel frame into a typed event. The envelope is
412
+ * validated strictly here; the payload's own leniency contract lives in
413
+ * {@link parseTwapOrderEventData}.
359
414
  */
360
415
  function parseTwapOrderEvent(rawData) {
361
416
  if (!isRecord(rawData)) {
@@ -372,7 +427,27 @@ function parseTwapOrderEvent(rawData) {
372
427
  if (typeof userId !== "string" || userId.length === 0) {
373
428
  throw new Error("Invalid TWAP order event: user_id must be a non-empty string");
374
429
  }
375
- const data = keysToCamelCase(rawData.data);
430
+ return {
431
+ eventType: "twap_order_update",
432
+ userId,
433
+ data: parseTwapOrderEventData(rawData.data),
434
+ };
435
+ }
436
+ /**
437
+ * Parse a TWAP parent order's terminal-state payload. Shared by the live event
438
+ * and the subscribe-time snapshot (see {@link parseConditionalOrderEventData}).
439
+ *
440
+ * Only the identity and state fields are validated; the rest of the snapshot is
441
+ * passed through camelCased, so additive server fields never break older
442
+ * clients. The server serializes absent optional fields as explicit `null`;
443
+ * those are dropped so the parsed payload matches the optional (`?:`) fields of
444
+ * `TwapOrderEventData`.
445
+ */
446
+ function parseTwapOrderEventData(raw) {
447
+ if (!isRecord(raw)) {
448
+ throw new Error("Invalid TWAP order event: data must be an object");
449
+ }
450
+ const data = keysToCamelCase(raw);
376
451
  for (const key of Object.keys(data)) {
377
452
  if (data[key] === null) {
378
453
  delete data[key];
@@ -393,10 +468,139 @@ function parseTwapOrderEvent(rawData) {
393
468
  if (typeof state !== "string" || state.length === 0) {
394
469
  throw new Error("Invalid TWAP order event: state must be a non-empty string");
395
470
  }
471
+ return data;
472
+ }
473
+ /**
474
+ * Parse a user balance payload. Shared by the live event and the subscribe-time
475
+ * snapshot (see {@link parseConditionalOrderEventData}).
476
+ *
477
+ * `tokenSymbol` and `referenceId` are read as nullable rather than optional:
478
+ * the server sends both keys always, carrying `null` when unset, and
479
+ * `UserBalanceEventData` declares them `string | null`.
480
+ */
481
+ function parseUserBalanceEventData(raw) {
482
+ if (!isRecord(raw)) {
483
+ throw new Error("Invalid balance event: data must be an object");
484
+ }
485
+ const data = keysToCamelCase(raw);
396
486
  return {
397
- eventType: "twap_order_update",
398
- userId,
399
- data: data,
487
+ tokenAddress: readString(data, "tokenAddress"),
488
+ tokenSymbol: readNullableString(data, "tokenSymbol"),
489
+ available: readString(data, "available"),
490
+ availableRaw: readString(data, "availableRaw"),
491
+ locked: readString(data, "locked"),
492
+ lockedRaw: readString(data, "lockedRaw"),
493
+ total: readString(data, "total"),
494
+ totalRaw: readString(data, "totalRaw"),
495
+ // Free-form on the wire and read leniently: a snapshot row carries the
496
+ // `snapshot` literal, a live frame the mutation cause, and a reason added
497
+ // server-side after this SDK shipped must not drop the row (0XM-2627).
498
+ reason: readString(data, "reason"),
499
+ referenceId: readNullableString(data, "referenceId"),
500
+ updatedAt: readString(data, "updatedAt"),
501
+ };
502
+ }
503
+ /**
504
+ * Parse one resting order from an `orders` channel snapshot.
505
+ *
506
+ * This is the one snapshot row that is NOT the channel's live payload: an order
507
+ * event describes a change, a snapshot row describes resting state, so the row
508
+ * carries `status` and the cumulative fill totals and carries none of the
509
+ * change-only fill metadata.
510
+ *
511
+ * The optional fields are read as nullable, not optional: they are Rust
512
+ * `Option`s serialized without `skip_serializing_if`, so the key is always
513
+ * present carrying `null`. `clientOrderId` is the one field the server omits
514
+ * outright, so it is the one read as optional.
515
+ */
516
+ function parseOrderSnapshotItem(raw) {
517
+ if (!isRecord(raw)) {
518
+ throw new Error("Invalid order snapshot: item must be an object");
519
+ }
520
+ const item = keysToCamelCase(raw);
521
+ // Read once: this is the only genuinely-omitted field, and it is spread in
522
+ // conditionally so an absent one stays absent rather than becoming
523
+ // `clientOrderId: undefined`, which a structural comparison would see.
524
+ const clientOrderId = readOptionalString(item, "clientOrderId");
525
+ const version = readOptionalNumber(item, "version");
526
+ return {
527
+ orderId: readString(item, "orderId"),
528
+ userId: readString(item, "userId"),
529
+ applicationId: readString(item, "applicationId"),
530
+ tradingPairId: readString(item, "tradingPairId"),
531
+ tradingPair: readString(item, "tradingPair"),
532
+ // Value fields are read as plain strings and cast, never matched against an
533
+ // allow-list: an unrecognized value must not drop the row, or a consumer
534
+ // seeding from the baseline would silently lose an order that is really
535
+ // resting on the book (0XM-2627).
536
+ tradingMode: readString(item, "tradingMode"),
537
+ side: readString(item, "side"),
538
+ orderType: readString(item, "orderType"),
539
+ price: readNullableString(item, "price"),
540
+ quantity: readString(item, "quantity"),
541
+ quantityRaw: readString(item, "quantityRaw"),
542
+ filledQuantity: readString(item, "filledQuantity"),
543
+ filledQuantityRaw: readString(item, "filledQuantityRaw"),
544
+ remainingQuantity: readNullableString(item, "remainingQuantity"),
545
+ remainingQuantityRaw: readNullableString(item, "remainingQuantityRaw"),
546
+ status: readString(item, "status"),
547
+ timeInForce: readNullableString(item, "timeInForce"),
548
+ averageFillPrice: readNullableString(item, "averageFillPrice"),
549
+ createdAt: readString(item, "createdAt"),
550
+ updatedAt: readNullableString(item, "updatedAt"),
551
+ marginAccountId: readNullableString(item, "marginAccountId"),
552
+ positionId: readNullableString(item, "positionId"),
553
+ leverage: readNullableString(item, "leverage"),
554
+ reduceOnly: readBoolean(item, "reduceOnly"),
555
+ ...(clientOrderId === undefined ? {} : { clientOrderId }),
556
+ // Spread in conditionally for the same reason as `clientOrderId`: the
557
+ // server omits the key when unknown, and absent must stay absent rather
558
+ // than becoming `version: undefined` — a resync compares this value, and
559
+ // absent means unknown, never zero (0XM-2440).
560
+ ...(version === undefined ? {} : { version }),
561
+ };
562
+ }
563
+ /**
564
+ * Wrap a caller's `onSnapshot` in the internal snapshot-frame handler.
565
+ *
566
+ * The frame's `data` is the channel's COMPLETE current state as a flat array,
567
+ * and that completeness is the whole contract: a consumer is expected to
568
+ * replace state from it, so absence from the array means the entity is gone.
569
+ *
570
+ * Delivery is therefore all-or-nothing. If the payload is not an array, or any
571
+ * single row fails to parse, NOTHING is delivered.
572
+ *
573
+ * Skipping just the unreadable row and delivering the rest would be worse than
574
+ * delivering nothing, because the array would still be presented as complete: a
575
+ * consumer would drop the entity that row described — zero its balance, remove
576
+ * its market, cancel its order in the UI — on the strength of an omission the
577
+ * SDK itself created. Nor is that self-correcting. A resting order or an
578
+ * unchanged market has no later diff to repair it, so the deletion would stand
579
+ * for the life of the connection.
580
+ *
581
+ * Withholding the frame instead leaves the consumer on whatever baseline it
582
+ * already had, and the channel keeps streaming live events over it. That is a
583
+ * strictly smaller loss, and it is the same all-or-nothing rule this channel's
584
+ * original `instrument` snapshot already applied.
585
+ *
586
+ * `[]` is a legitimate baseline, not a failure, and is always delivered:
587
+ * "nothing is open" is exactly the information a consumer needs to clear stale
588
+ * state after a reconnect.
589
+ */
590
+ function snapshotFrameHandler(channel, parseItem, onSnapshot) {
591
+ return (rawData) => {
592
+ try {
593
+ if (!Array.isArray(rawData)) {
594
+ throw new Error(`Invalid ${channel} snapshot: data must be an array`);
595
+ }
596
+ // Parsed fully before anything is handed over, so a failure part-way
597
+ // through cannot deliver a truncated baseline.
598
+ const items = rawData.map(parseItem);
599
+ onSnapshot(items);
600
+ }
601
+ catch (err) {
602
+ console.error(`WebSocket: Error processing ${channel} snapshot`, err);
603
+ }
400
604
  };
401
605
  }
402
606
  /**
@@ -759,7 +963,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
759
963
  };
760
964
  };
761
965
  // --- Channel-specific subscription methods ---
762
- const subscribeOrders = (tradingPairId, tradingMode, handler) => {
966
+ const subscribeOrders = (tradingPairId, tradingMode, handler, onSnapshot) => {
763
967
  const channel = `orders:${tradingPairId}:${tradingMode}`;
764
968
  return subscribe(channel, (rawData) => {
765
969
  try {
@@ -775,7 +979,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
775
979
  catch (err) {
776
980
  console.error("WebSocket: Error processing order event", err);
777
981
  }
778
- });
982
+ }, onSnapshot && snapshotFrameHandler("orders", parseOrderSnapshotItem, onSnapshot));
779
983
  };
780
984
  const subscribeOrderbook = (tradingPairId, tradingMode, magnitude, quotationMode, handler) => {
781
985
  if (!tradingPairId || !tradingMode || magnitude === undefined || !quotationMode) {
@@ -957,7 +1161,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
957
1161
  * Subscribe to authenticated user's order events (all trading pairs)
958
1162
  * Requires authentication - user is identified from the signed session-key handshake
959
1163
  */
960
- const subscribeUserOrders = (handler) => {
1164
+ const subscribeUserOrders = (handler, onSnapshot) => {
961
1165
  // User is identified from the authenticated session, so channel is just "orders"
962
1166
  const channel = "orders";
963
1167
  return subscribe(channel, (rawData) => {
@@ -974,44 +1178,33 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
974
1178
  catch (err) {
975
1179
  console.error("WebSocket: Error processing user order event", err);
976
1180
  }
977
- });
1181
+ }, onSnapshot && snapshotFrameHandler("orders", parseOrderSnapshotItem, onSnapshot));
978
1182
  };
979
1183
  /**
980
1184
  * Subscribe to authenticated user's balance events
981
1185
  * Requires authentication - user is identified from the signed session-key handshake
982
1186
  */
983
- const subscribeBalances = (handler) => {
1187
+ const subscribeBalances = (handler, onSnapshot) => {
984
1188
  // User is identified from the authenticated session, so channel is just "balances"
985
1189
  const channel = "balances";
986
1190
  return subscribe(channel, (rawData) => {
987
1191
  try {
988
- const data = rawData;
989
- const balanceData = data.data;
1192
+ if (!isRecord(rawData)) {
1193
+ throw new Error("Invalid balance event: payload must be an object");
1194
+ }
990
1195
  const event = {
991
1196
  eventType: "balance_update",
992
- userId: data.user_id,
993
- data: {
994
- tokenAddress: balanceData.token_address,
995
- tokenSymbol: balanceData.token_symbol,
996
- available: balanceData.available,
997
- availableRaw: balanceData.available_raw,
998
- locked: balanceData.locked,
999
- lockedRaw: balanceData.locked_raw,
1000
- total: balanceData.total,
1001
- totalRaw: balanceData.total_raw,
1002
- reason: balanceData.reason,
1003
- referenceId: balanceData.reference_id,
1004
- updatedAt: balanceData.updated_at,
1005
- },
1197
+ userId: readString(rawData, "user_id"),
1198
+ data: parseUserBalanceEventData(rawData.data),
1006
1199
  };
1007
1200
  handler(event);
1008
1201
  }
1009
1202
  catch (err) {
1010
1203
  console.error("WebSocket: Error processing balance event", err);
1011
1204
  }
1012
- });
1205
+ }, onSnapshot && snapshotFrameHandler("balances", parseUserBalanceEventData, onSnapshot));
1013
1206
  };
1014
- const subscribeConditionalOrders = (handler, tradingPairId) => {
1207
+ const subscribeConditionalOrders = (handler, tradingPairId, onSnapshot) => {
1015
1208
  if (!handler) {
1016
1209
  throw new Error("conditionalOrders subscription requires a handler");
1017
1210
  }
@@ -1026,9 +1219,9 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1026
1219
  catch (err) {
1027
1220
  console.error("WebSocket: Error processing conditional order event", err);
1028
1221
  }
1029
- });
1222
+ }, onSnapshot && snapshotFrameHandler("conditional orders", parseConditionalOrderEventData, onSnapshot));
1030
1223
  };
1031
- const subscribeTwapOrders = (handler, tradingPairId) => {
1224
+ const subscribeTwapOrders = (handler, tradingPairId, onSnapshot) => {
1032
1225
  if (!handler) {
1033
1226
  throw new Error("twapOrders subscription requires a handler");
1034
1227
  }
@@ -1043,7 +1236,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1043
1236
  catch (err) {
1044
1237
  console.error("WebSocket: Error processing TWAP order event", err);
1045
1238
  }
1046
- });
1239
+ }, onSnapshot && snapshotFrameHandler("TWAP orders", parseTwapOrderEventData, onSnapshot));
1047
1240
  };
1048
1241
  /**
1049
1242
  * Subscribe to authenticated user's position updates.
@@ -1052,7 +1245,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1052
1245
  * Lag policy is Conflate: a lagging subscriber is fast-forwarded, no gap
1053
1246
  * signal.
1054
1247
  */
1055
- const subscribePositions = (handler, tradingPairId) => {
1248
+ const subscribePositions = (handler, tradingPairId, onSnapshot) => {
1056
1249
  if (!handler) {
1057
1250
  throw new Error("positions subscription requires a handler");
1058
1251
  }
@@ -1067,7 +1260,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1067
1260
  catch (err) {
1068
1261
  console.error("WebSocket: Error processing position event", err);
1069
1262
  }
1070
- });
1263
+ }, onSnapshot && snapshotFrameHandler("positions", parsePositionEventData, onSnapshot));
1071
1264
  };
1072
1265
  /**
1073
1266
  * Subscribe to authenticated user's liquidation alerts.
@@ -1076,7 +1269,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1076
1269
  * Lag policy is Disconnect: a subscriber that falls behind is closed with
1077
1270
  * `1013`; resync by refetching a snapshot (no wire sequence number).
1078
1271
  */
1079
- const subscribeLiquidations = (handler, tradingPairId) => {
1272
+ const subscribeLiquidations = (handler, tradingPairId, onSnapshot) => {
1080
1273
  if (!handler) {
1081
1274
  throw new Error("liquidations subscription requires a handler");
1082
1275
  }
@@ -1091,7 +1284,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1091
1284
  catch (err) {
1092
1285
  console.error("WebSocket: Error processing liquidation event", err);
1093
1286
  }
1094
- });
1287
+ }, onSnapshot && snapshotFrameHandler("liquidations", parseLiquidationEventData, onSnapshot));
1095
1288
  };
1096
1289
  /**
1097
1290
  * Subscribe to the authenticated user's account-level margin health updates
@@ -1101,7 +1294,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1101
1294
  * is fast-forwarded, no gap signal. Each frame is a terminal-state snapshot
1102
1295
  * per margin account, not an additive delta.
1103
1296
  */
1104
- const subscribeAccount = (handler) => {
1297
+ const subscribeAccount = (handler, onSnapshot) => {
1105
1298
  if (!handler) {
1106
1299
  throw new Error("account subscription requires a handler");
1107
1300
  }
@@ -1112,7 +1305,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1112
1305
  catch (err) {
1113
1306
  console.error("WebSocket: Error processing account event", err);
1114
1307
  }
1115
- });
1308
+ }, onSnapshot && snapshotFrameHandler("account", parseAccountEventData, onSnapshot));
1116
1309
  };
1117
1310
  /**
1118
1311
  * Subscribe to market lifecycle + configuration changes (public): listings,
@@ -1127,7 +1320,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1127
1320
  * genuinely new wire subscriptions). Lag policy is Conflate: a lagging
1128
1321
  * subscriber is fast-forwarded, no gap signal.
1129
1322
  */
1130
- const subscribeInstruments = (handler, tradingPairId) => {
1323
+ const subscribeInstruments = (handler, tradingPairId, onSnapshot) => {
1131
1324
  if (!handler) {
1132
1325
  throw new Error("instruments subscription requires a handler");
1133
1326
  }
@@ -1166,37 +1359,82 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1166
1359
  },
1167
1360
  // The subscribe-time baseline arrives as one Snapshot frame whose data is
1168
1361
  // a flat InstrumentEventData[] (a `listing` per active market, no
1169
- // per-item envelope). Fan it out as one event per market so the handler
1170
- // sees the same shape for baseline and live frames.
1171
- (rawData) => {
1172
- try {
1173
- if (!Array.isArray(rawData)) {
1174
- throw new Error("Invalid instrument snapshot: data must be an array");
1175
- }
1176
- const events = rawData.map(parseInstrumentEventData).map((item) => ({ tradingPairId: item.tradingPairId, data: item }));
1177
- // A snapshot is the authoritative active-market set: replace the
1178
- // channel's cache wholesale rather than upserting, so markets absent
1179
- // from it (delisted while disconnected) don't linger.
1180
- instrumentBaseline.set(channel, new Map(events.map((event) => [event.tradingPairId, event])));
1181
- for (const event of events) {
1182
- handler(event);
1183
- }
1362
+ // per-item envelope).
1363
+ //
1364
+ // Where it goes depends on whether the caller opted in. With
1365
+ // `onSnapshot`, the rows are handed over as one array and the event
1366
+ // handler sees none of them — that array is the snapshot BOUNDARY a
1367
+ // consumer needs to replace its market set wholesale after a reconnect,
1368
+ // which per-market events cannot express. Without it, the rows are fanned
1369
+ // out as one synthetic event per market, which is how this channel has
1370
+ // always delivered its baseline; changing that for callers who never
1371
+ // asked would be a silent behaviour break.
1372
+ //
1373
+ // Either way the local cache is replaced wholesale rather than upserted,
1374
+ // so markets absent from the snapshot (delisted while disconnected) do
1375
+ // not linger.
1376
+ snapshotFrameHandler("instrument", parseInstrumentEventData, (items) => {
1377
+ const events = items.map((item) => ({ tradingPairId: item.tradingPairId, data: item }));
1378
+ instrumentBaseline.set(channel, new Map(events.map((event) => [event.tradingPairId, event])));
1379
+ if (onSnapshot) {
1380
+ onSnapshot(items);
1381
+ return;
1184
1382
  }
1185
- catch (err) {
1186
- console.error("WebSocket: Error processing instrument snapshot", err);
1383
+ for (const event of events) {
1384
+ handler(event);
1187
1385
  }
1188
- });
1386
+ }));
1189
1387
  // Replay only while the socket is open: during abnormal-close backoff the
1190
1388
  // handlers and cache survive but the state is pre-outage — the reconnect's
1191
1389
  // resubscribe clears it and a fresh authoritative snapshot follows, so a
1192
1390
  // backoff-time subscriber must wait for that instead.
1193
1391
  if (isLateSubscriber && ws?.readyState === WebSocket.OPEN) {
1194
- const cached = instrumentBaseline.get(channel);
1195
- if (cached && cached.size > 0) {
1196
- const replay = [...cached.values()];
1392
+ // Presence, not size. An entry exists only once a snapshot has been
1393
+ // received for the channel, so an EMPTY one is a genuine "no active
1394
+ // markets" baseline rather than "nothing known yet" — and under this
1395
+ // API empty is authoritative information a late subscriber needs in
1396
+ // order to clear stale markets.
1397
+ if (instrumentBaseline.has(channel)) {
1197
1398
  queueMicrotask(() => {
1198
1399
  if (unsubscribed)
1199
1400
  return;
1401
+ // Read INSIDE the microtask, and re-check the socket. This
1402
+ // subscriber is already registered by now, so a live frame landing
1403
+ // first would update the cache and reach `handler` directly — a
1404
+ // snapshot array captured before the microtask would then arrive
1405
+ // afterwards carrying older state, and a consumer that replaces its
1406
+ // market map from it would go backwards. A `disconnect()` in the
1407
+ // same window drops the cache outright, and delivering a baseline
1408
+ // after that would be inventing one.
1409
+ const cached = instrumentBaseline.get(channel);
1410
+ if (!cached || ws?.readyState !== WebSocket.OPEN)
1411
+ return;
1412
+ const replay = [...cached.values()];
1413
+ // The replay IS this subscriber's baseline — it stands in for the
1414
+ // wire snapshot the suppressed Subscribe never triggered — so it
1415
+ // follows the same delivery contract: one array to `onSnapshot`
1416
+ // when opted in, per-market events otherwise.
1417
+ //
1418
+ // The two forms carry deliberately different sets. The ARRAY is
1419
+ // filtered to active markets, because that is what a real snapshot
1420
+ // is — the server enumerates active pairs, while this cache also
1421
+ // holds the delistings it has seen live — and a consumer replaces
1422
+ // its market set from the array, so a delisted row there would
1423
+ // resurrect a market that is gone. The per-market EVENTS are not
1424
+ // filtered: a delisting is a legitimate event, it is what tells a
1425
+ // legacy subscriber the market went inactive, and dropping it would
1426
+ // change a caller that never opted into any of this.
1427
+ if (onSnapshot) {
1428
+ try {
1429
+ onSnapshot(replay.filter((event) => event.data.isActive).map((event) => event.data));
1430
+ }
1431
+ catch (err) {
1432
+ console.error("WebSocket: Error processing instrument baseline replay", err);
1433
+ }
1434
+ return;
1435
+ }
1436
+ // Contained per event, not per replay: a handler that throws on one
1437
+ // market must not cost the subscriber every market after it.
1200
1438
  for (const event of replay) {
1201
1439
  try {
1202
1440
  handler(event);
@@ -1223,7 +1461,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1223
1461
  * frame carries the market's full current stats as a terminal-state snapshot.
1224
1462
  * Use {@link subscribeMarketStatsAll} for the all-markets summary instead.
1225
1463
  */
1226
- const subscribeMarketStats = (tradingPairId, handler) => {
1464
+ const subscribeMarketStats = (tradingPairId, handler, onSnapshot) => {
1227
1465
  if (!tradingPairId) {
1228
1466
  throw new Error("marketStats subscription requires a tradingPairId");
1229
1467
  }
@@ -1237,13 +1475,13 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1237
1475
  catch (err) {
1238
1476
  console.error("WebSocket: Error processing market stats event", err);
1239
1477
  }
1240
- });
1478
+ }, onSnapshot && snapshotFrameHandler("market stats", parseMarketStatsData, onSnapshot));
1241
1479
  };
1242
1480
  /**
1243
1481
  * Subscribe to the all-markets statistics summary (public): one summary row
1244
1482
  * per market in a single frame per push.
1245
1483
  */
1246
- const subscribeMarketStatsAll = (handler) => {
1484
+ const subscribeMarketStatsAll = (handler, onSnapshot) => {
1247
1485
  if (!handler) {
1248
1486
  throw new Error("marketStatsAll subscription requires a handler");
1249
1487
  }
@@ -1261,7 +1499,12 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1261
1499
  catch (err) {
1262
1500
  console.error("WebSocket: Error processing market stats all event", err);
1263
1501
  }
1264
- });
1502
+ },
1503
+ // The per-market channel needs no such guard: snapshot frames are routed
1504
+ // by exact channel, so `market_stats:<pair>` never reaches the bare
1505
+ // `market_stats` subscription's snapshot handler the way its live frames
1506
+ // reach the event handler.
1507
+ onSnapshot && snapshotFrameHandler("all-markets stats", parseMarketStatsSummary, onSnapshot));
1265
1508
  };
1266
1509
  return {
1267
1510
  connect,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmonaco/core",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
4
4
  "type": "module",
5
5
  "homepage": "https://docs.0xmonaco.com/sdk/typescript",
6
6
  "main": "./dist/index.js",
@@ -19,8 +19,8 @@
19
19
  "viem": "^2.45.2"
20
20
  },
21
21
  "dependencies": {
22
- "@0xmonaco/contracts": "1.0.51",
23
- "@0xmonaco/types": "1.0.51",
22
+ "@0xmonaco/contracts": "1.0.53",
23
+ "@0xmonaco/types": "1.0.53",
24
24
  "@noble/curves": "^1.9.1",
25
25
  "@noble/hashes": "^1.8.0",
26
26
  "http-status-codes": "^2.3.0"