@0xmonaco/core 1.1.5 → 1.1.7

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.
@@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js";
2
2
  import { composeWsSigningString, signMessage } from "../../crypto/session";
3
3
  import { ALL_MAGNITUDES } from "../../utils";
4
4
  import { ServerClock } from "../../utils/server-clock";
5
+ import { LocalBbo, LocalDepthBook, parseBboEvent, parseBboSnapshot, parseDepthDelta, parseDepthSnapshot, quoteSequenceNumbers, tryReadSequence, } from "./local-book";
5
6
  import { keysToCamelCase } from "./utils";
6
7
  // Connection constants
7
8
  const CONNECTION_TIMEOUT = 10000;
@@ -21,6 +22,8 @@ const SKEW_REJECTION = /timestamp outside allowed skew/i;
21
22
  // Unsubscribe for listing too many channels, after parsing it (ws-api
22
23
  // `oversized_channel_list_message`) or before (`too_many_elements_message`).
23
24
  // No ack follows either.
25
+ /** `Resynced` frames arriving this close together form one reported burst. */
26
+ const RESYNCED_BURST_MS = 50;
24
27
  const OVERSIZED_REQUEST = /^(?:(?:Subscribe|Unsubscribe) lists \d+ channels; at most \d+ per request|Message lists too many elements; at most \d+ channels per request)$/;
25
28
  function isRecord(value) {
26
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -431,22 +434,53 @@ function parseRfqSideViewEventData(raw) {
431
434
  if (!isRecord(raw)) {
432
435
  throw new Error("Invalid RFQ view: a side must be an object");
433
436
  }
437
+ const grossVwap = readOptionalDecimalString(raw, "grossVwap");
438
+ const estimatedMonacoFee = readOptionalDecimalString(raw, "estimatedMonacoFee");
439
+ const estimatedBuilderFee = readOptionalDecimalString(raw, "estimatedBuilderFee");
440
+ const estimatedTotalFee = readOptionalDecimalString(raw, "estimatedTotalFee");
434
441
  return {
435
442
  vwap: readString(raw, "vwap"),
436
443
  availableQuantity: readString(raw, "availableQuantity"),
444
+ ...(grossVwap !== undefined && { grossVwap }),
445
+ ...(estimatedMonacoFee !== undefined && { estimatedMonacoFee }),
446
+ ...(estimatedBuilderFee !== undefined && { estimatedBuilderFee }),
447
+ ...(estimatedTotalFee !== undefined && { estimatedTotalFee }),
448
+ ...(raw.book != null && { book: parseRfqBookComparisonEventData(raw.book) }),
437
449
  };
438
450
  }
451
+ function parseRfqBookComparisonEventData(raw) {
452
+ if (!isRecord(raw)) {
453
+ throw new Error("Invalid RFQ view: a book comparison must be an object");
454
+ }
455
+ return {
456
+ vwap: readString(raw, "vwap"),
457
+ grossVwap: readString(raw, "grossVwap"),
458
+ asOfMs: readSequence(raw, "asOfMs"),
459
+ levels: readSequence(raw, "levels"),
460
+ };
461
+ }
462
+ /** An optional wire integer that may be negative; absent or `null` is `undefined`. */
463
+ function readOptionalInteger(source, field) {
464
+ const value = source[field];
465
+ if (value === undefined || value === null)
466
+ return undefined;
467
+ return readInteger(source, field);
468
+ }
439
469
  /** Parse the taker's view. A side no maker quotes is absent. */
440
470
  function parseRfqViewEventData(raw) {
441
471
  if (!isRecord(raw)) {
442
472
  throw new Error("Invalid RFQ view: view must be an object");
443
473
  }
474
+ const monacoFeeCbps = readOptionalInteger(raw, "monacoFeeCbps");
475
+ const builderFeeCbps = readOptionalInteger(raw, "builderFeeCbps");
444
476
  return {
445
477
  viewVersion: readSequence(raw, "viewVersion"),
446
478
  ...(raw.bid != null && { bid: parseRfqSideViewEventData(raw.bid) }),
447
479
  ...(raw.ask != null && { ask: parseRfqSideViewEventData(raw.ask) }),
448
480
  asOfMs: readSequence(raw, "asOfMs"),
449
481
  sequence: readSequence(raw, "sequence"),
482
+ ...(monacoFeeCbps !== undefined && { monacoFeeCbps }),
483
+ ...(builderFeeCbps !== undefined && { builderFeeCbps }),
450
484
  };
451
485
  }
452
486
  /** Parse a selected maker's own part of an acceptance. */
@@ -454,6 +488,8 @@ function parseRfqSelectionEventData(raw) {
454
488
  if (!isRecord(raw)) {
455
489
  throw new Error("Invalid RFQ selection: selection must be an object");
456
490
  }
491
+ const maxBuilderFeeCbps = readOptionalInteger(raw, "maxBuilderFeeCbps");
492
+ const estimatedNet = readOptionalDecimalString(raw, "estimatedNet");
457
493
  return {
458
494
  acceptanceId: readString(raw, "acceptanceId"),
459
495
  takerSide: readString(raw, "takerSide"),
@@ -465,20 +501,26 @@ function parseRfqSelectionEventData(raw) {
465
501
  capacity: readString(raw, "capacity"),
466
502
  makerFeeCbps: readInteger(raw, "makerFeeCbps"),
467
503
  lastLookDeadlineAtMs: readSequence(raw, "lastLookDeadlineAtMs"),
504
+ ...(maxBuilderFeeCbps !== undefined && { maxBuilderFeeCbps }),
505
+ ...(estimatedNet !== undefined && { estimatedNet }),
468
506
  };
469
507
  }
470
508
  function parseRfqFillEventData(raw) {
471
509
  if (!isRecord(raw)) {
472
510
  throw new Error("Invalid RFQ outcome: a fill must be an object");
473
511
  }
512
+ const monacoFee = readOptionalDecimalString(raw, "monacoFee");
513
+ const builderFee = readOptionalDecimalString(raw, "builderFee");
474
514
  return {
475
515
  executionId: readString(raw, "executionId"),
476
516
  price: readString(raw, "price"),
477
517
  quantity: readString(raw, "quantity"),
478
518
  fee: readString(raw, "fee"),
519
+ ...(monacoFee !== undefined && { monacoFee }),
520
+ ...(builderFee !== undefined && { builderFee }),
479
521
  };
480
522
  }
481
- /** Parse the taker's outcome. Nothing filled leaves `vwap` absent and `fills` empty. */
523
+ /** Parse the taker's outcome. Nothing filled leaves `vwap` and `allInVwap` absent and `fills` empty. */
482
524
  function parseRfqOutcomeEventData(raw) {
483
525
  if (!isRecord(raw)) {
484
526
  throw new Error("Invalid RFQ outcome: outcome must be an object");
@@ -487,11 +529,13 @@ function parseRfqOutcomeEventData(raw) {
487
529
  throw new Error("Invalid RFQ outcome: fills must be an array");
488
530
  }
489
531
  const vwap = readOptionalDecimalString(raw, "vwap");
532
+ const allInVwap = readOptionalDecimalString(raw, "allInVwap");
490
533
  return {
491
534
  executedQuantity: readString(raw, "executedQuantity"),
492
535
  unfilledQuantity: readString(raw, "unfilledQuantity"),
493
536
  ...(vwap !== undefined && { vwap }),
494
537
  fills: raw.fills.map(parseRfqFillEventData),
538
+ ...(allInVwap !== undefined && { allInVwap }),
495
539
  };
496
540
  }
497
541
  /** Parse an RFQ's end. Only the taker's push carries `outcome`. */
@@ -594,6 +638,21 @@ function parseAccountEvent(rawData) {
594
638
  data: parseAccountEventData(rawData.data),
595
639
  };
596
640
  }
641
+ /** The frame's 24h volume split by source, or undefined when the frame omits it. */
642
+ function parseDailyVolumeBySource(raw) {
643
+ if (raw === undefined || raw === null) {
644
+ return undefined;
645
+ }
646
+ if (!isRecord(raw)) {
647
+ throw new Error("Invalid market stats event: daily_volume_by_source must be an object");
648
+ }
649
+ return {
650
+ orderBookBaseTokenVolume: readString(raw, "orderBookBaseTokenVolume"),
651
+ rfqBaseTokenVolume: readString(raw, "rfqBaseTokenVolume"),
652
+ orderBookQuoteTokenVolume: readOptionalString(raw, "orderBookQuoteTokenVolume"),
653
+ rfqQuoteTokenVolume: readOptionalString(raw, "rfqQuoteTokenVolume"),
654
+ };
655
+ }
597
656
  function parseMarketStatsData(raw) {
598
657
  if (!isRecord(raw)) {
599
658
  throw new Error("Invalid market stats event: data must be an object");
@@ -617,6 +676,7 @@ function parseMarketStatsData(raw) {
617
676
  dailyPriceHigh: readOptionalString(data, "dailyPriceHigh"),
618
677
  dailyPriceChange: readOptionalString(data, "dailyPriceChange"),
619
678
  dailyPriceChangePercent: readOptionalString(data, "dailyPriceChangePercent"),
679
+ dailyVolumeBySource: parseDailyVolumeBySource(data.dailyVolumeBySource),
620
680
  currentFundingRate: readOptionalString(data, "currentFundingRate"),
621
681
  fundingRate: readOptionalString(data, "fundingRate"),
622
682
  fundingTimestamp: readOptionalNumber(data, "fundingTimestamp"),
@@ -777,8 +837,18 @@ function parseOrderbookEvent(rawData) {
777
837
  quoteDecimals: data.quote_decimals || 0,
778
838
  timestamp: data.timestamp || new Date().toISOString(),
779
839
  sequence: data.sequence_number || 0,
840
+ // Sent only when the server renders `orderbook` from market-data's book
841
+ // (0XM-3552), as JSON numbers; surfaced as decimal strings like REST's
842
+ // `bookSeq` (a u64 may pass `Number.MAX_SAFE_INTEGER`).
843
+ ...sequenceField("bookSeq", data, "book_seq"),
844
+ ...sequenceField("prevBookSeq", data, "prev_book_seq"),
780
845
  };
781
846
  }
847
+ /** `{ [name]: sequence }` when `data[key]` is an exact u64 sequence, `{}` otherwise, so an absent or malformed key never surfaces. */
848
+ function sequenceField(name, data, key) {
849
+ const sequence = tryReadSequence(data, key);
850
+ return sequence === undefined ? {} : { [name]: sequence };
851
+ }
782
852
  /**
783
853
  * Parse the one row of an `orderbook` snapshot: the channel's current book in
784
854
  * the live frame's shape, read the same way.
@@ -786,7 +856,9 @@ function parseOrderbookEvent(rawData) {
786
856
  * The live parser casts; here a row that is not an object, or carries no `data`
787
857
  * object, throws, so {@link snapshotFrameHandler} withholds the frame rather
788
858
  * than handing over an empty book. The engine's book read carries no sequence,
789
- * so the row's `sequence` is 0.
859
+ * so under the engine source the row's `sequence` is 0; when the server renders
860
+ * the channel from market-data's book (0XM-3552) the row carries `book_seq` as
861
+ * `sequence_number` and surfaces `bookSeq` / `prevBookSeq` like a live frame.
790
862
  */
791
863
  function parseOrderbookSnapshotRow(raw) {
792
864
  if (!isRecord(raw) || !isRecord(raw.data)) {
@@ -1146,35 +1218,23 @@ function parseOrderSnapshotItem(raw) {
1146
1218
  ...(placementMakerFeeBps === undefined ? {} : { placementMakerFeeBps }),
1147
1219
  };
1148
1220
  }
1149
- /**
1150
- * Wrap a caller's `onSnapshot` in the internal snapshot-frame handler.
1151
- *
1152
- * The frame's `data` is the channel's COMPLETE current state as a flat array,
1153
- * and that completeness is the whole contract: a consumer is expected to
1154
- * replace state from it, so absence from the array means the entity is gone.
1155
- *
1156
- * Delivery is therefore all-or-nothing. If the payload is not an array, or any
1157
- * single row fails to parse, NOTHING is delivered.
1158
- *
1159
- * Skipping just the unreadable row and delivering the rest would be worse than
1160
- * delivering nothing, because the array would still be presented as complete: a
1161
- * consumer would drop the entity that row described — zero its balance, remove
1162
- * its market, cancel its order in the UI — on the strength of an omission the
1163
- * SDK itself created. Nor is that self-correcting. A resting order or an
1164
- * unchanged market has no later diff to repair it, so the deletion would stand
1165
- * for the life of the connection.
1166
- *
1167
- * Withholding the frame instead leaves the consumer on whatever baseline it
1168
- * already had, and the channel keeps streaming live events over it. That is a
1169
- * strictly smaller loss, and it is the same all-or-nothing rule this channel's
1170
- * original `instrument` snapshot already applied.
1171
- *
1172
- * `[]` is a legitimate baseline, not a failure, and is always delivered:
1173
- * "nothing is open" is exactly the information a consumer needs to clear stale
1174
- * state after a reconnect.
1175
- */
1221
+ /** A `trades:<pair>` snapshot cursor; `null` is a meaningful value (no trade applied since seeding). */
1222
+ function parseTradeSnapshotCursor(raw) {
1223
+ if (raw === null)
1224
+ return null;
1225
+ if (!isRecord(raw))
1226
+ throw new Error("Invalid trades snapshot cursor: must be an object or null");
1227
+ const seq = raw.seq;
1228
+ const matchIndex = raw.match_index;
1229
+ if (!Number.isSafeInteger(seq) || seq < 0 || !Number.isSafeInteger(matchIndex) || matchIndex < 0) {
1230
+ throw new Error("Invalid trades snapshot cursor: seq and match_index must be non-negative integers");
1231
+ }
1232
+ return { seq: seq, matchIndex: matchIndex };
1233
+ }
1176
1234
  function snapshotFrameHandler(channel, parseItem, onSnapshot) {
1177
- return (rawData) => {
1235
+ // `frame` is the router's second argument, unused here; `meta` is passed
1236
+ // only by a channel that reads something beside the rows (trades' cursor).
1237
+ return (rawData, _frame, meta) => {
1178
1238
  try {
1179
1239
  if (!Array.isArray(rawData)) {
1180
1240
  throw new Error(`Invalid ${channel} snapshot: data must be an array`);
@@ -1182,13 +1242,86 @@ function snapshotFrameHandler(channel, parseItem, onSnapshot) {
1182
1242
  // Parsed fully before anything is handed over, so a failure part-way
1183
1243
  // through cannot deliver a truncated baseline.
1184
1244
  const items = rawData.map(parseItem);
1185
- onSnapshot(items);
1245
+ // Only a snapshot that carries meta passes it, so every other channel
1246
+ // sees exactly the call it always did.
1247
+ if (meta)
1248
+ onSnapshot(items, meta);
1249
+ else
1250
+ onSnapshot(items);
1186
1251
  }
1187
1252
  catch (err) {
1188
1253
  console.error(`WebSocket: Error processing ${channel} snapshot`, err);
1189
1254
  }
1190
1255
  };
1191
1256
  }
1257
+ function tradeKeyAtOrBelow(key, bound) {
1258
+ return key.seq < bound.seq || (key.seq === bound.seq && key.matchIndex <= bound.matchIndex);
1259
+ }
1260
+ /** An optional chain field: a non-negative integer, else absent (a malformed value never drops the trade). */
1261
+ function readOptionalChainNumber(data, key) {
1262
+ const value = data[key];
1263
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
1264
+ }
1265
+ /** The trade frame's chain fields (market-data source only), set only when present and well formed. */
1266
+ function readTradeChain(data) {
1267
+ const chain = {};
1268
+ const seq = readOptionalChainNumber(data, "seq");
1269
+ const matchIndex = readOptionalChainNumber(data, "match_index");
1270
+ const prevSeq = readOptionalChainNumber(data, "prev_seq");
1271
+ if (seq !== undefined)
1272
+ chain.seq = seq;
1273
+ if (matchIndex !== undefined)
1274
+ chain.matchIndex = matchIndex;
1275
+ if (prevSeq !== undefined)
1276
+ chain.prevSeq = prevSeq;
1277
+ if (data.prev_match_index === null)
1278
+ chain.prevMatchIndex = null;
1279
+ else {
1280
+ const prevMatchIndex = readOptionalChainNumber(data, "prev_match_index");
1281
+ if (prevMatchIndex !== undefined)
1282
+ chain.prevMatchIndex = prevMatchIndex;
1283
+ }
1284
+ return chain;
1285
+ }
1286
+ /** Backoff for repeated client-side rebuilds (a gap, an unreadable frame or snapshot, a thinned side). */
1287
+ const SNAPSHOT_RETRY_BASE_MS = 500;
1288
+ const SNAPSHOT_RETRY_MAX_MS = 15_000;
1289
+ /**
1290
+ * After SNAPSHOT_UNAVAILABLE the subscription stays routed: the server retries
1291
+ * a failed book (5 s doubling to 60 s) or a capped one once a slot frees, and
1292
+ * pushes the healing snapshot with no client action. A resubscribe lands on
1293
+ * the same per-pod book, so it is never required; this timer is only a
1294
+ * harmless safety net. The cases that never heal (an unknown pair id, a
1295
+ * durable-log reset until ws-api restarts) are not fixed by it either.
1296
+ */
1297
+ const SNAPSHOT_FALLBACK_BASE_MS = 90_000;
1298
+ const SNAPSHOT_FALLBACK_MAX_MS = 300_000;
1299
+ /**
1300
+ * Whether a raw frame belongs to a channel carrying market-data sequences:
1301
+ * depth, bbo, or orderbook rendered from market-data's book (0XM-3552). A
1302
+ * cheap prefix test before the sequence quoting pass.
1303
+ */
1304
+ const MARKET_DATA_CHANNEL_KEY = /"channel"\s*:\s*"(?:depth|bbo|orderbook):/;
1305
+ function isMarketDataFrame(raw) {
1306
+ // The substring test first: almost every frame fails it without a regex.
1307
+ return raw.includes("book_seq") && MARKET_DATA_CHANNEL_KEY.test(raw);
1308
+ }
1309
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1310
+ /**
1311
+ * The canonical market-data channel: lowercase pair UUID, uppercase mode. The
1312
+ * server sends live frames on this form and echoes the subscribed form on
1313
+ * the snapshot, so subscribing canonically keeps both on one key.
1314
+ */
1315
+ function marketDataChannel(kind, tradingPairId, tradingMode) {
1316
+ if (!tradingPairId || !UUID_PATTERN.test(tradingPairId)) {
1317
+ throw new Error(`${kind} subscription requires a trading pair UUID, got "${tradingPairId}"`);
1318
+ }
1319
+ const mode = String(tradingMode ?? "").toUpperCase();
1320
+ if (mode !== "SPOT" && mode !== "MARGIN") {
1321
+ throw new Error(`${kind} subscription requires tradingMode "SPOT" or "MARGIN", got "${tradingMode}"`);
1322
+ }
1323
+ return `${kind}:${tradingPairId.toLowerCase()}:${mode}`;
1324
+ }
1192
1325
  /**
1193
1326
  * Create a Monaco WebSocket client
1194
1327
  *
@@ -1231,6 +1364,22 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1231
1364
  // Close code of the connection loss the next successful reopen recovers
1232
1365
  // from; null when no resync is owed (first connect, clean disconnect).
1233
1366
  let pendingResyncCode = null;
1367
+ // Scoped resync (0XM-3553): offer the capability once per socket, on its
1368
+ // first Authenticate or Subscribe; the server keeps it for the connection.
1369
+ // Only to a client with an onResync: without one a scoped resync would
1370
+ // leave the app silently stale, where the 1013 close at least resubscribes
1371
+ // and re-delivers subscribe snapshots.
1372
+ const offerResync = (options.scopedResync ?? true) && options.onResync !== undefined;
1373
+ let resyncOffered = false;
1374
+ let resyncedBursts = [];
1375
+ // A scoped refetch was still owed when a socket was lost: the next
1376
+ // connection's onResync reports it (as a 1013 gap if the close itself owed
1377
+ // none).
1378
+ let scopedResyncOwed = false;
1379
+ // Snapshot handlers that keep only internal state and hand the app
1380
+ // nothing (the trades chain cursor without an app `onSnapshot`): a re-sent
1381
+ // snapshot through one alone still reaches no app code.
1382
+ const internalSnapshotHandlers = new WeakSet();
1234
1383
  // Authentication-ending Error frame received on the current socket, tagged
1235
1384
  // with the session it rejected. The server answers handshakes in the order
1236
1385
  // they were sent, so each Authenticated / AUTH_FAILED reply belongs to the
@@ -1252,6 +1401,14 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1252
1401
  // reusing the Event handlers would feed every other channel's parser a frame
1253
1402
  // it must reject.
1254
1403
  const snapshotHandlers = new Map();
1404
+ // Per-channel listeners for Error frames that name a channel (the local
1405
+ // depth/bbo books retry a SNAPSHOT_UNAVAILABLE subscribe). They run before
1406
+ // the app's onError and never stop it.
1407
+ const channelErrorHandlers = new Map();
1408
+ // One shared local book per depth/bbo channel: the server sends a snapshot
1409
+ // only for a new wire subscription, so a second local subscriber must share
1410
+ // the first one's state rather than wait for a snapshot that never comes.
1411
+ const localStateChannels = new Map();
1255
1412
  // Latest known full-config frame per market for each subscribed instrument
1256
1413
  // channel, seeded by the subscribe-time Snapshot and superseded by every
1257
1414
  // live frame (instrument frames are terminal-state, never deltas). The
@@ -1357,6 +1514,7 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1357
1514
  public_key: session.publicKey,
1358
1515
  timestamp,
1359
1516
  signature,
1517
+ ...capabilitiesOnce(),
1360
1518
  }));
1361
1519
  };
1362
1520
  const stopHeartbeat = () => {
@@ -1444,7 +1602,67 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1444
1602
  if (channels.length === 0 || ws?.readyState !== WebSocket.OPEN)
1445
1603
  return;
1446
1604
  pendingRequests.push({ kind: "subscribe", channels });
1447
- send({ type: "Subscribe", channels });
1605
+ send({ type: "Subscribe", channels, ...capabilitiesOnce() });
1606
+ };
1607
+ /** The capability offer, on the first request of each socket only. */
1608
+ const capabilitiesOnce = () => {
1609
+ if (!offerResync || resyncOffered)
1610
+ return {};
1611
+ resyncOffered = true;
1612
+ return { capabilities: ["resync"] };
1613
+ };
1614
+ /** Report `channel` in a scoped onResync, at least `delayMs` from now. */
1615
+ const scheduleResynced = (channel, delayMs) => {
1616
+ const now = performance.now();
1617
+ let burst = resyncedBursts.at(-1);
1618
+ if (!burst || now - burst.opened > RESYNCED_BURST_MS) {
1619
+ // Held open for the whole window, so frames still arriving join it.
1620
+ burst = { opened: now, due: now + RESYNCED_BURST_MS, channels: new Set(), timer: null };
1621
+ resyncedBursts.push(burst);
1622
+ }
1623
+ burst.channels.add(channel);
1624
+ burst.due = Math.max(burst.due, now + delayMs);
1625
+ armResynced(burst);
1626
+ };
1627
+ /** (Re)arm `burst`'s timer for its deadline, rounding up. */
1628
+ const armResynced = (burst) => {
1629
+ if (burst.timer)
1630
+ clearTimeout(burst.timer);
1631
+ burst.timer = setTimeout(() => flushResynced(burst), Math.max(0, Math.ceil(burst.due - performance.now())));
1632
+ };
1633
+ const flushResynced = (burst) => {
1634
+ // A timer can fire before the monotonic deadline: wait out the rest.
1635
+ if (performance.now() < burst.due) {
1636
+ armResynced(burst);
1637
+ return;
1638
+ }
1639
+ resyncedBursts = resyncedBursts.filter((other) => other !== burst);
1640
+ const channels = [...burst.channels].filter((channel) => handlers.has(channel));
1641
+ if (channels.length === 0)
1642
+ return;
1643
+ // Called from a timer: a throw or a rejection must not escape it.
1644
+ const logResyncError = (err) => console.error("WebSocket: onResync threw", err);
1645
+ try {
1646
+ const result = options.onResync?.({ code: 1013, slowClient: true, reason: "resynced", channels });
1647
+ if (result instanceof Promise)
1648
+ result.catch(logResyncError);
1649
+ }
1650
+ catch (err) {
1651
+ logResyncError(err);
1652
+ }
1653
+ };
1654
+ /**
1655
+ * Drop every pending scoped resync; whether one was owed. A reconnect's
1656
+ * onResync covers it.
1657
+ */
1658
+ const cancelResynced = () => {
1659
+ // Owed only for a channel the app still holds.
1660
+ const owed = resyncedBursts.some((burst) => [...burst.channels].some((channel) => handlers.has(channel)));
1661
+ for (const burst of resyncedBursts)
1662
+ if (burst.timer)
1663
+ clearTimeout(burst.timer);
1664
+ resyncedBursts = [];
1665
+ return owed;
1448
1666
  };
1449
1667
  const sendUnsubscribe = (channels) => {
1450
1668
  if (channels.length === 0 || ws?.readyState !== WebSocket.OPEN)
@@ -1595,6 +1813,8 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1595
1813
  // oldest outstanding request: retire it, or every later ack would
1596
1814
  // pair with the wrong request.
1597
1815
  if (msg.code === "INVALID_MESSAGE" && OVERSIZED_REQUEST.test(msg.message)) {
1816
+ // Refused unread: any capability it carried was never recorded.
1817
+ resyncOffered = false;
1598
1818
  pendingRequests.shift();
1599
1819
  }
1600
1820
  if (msg.code === "AUTH_FAILED") {
@@ -1613,6 +1833,16 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1613
1833
  // actionable without it. (The frame is untyped JSON, so the declared
1614
1834
  // `string` type of `channel` is not trusted here.)
1615
1835
  const channel = msg.channel;
1836
+ if (typeof channel === "string") {
1837
+ for (const handler of channelErrorHandlers.get(channel) ?? []) {
1838
+ try {
1839
+ handler(msg.code, msg.message);
1840
+ }
1841
+ catch (err) {
1842
+ console.error("WebSocket: Error in channel error handler", err);
1843
+ }
1844
+ }
1845
+ }
1616
1846
  options.onError?.(typeof channel === "string" ? { code: msg.code, message: msg.message, channel } : { code: msg.code, message: msg.message });
1617
1847
  }
1618
1848
  return;
@@ -1658,13 +1888,36 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1658
1888
  }
1659
1889
  return;
1660
1890
  }
1891
+ if (msg.type === "Resynced") {
1892
+ // A channel the server could not repair in place (0XM-3553). A frame
1893
+ // missing either field is malformed and ignored.
1894
+ const retryAfterMs = msg.retry_after_ms;
1895
+ if (typeof msg.channel === "string" && typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
1896
+ scheduleResynced(msg.channel, retryAfterMs);
1897
+ }
1898
+ return;
1899
+ }
1661
1900
  if (!msg.channel)
1662
1901
  return;
1663
1902
  if (msg.type === "Snapshot") {
1664
1903
  // Exact match only: the Snapshot frame's channel echoes the raw
1665
1904
  // subscribed channel, never a more specific one.
1666
- for (const handler of snapshotHandlers.get(msg.channel) ?? []) {
1667
- handler(msg.data);
1905
+ const channelSnapshotHandlers = snapshotHandlers.get(msg.channel);
1906
+ for (const handler of channelSnapshotHandlers ?? []) {
1907
+ handler(msg.data, msg);
1908
+ }
1909
+ // A server-side repair after an upstream loss (0XM-3553) is marked; a
1910
+ // channel consumed without an app snapshot handler would never see it.
1911
+ if (offerResync && msg.resynced === true && handlers.has(msg.channel)) {
1912
+ // Every subscriber needs an app snapshot handler for the repair to
1913
+ // reach it; an event-only one is told to refetch instead.
1914
+ let appSnapshotHandlers = 0;
1915
+ for (const handler of channelSnapshotHandlers ?? []) {
1916
+ if (!internalSnapshotHandlers.has(handler))
1917
+ appSnapshotHandlers++;
1918
+ }
1919
+ if (appSnapshotHandlers < (handlers.get(msg.channel)?.size ?? 0))
1920
+ scheduleResynced(msg.channel, 0);
1668
1921
  }
1669
1922
  return;
1670
1923
  }
@@ -1687,6 +1940,12 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1687
1940
  const handleSocketLoss = (code) => {
1688
1941
  ws = null;
1689
1942
  stopHeartbeat();
1943
+ // A scoped refetch still owed is owed by whichever connection comes next,
1944
+ // even one opened later by connect() rather than by the reconnect loop.
1945
+ if (cancelResynced())
1946
+ scopedResyncOwed = true;
1947
+ for (const entry of [...localStateChannels.values()])
1948
+ entry.onSocketLoss();
1690
1949
  // The server dropped every subscription with the connection.
1691
1950
  resetSubscriptionAcks();
1692
1951
  // Terminal authentication loss: the server revoked the session
@@ -1766,6 +2025,7 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1766
2025
  try {
1767
2026
  const socket = new WebSocket(getUrl());
1768
2027
  ws = socket;
2028
+ resyncOffered = false;
1769
2029
  lastAuthError = null;
1770
2030
  pendingAuthSessions = [];
1771
2031
  confirmedSession = undefined;
@@ -1807,9 +2067,10 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1807
2067
  //
1808
2068
  // This callback waits for neither acknowledgements nor snapshots,
1809
2069
  // and the SDK performs no REST recovery itself.
1810
- if (pendingResyncCode !== null) {
1811
- const code = pendingResyncCode;
2070
+ if (pendingResyncCode !== null || scopedResyncOwed) {
2071
+ const code = pendingResyncCode ?? 1013;
1812
2072
  pendingResyncCode = null;
2073
+ scopedResyncOwed = false;
1813
2074
  options.onResync?.({ code, slowClient: code === 1013 });
1814
2075
  }
1815
2076
  resolve();
@@ -1819,7 +2080,10 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1819
2080
  return;
1820
2081
  lastMessageAt = Date.now();
1821
2082
  try {
1822
- const msg = JSON.parse(event.data);
2083
+ // Market-data sequences are u64: quote them before parsing so
2084
+ // one above Number.MAX_SAFE_INTEGER stays exact.
2085
+ const raw = event.data;
2086
+ const msg = JSON.parse(typeof raw === "string" && isMarketDataFrame(raw) ? quoteSequenceNumbers(raw) : raw);
1823
2087
  // Handle pong silently
1824
2088
  if (msg.type === "Pong")
1825
2089
  return;
@@ -1869,6 +2133,14 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1869
2133
  stopReconnect();
1870
2134
  stopHeartbeat();
1871
2135
  pendingResyncCode = null;
2136
+ cancelResynced();
2137
+ scopedResyncOwed = false;
2138
+ // Local depth/bbo books go with their wire handlers: stop each (its retry
2139
+ // timer too) so a later depth()/bbo() call starts a fresh subscription.
2140
+ for (const entry of [...localStateChannels.values()])
2141
+ entry.stop();
2142
+ localStateChannels.clear();
2143
+ channelErrorHandlers.clear();
1872
2144
  handlers.clear();
1873
2145
  snapshotHandlers.clear();
1874
2146
  instrumentBaseline.clear();
@@ -1953,6 +2225,266 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
1953
2225
  }
1954
2226
  }, onSnapshot && snapshotFrameHandler("orderbook", parseOrderbookSnapshotRow, onSnapshot));
1955
2227
  };
2228
+ /**
2229
+ * Keep one shared, sequenced local state for a depth/bbo channel: the
2230
+ * subscribe-time snapshot is the base, linked frames update it, and a gap,
2231
+ * an unreadable frame or snapshot, an exhausted incomplete side or a
2232
+ * SNAPSHOT_UNAVAILABLE rebuilds it from a fresh wire subscription.
2233
+ */
2234
+ const openLocalState = (channel, createState, onValue, localOptions) => {
2235
+ let entry = localStateChannels.get(channel);
2236
+ if (!entry) {
2237
+ const state = createState();
2238
+ const listeners = new Set();
2239
+ let unsubscribeWire = null;
2240
+ let stopped = false;
2241
+ // One pending resubscribe at a time (a rebuild, or the fallback after a
2242
+ // SNAPSHOT_UNAVAILABLE); scheduling another supersedes it, and a stale
2243
+ // timer or microtask finds its token outdated and does nothing.
2244
+ let scheduleToken = 0;
2245
+ let scheduledTimer = null;
2246
+ // Set while a rebuild is pending: frames still being routed for the old
2247
+ // subscription (the router may visit the re-added channel in the same
2248
+ // pass) are ignored rather than buffered against the next snapshot.
2249
+ let rebuildPending = false;
2250
+ // Rebuilds since the last frame that applied cleanly on a fresh
2251
+ // snapshot: a fault that recurs on every snapshot backs off instead of
2252
+ // resubscribing per frame.
2253
+ let consecutiveRebuilds = 0;
2254
+ let fallbackDelay = SNAPSHOT_FALLBACK_BASE_MS;
2255
+ // The channel's last rejection, replayed to a listener that joins later
2256
+ // (no new Subscribe goes out for it); a snapshot clears it.
2257
+ let lastRejection = null;
2258
+ const forEachListener = (what, call) => {
2259
+ for (const listener of [...listeners]) {
2260
+ try {
2261
+ call(listener);
2262
+ }
2263
+ catch (err) {
2264
+ console.error(`WebSocket: Error in ${channel} ${what}`, err);
2265
+ }
2266
+ }
2267
+ };
2268
+ // The view is built once; each listener gets its own copy of it, so one
2269
+ // that mutates the book cannot change what the others see.
2270
+ const emit = () => {
2271
+ const value = state.view();
2272
+ if (value === null)
2273
+ return;
2274
+ forEachListener("listener", (listener) => listener.onValue(state.copy(value)));
2275
+ };
2276
+ const notifyResync = (reason) => forEachListener("onResync", (listener) => listener.options?.onResync?.(reason));
2277
+ const open = () => subscribe(channel, onFrame, onSnapshot);
2278
+ const cancelScheduled = () => {
2279
+ scheduleToken += 1;
2280
+ if (scheduledTimer)
2281
+ clearTimeout(scheduledTimer);
2282
+ scheduledTimer = null;
2283
+ rebuildPending = false;
2284
+ };
2285
+ const schedule = (delay, run) => {
2286
+ cancelScheduled();
2287
+ const token = scheduleToken;
2288
+ const fire = () => {
2289
+ if (token !== scheduleToken || stopped)
2290
+ return;
2291
+ scheduledTimer = null;
2292
+ rebuildPending = false;
2293
+ run();
2294
+ };
2295
+ if (delay === 0)
2296
+ queueMicrotask(fire);
2297
+ else
2298
+ scheduledTimer = setTimeout(fire, delay);
2299
+ };
2300
+ const resubscribe = () => {
2301
+ unsubscribeWire?.();
2302
+ unsubscribeWire = open();
2303
+ };
2304
+ /** Discard the state now; resubscribe once the current routing pass is done, backing off on repeats. */
2305
+ const rebuild = (reason) => {
2306
+ state.reset();
2307
+ notifyResync(reason);
2308
+ if (rebuildPending)
2309
+ return;
2310
+ const delay = consecutiveRebuilds === 0 ? 0 : Math.min(SNAPSHOT_RETRY_BASE_MS * 2 ** (consecutiveRebuilds - 1), SNAPSHOT_RETRY_MAX_MS);
2311
+ consecutiveRebuilds += 1;
2312
+ schedule(delay, resubscribe);
2313
+ rebuildPending = true;
2314
+ if (delay > 0) {
2315
+ // Waiting out the backoff unsubscribed, so no frame or snapshot
2316
+ // arrives for the abandoned subscription meanwhile.
2317
+ unsubscribeWire?.();
2318
+ unsubscribeWire = null;
2319
+ }
2320
+ };
2321
+ /**
2322
+ * The server could not serve a snapshot. The subscription stays routed
2323
+ * and ws-api pushes the healing snapshot with no client action, so stay
2324
+ * subscribed (frames are withheld until then). The resubscribe after the
2325
+ * long fallback is a safety net only: it lands on the same book.
2326
+ */
2327
+ const awaitServerSnapshot = (reason) => {
2328
+ state.reset();
2329
+ notifyResync(reason);
2330
+ schedule(fallbackDelay, resubscribe);
2331
+ fallbackDelay = Math.min(fallbackDelay * 2, SNAPSHOT_FALLBACK_MAX_MS);
2332
+ if (unsubscribeWire === null)
2333
+ unsubscribeWire = open();
2334
+ };
2335
+ function onFrame(raw) {
2336
+ if (rebuildPending || stopped)
2337
+ return;
2338
+ let outcome;
2339
+ try {
2340
+ outcome = state.offerRaw(raw);
2341
+ }
2342
+ catch (err) {
2343
+ // An unreadable frame is a lost frame.
2344
+ console.error(`WebSocket: Error processing ${channel} frame`, err);
2345
+ rebuild("gap");
2346
+ return;
2347
+ }
2348
+ if (outcome === "applied") {
2349
+ consecutiveRebuilds = 0;
2350
+ emit();
2351
+ }
2352
+ else if (outcome === "gap")
2353
+ rebuild("gap");
2354
+ else if (outcome === "truncated")
2355
+ rebuild("truncated");
2356
+ }
2357
+ function onSnapshot(raw) {
2358
+ if (stopped)
2359
+ return;
2360
+ let outcome;
2361
+ try {
2362
+ outcome = state.applySnapshotRaw(raw);
2363
+ }
2364
+ catch (err) {
2365
+ // No further snapshot would come on this subscription unless the
2366
+ // server reloads the book: rebuild it (backing off on repeats).
2367
+ console.error(`WebSocket: Error processing ${channel} snapshot`, err);
2368
+ rebuild("snapshot_unavailable");
2369
+ return;
2370
+ }
2371
+ // A snapshot supersedes any pending fallback or rebuild, and any
2372
+ // earlier rejection.
2373
+ cancelScheduled();
2374
+ lastRejection = null;
2375
+ fallbackDelay = SNAPSHOT_FALLBACK_BASE_MS;
2376
+ if (outcome === "gap" || outcome === "truncated") {
2377
+ rebuild(outcome);
2378
+ return;
2379
+ }
2380
+ emit();
2381
+ }
2382
+ const onChannelError = (code, message) => {
2383
+ if (stopped)
2384
+ return;
2385
+ if (code === "SNAPSHOT_UNAVAILABLE") {
2386
+ awaitServerSnapshot("snapshot_unavailable");
2387
+ return;
2388
+ }
2389
+ lastRejection = { code, message };
2390
+ forEachListener("onError", (listener) => listener.options?.onError?.({ code, message }));
2391
+ };
2392
+ const onSocketLoss = () => {
2393
+ if (stopped)
2394
+ return;
2395
+ // The reconnect resubscribes and its snapshot rebuilds the book;
2396
+ // until then the old one is not live. Frames buffered from the dead
2397
+ // connection go too.
2398
+ const wasLive = state.ready;
2399
+ state.reset();
2400
+ if (wasLive)
2401
+ notifyResync("disconnected");
2402
+ };
2403
+ if (!channelErrorHandlers.has(channel))
2404
+ channelErrorHandlers.set(channel, new Set());
2405
+ channelErrorHandlers.get(channel)?.add(onChannelError);
2406
+ unsubscribeWire = open();
2407
+ const created = {
2408
+ listeners,
2409
+ current: () => {
2410
+ const value = state.view();
2411
+ return value === null ? null : state.copy(value);
2412
+ },
2413
+ rejection: () => lastRejection,
2414
+ onSocketLoss,
2415
+ stop: () => {
2416
+ if (stopped)
2417
+ return;
2418
+ stopped = true;
2419
+ cancelScheduled();
2420
+ unsubscribeWire?.();
2421
+ unsubscribeWire = null;
2422
+ channelErrorHandlers.get(channel)?.delete(onChannelError);
2423
+ if (channelErrorHandlers.get(channel)?.size === 0)
2424
+ channelErrorHandlers.delete(channel);
2425
+ if (localStateChannels.get(channel) === created)
2426
+ localStateChannels.delete(channel);
2427
+ },
2428
+ };
2429
+ localStateChannels.set(channel, created);
2430
+ entry = created;
2431
+ }
2432
+ const listener = { onValue: onValue, options: localOptions };
2433
+ const held = entry;
2434
+ held.listeners.add(listener);
2435
+ const current = held.current();
2436
+ const rejection = held.rejection();
2437
+ try {
2438
+ if (current !== null)
2439
+ listener.onValue(current);
2440
+ else if (rejection !== null)
2441
+ listener.options?.onError?.(rejection);
2442
+ }
2443
+ catch (err) {
2444
+ console.error(`WebSocket: Error in ${channel} listener`, err);
2445
+ }
2446
+ let unsubscribed = false;
2447
+ return () => {
2448
+ if (unsubscribed)
2449
+ return;
2450
+ unsubscribed = true;
2451
+ held.listeners.delete(listener);
2452
+ if (held.listeners.size === 0)
2453
+ held.stop();
2454
+ };
2455
+ };
2456
+ const subscribeDepth = (tradingPairId, tradingMode, onBook, localOptions) => {
2457
+ const channel = marketDataChannel("depth", tradingPairId, tradingMode);
2458
+ return openLocalState(channel, () => {
2459
+ const book = new LocalDepthBook();
2460
+ return {
2461
+ offerRaw: (raw) => book.offer(parseDepthDelta(raw)),
2462
+ applySnapshotRaw: (raw) => book.applySnapshot(parseDepthSnapshot(raw)),
2463
+ reset: () => book.reset(),
2464
+ view: () => book.view(),
2465
+ copy: (value) => ({ ...value, bids: value.bids.map((level) => ({ ...level })), asks: value.asks.map((level) => ({ ...level })) }),
2466
+ get ready() {
2467
+ return book.ready;
2468
+ },
2469
+ };
2470
+ }, onBook, localOptions);
2471
+ };
2472
+ const subscribeBbo = (tradingPairId, tradingMode, onBbo, localOptions) => {
2473
+ const channel = marketDataChannel("bbo", tradingPairId, tradingMode);
2474
+ return openLocalState(channel, () => {
2475
+ const bbo = new LocalBbo();
2476
+ return {
2477
+ offerRaw: (raw) => bbo.offer(parseBboEvent(raw)),
2478
+ applySnapshotRaw: (raw) => bbo.applySnapshot(parseBboSnapshot(raw)),
2479
+ reset: () => bbo.reset(),
2480
+ view: () => bbo.view(),
2481
+ copy: (value) => ({ ...value }),
2482
+ get ready() {
2483
+ return bbo.ready;
2484
+ },
2485
+ };
2486
+ }, onBbo, localOptions);
2487
+ };
1956
2488
  const subscribeOHLCV = (tradingPairId, tradingMode, interval, handler, priceType, onSnapshot) => {
1957
2489
  // Four-token channel selects the trade-derived candles (back-compat); the
1958
2490
  // optional fifth token selects the price series (`mark` = close-only mark
@@ -2003,8 +2535,43 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
2003
2535
  }
2004
2536
  }, onSnapshot && snapshotFrameHandler("ohlcv", (raw) => parseOHLCVSnapshotCandle(raw, interval), onSnapshot));
2005
2537
  };
2006
- const subscribeTrades = (tradingPairId, handler, onSnapshot) => {
2538
+ const subscribeTrades = (tradingPairId, handler, onSnapshot, tradesOptions) => {
2007
2539
  const channel = `trades:${tradingPairId}`;
2540
+ // The chain check runs once a snapshot carries a cursor key (the
2541
+ // market-data source); under the engine source every trade is delivered
2542
+ // as before. The server forwards only trades linking to this
2543
+ // connection's cursor and reseeds by itself on a break it sees, so the
2544
+ // check here is a safety net: it drops a trade at or below the cursor or
2545
+ // the last delivered one, and reports a trade that does not link (a
2546
+ // frame this client could not read, say) to onGap, then continues from
2547
+ // it. Withholding would freeze the feed, since the server believes the
2548
+ // connection is in sync and sends no fresh snapshot for it.
2549
+ let checking = false;
2550
+ let last = null;
2551
+ const deliverSnapshot = onSnapshot && snapshotFrameHandler("trades", parseTradeEventData, onSnapshot);
2552
+ const onSnapshotFrame = (rawData, frame) => {
2553
+ let meta;
2554
+ if (frame && "cursor" in frame) {
2555
+ try {
2556
+ const cursor = parseTradeSnapshotCursor(frame.cursor);
2557
+ // The internal bound is a copy: a consumer mutating meta.cursor
2558
+ // must not move it.
2559
+ last = cursor === null ? null : { ...cursor };
2560
+ checking = true;
2561
+ meta = { cursor };
2562
+ }
2563
+ catch (err) {
2564
+ // An unreadable cursor leaves nothing to check against: deliver as
2565
+ // the engine source does until the next snapshot (the rows still go).
2566
+ console.error("WebSocket: Error processing trades snapshot cursor", err);
2567
+ checking = false;
2568
+ last = null;
2569
+ }
2570
+ }
2571
+ deliverSnapshot?.(rawData, frame, meta);
2572
+ };
2573
+ if (!deliverSnapshot)
2574
+ internalSnapshotHandlers.add(onSnapshotFrame);
2008
2575
  return subscribe(channel, (rawData) => {
2009
2576
  try {
2010
2577
  const data = rawData;
@@ -2020,13 +2587,37 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
2020
2587
  makerSide: tradeData.maker_side.toUpperCase(),
2021
2588
  executedAt: tradeData.executed_at,
2022
2589
  },
2590
+ ...readTradeChain(data),
2023
2591
  };
2592
+ if (checking && event.seq !== undefined && event.matchIndex !== undefined) {
2593
+ const key = { seq: event.seq, matchIndex: event.matchIndex };
2594
+ if (last !== null) {
2595
+ // At or below the cursor or the last delivered trade: already reflected.
2596
+ if (tradeKeyAtOrBelow(key, last))
2597
+ return;
2598
+ if (event.prevSeq !== last.seq || event.prevMatchIndex !== last.matchIndex) {
2599
+ // A throwing onGap must not stop the chain advancing or the
2600
+ // trade being delivered.
2601
+ try {
2602
+ tradesOptions?.onGap?.({
2603
+ tradingPairId: event.tradingPairId,
2604
+ expected: { seq: last.seq, matchIndex: last.matchIndex },
2605
+ received: { prevSeq: event.prevSeq ?? null, prevMatchIndex: event.prevMatchIndex ?? null },
2606
+ });
2607
+ }
2608
+ catch (err) {
2609
+ console.error("WebSocket: Error in trades onGap", err);
2610
+ }
2611
+ }
2612
+ }
2613
+ last = key;
2614
+ }
2024
2615
  handler(event);
2025
2616
  }
2026
2617
  catch (err) {
2027
2618
  console.error("WebSocket: Error processing trade event", err);
2028
2619
  }
2029
- }, onSnapshot && snapshotFrameHandler("trades", parseTradeEventData, onSnapshot));
2620
+ }, onSnapshotFrame);
2030
2621
  };
2031
2622
  const subscribeMovements = (handler) => {
2032
2623
  // User is identified from the authenticated session, so channel is just "movements"
@@ -2522,6 +3113,8 @@ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new S
2522
3113
  },
2523
3114
  orders: subscribeOrders,
2524
3115
  orderbook: subscribeOrderbook,
3116
+ depth: subscribeDepth,
3117
+ bbo: subscribeBbo,
2525
3118
  ohlcv: subscribeOHLCV,
2526
3119
  trades: subscribeTrades,
2527
3120
  movements: subscribeMovements,