@taphubhq/sdk-core 0.23.3 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -59,7 +59,8 @@ __export(index_exports, {
59
59
  isTerminal: () => isTerminal,
60
60
  isWin: () => isWin,
61
61
  normalCDF: () => normalCDF,
62
- normalPDF: () => normalPDF
62
+ normalPDF: () => normalPDF,
63
+ pairIdFromBidResultTopic: () => pairIdFromBidResultTopic
63
64
  });
64
65
  module.exports = __toCommonJS(index_exports);
65
66
 
@@ -1043,6 +1044,19 @@ function walletBalanceTopic(userId) {
1043
1044
  function agencyPairStatsTopic(aid, pairId) {
1044
1045
  return `public/agency/${aid}/pair/${pairId}/stats`;
1045
1046
  }
1047
+ function userBidsWildcardTopic(userId) {
1048
+ return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
1049
+ }
1050
+ function topicMatchesWildcard(pattern, topic) {
1051
+ const pp = pattern.split("/");
1052
+ const tp = topic.split("/");
1053
+ if (pp.length !== tp.length) return false;
1054
+ for (let i = 0; i < pp.length; i++) {
1055
+ if (pp[i] === "+") continue;
1056
+ if (pp[i] !== tp[i]) return false;
1057
+ }
1058
+ return true;
1059
+ }
1046
1060
  function topicFor(gameId, suffix, userId) {
1047
1061
  if (USER_SCOPED_SUFFIXES.includes(suffix)) {
1048
1062
  return `${TOPIC_PREFIX}/${gameId}/user/${userId}/${suffix}`;
@@ -1061,6 +1075,7 @@ function createMqttTransport(endpoint, opts = {}) {
1061
1075
  const candleSubscriptions = /* @__PURE__ */ new Map();
1062
1076
  const statsSubscriptions = /* @__PURE__ */ new Map();
1063
1077
  const walletSubscriptions = /* @__PURE__ */ new Map();
1078
+ const userBidsSubscriptions = /* @__PURE__ */ new Map();
1064
1079
  const { onLifecycle, auth } = opts;
1065
1080
  let connectStartedAt = 0;
1066
1081
  function fireLifecycle(event) {
@@ -1111,6 +1126,9 @@ function createMqttTransport(endpoint, opts = {}) {
1111
1126
  for (const entry of walletSubscriptions.values()) {
1112
1127
  c.subscribe(entry.topic, { qos: 1 });
1113
1128
  }
1129
+ for (const entry of userBidsSubscriptions.values()) {
1130
+ c.subscribe(entry.pattern, { qos: 1 });
1131
+ }
1114
1132
  fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
1115
1133
  });
1116
1134
  client.on("reconnect", () => {
@@ -1159,6 +1177,19 @@ function createMqttTransport(endpoint, opts = {}) {
1159
1177
  walletSub.onMessage(receivedTopic, payload2);
1160
1178
  return;
1161
1179
  }
1180
+ let userBidsPayload;
1181
+ for (const sub of userBidsSubscriptions.values()) {
1182
+ if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
1183
+ if (userBidsPayload === void 0) {
1184
+ try {
1185
+ userBidsPayload = JSON.parse(message.toString());
1186
+ } catch {
1187
+ userBidsPayload = null;
1188
+ sub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
1189
+ }
1190
+ }
1191
+ if (userBidsPayload != null) sub.onMessage(receivedTopic, userBidsPayload);
1192
+ }
1162
1193
  let matched;
1163
1194
  for (const sub of subscriptions.values()) {
1164
1195
  if (sub.topics.includes(receivedTopic)) {
@@ -1246,6 +1277,19 @@ function createMqttTransport(endpoint, opts = {}) {
1246
1277
  if (client) client.unsubscribe(entry.topic);
1247
1278
  walletSubscriptions.delete(userId);
1248
1279
  },
1280
+ subscribeUserBids(userId, onMessage, onError) {
1281
+ if (userBidsSubscriptions.has(userId)) return;
1282
+ const pattern = userBidsWildcardTopic(userId);
1283
+ const mqttClient = ensureConnected();
1284
+ userBidsSubscriptions.set(userId, { pattern, onMessage, onError });
1285
+ mqttClient.subscribe(pattern, { qos: 1 });
1286
+ },
1287
+ unsubscribeUserBids(userId) {
1288
+ const entry = userBidsSubscriptions.get(userId);
1289
+ if (!entry) return;
1290
+ if (client) client.unsubscribe(entry.pattern);
1291
+ userBidsSubscriptions.delete(userId);
1292
+ },
1249
1293
  unsubscribeAll(gameId, userId) {
1250
1294
  const matches = entriesForGame(gameId);
1251
1295
  if (matches.length === 0) return;
@@ -1286,11 +1330,15 @@ function createMqttTransport(endpoint, opts = {}) {
1286
1330
  for (const entry of walletSubscriptions.values()) {
1287
1331
  client.unsubscribe(entry.topic);
1288
1332
  }
1333
+ for (const entry of userBidsSubscriptions.values()) {
1334
+ client.unsubscribe(entry.pattern);
1335
+ }
1289
1336
  }
1290
1337
  subscriptions.clear();
1291
1338
  candleSubscriptions.clear();
1292
1339
  statsSubscriptions.clear();
1293
1340
  walletSubscriptions.clear();
1341
+ userBidsSubscriptions.clear();
1294
1342
  if (client) {
1295
1343
  client.end(true);
1296
1344
  client = null;
@@ -1300,7 +1348,7 @@ function createMqttTransport(endpoint, opts = {}) {
1300
1348
  }
1301
1349
 
1302
1350
  // src/modules/realtime/index.ts
1303
- var import_eventemitter33 = __toESM(require("eventemitter3"));
1351
+ var import_eventemitter34 = __toESM(require("eventemitter3"));
1304
1352
 
1305
1353
  // src/modules/realtime/GameChannel.ts
1306
1354
  var import_eventemitter3 = __toESM(require("eventemitter3"));
@@ -1312,9 +1360,9 @@ var GameChannel = class extends import_eventemitter3.default {
1312
1360
  }
1313
1361
  };
1314
1362
 
1315
- // src/modules/realtime/WalletChannel.ts
1363
+ // src/modules/realtime/UserBidsChannel.ts
1316
1364
  var import_eventemitter32 = __toESM(require("eventemitter3"));
1317
- var WalletChannel = class extends import_eventemitter32.default {
1365
+ var UserBidsChannel = class extends import_eventemitter32.default {
1318
1366
  userId;
1319
1367
  constructor(userId) {
1320
1368
  super();
@@ -1322,6 +1370,29 @@ var WalletChannel = class extends import_eventemitter32.default {
1322
1370
  }
1323
1371
  };
1324
1372
 
1373
+ // src/modules/realtime/WalletChannel.ts
1374
+ var import_eventemitter33 = __toESM(require("eventemitter3"));
1375
+ var WalletChannel = class extends import_eventemitter33.default {
1376
+ userId;
1377
+ constructor(userId) {
1378
+ super();
1379
+ this.userId = userId;
1380
+ }
1381
+ };
1382
+
1383
+ // src/modules/realtime/topic.ts
1384
+ var BID_RESULT_SUFFIX = "bid_result";
1385
+ function pairIdFromBidResultTopic(topic) {
1386
+ if (!topic.endsWith(`/${BID_RESULT_SUFFIX}`)) return void 0;
1387
+ const parts = topic.split("/");
1388
+ if (parts.length !== 5 || parts[0] !== "game" || parts[2] !== "user") return void 0;
1389
+ const gameId = parts[1];
1390
+ const sep = gameId.indexOf(":");
1391
+ if (sep < 0) return void 0;
1392
+ const pairId = gameId.slice(sep + 1);
1393
+ return pairId === "" ? void 0 : pairId;
1394
+ }
1395
+
1325
1396
  // src/modules/realtime/index.ts
1326
1397
  var TOPIC_SUFFIX_CANDLE = "candle";
1327
1398
  var TOPIC_SUFFIX_BID_RESULT = "bid_result";
@@ -1344,7 +1415,7 @@ function mapWireCandle(raw) {
1344
1415
  if (c.coefMults !== void 0) result.coefMults = c.coefMults;
1345
1416
  return result;
1346
1417
  }
1347
- function mapWireBidResult(raw) {
1418
+ function mapWireBidResult(raw, topicPairId) {
1348
1419
  const p = raw;
1349
1420
  switch (p.type) {
1350
1421
  case "accepted": {
@@ -1361,13 +1432,13 @@ function mapWireBidResult(raw) {
1361
1432
  userId: p.user_id
1362
1433
  };
1363
1434
  if (typeof p.balance === "string") data.balance = p.balance;
1435
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1364
1436
  return { event: "bidWon", data };
1365
1437
  }
1366
1438
  case "lost": {
1367
- return {
1368
- event: "bidLost",
1369
- data: { bidId: p.bidId }
1370
- };
1439
+ const data = { bidId: p.bidId };
1440
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1441
+ return { event: "bidLost", data };
1371
1442
  }
1372
1443
  default:
1373
1444
  console.warn(`Unknown bid_result type "${String(p.type)}"`);
@@ -1402,6 +1473,9 @@ function mapWireConfig(raw) {
1402
1473
  function mapWireIdealConfig(raw) {
1403
1474
  const p = raw;
1404
1475
  return {
1476
+ // bid-260612 guard ①: carry the producer's pairId so consumers can reject cross-pair
1477
+ // suggestions. Undefined when talking to a producer that predates the field.
1478
+ pairId: p.pairId,
1405
1479
  cellSizeValue: p.cellSizeValue,
1406
1480
  currentPrice: p.currentPrice,
1407
1481
  reason: p.reason ?? "volatility_shift"
@@ -1412,7 +1486,7 @@ function mapWireToEvent(topic, payload) {
1412
1486
  return { event: "candle", data: mapWireCandle(payload) };
1413
1487
  }
1414
1488
  if (topic.endsWith(`/${TOPIC_SUFFIX_BID_RESULT}`)) {
1415
- return mapWireBidResult(payload);
1489
+ return mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1416
1490
  }
1417
1491
  if (topic.endsWith(`/${TOPIC_SUFFIX_BALANCE_UPDATE}`)) {
1418
1492
  return { event: "balanceUpdate", data: mapWireBalanceUpdate(payload) };
@@ -1428,11 +1502,13 @@ function mapWireToEvent(topic, payload) {
1428
1502
  function normaliseUserId2(userId) {
1429
1503
  return userId && userId !== "" ? userId : null;
1430
1504
  }
1431
- var RealtimeModule = class extends import_eventemitter33.default {
1505
+ var RealtimeModule = class extends import_eventemitter34.default {
1432
1506
  #transport;
1433
1507
  #entries = /* @__PURE__ */ new Map();
1434
1508
  #walletEntries = /* @__PURE__ */ new Map();
1435
1509
  // keyed by userId
1510
+ #userBidsEntries = /* @__PURE__ */ new Map();
1511
+ // keyed by userId
1436
1512
  #agencyId;
1437
1513
  constructor(mqttEndpointOrOptions) {
1438
1514
  super();
@@ -1563,6 +1639,64 @@ var RealtimeModule = class extends import_eventemitter33.default {
1563
1639
  entry.channel.removeAllListeners();
1564
1640
  this.#transport.unsubscribeWallet(cleanUserId);
1565
1641
  }
1642
+ /**
1643
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
1644
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
1645
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
1646
+ * on — independent of which pair is currently displayed (mirrors
1647
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
1648
+ * from the concrete result topic, so consumers can attribute and label it.
1649
+ *
1650
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
1651
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
1652
+ * channel drives state + sound, this cross-pair channel drives notifications.
1653
+ */
1654
+ subscribeUserBids(userId) {
1655
+ const cleanUserId = normaliseUserId2(userId);
1656
+ if (!cleanUserId) {
1657
+ throw new TaphubError("userId is required to subscribe to user bids", {
1658
+ code: "UserIdRequired"
1659
+ });
1660
+ }
1661
+ if (/[/+#]/.test(cleanUserId)) {
1662
+ throw new TaphubError("userId must not contain MQTT topic separators (/ + #)", {
1663
+ code: "InvalidUserId"
1664
+ });
1665
+ }
1666
+ const existing = this.#userBidsEntries.get(cleanUserId);
1667
+ if (existing) {
1668
+ existing.refcount += 1;
1669
+ return existing.channel;
1670
+ }
1671
+ const channel = new UserBidsChannel(cleanUserId);
1672
+ const onMessage = (topic, payload) => {
1673
+ const mapped = mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1674
+ if (!mapped) return;
1675
+ if (mapped.event === "bidWon") channel.emit("bidWon", mapped.data);
1676
+ else if (mapped.event === "bidLost") channel.emit("bidLost", mapped.data);
1677
+ };
1678
+ const onError = (err) => {
1679
+ channel.emit("error", err);
1680
+ };
1681
+ this.#userBidsEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
1682
+ this.#transport.subscribeUserBids(cleanUserId, onMessage, onError);
1683
+ return channel;
1684
+ }
1685
+ /**
1686
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1687
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1688
+ */
1689
+ unsubscribeUserBids(userId) {
1690
+ const cleanUserId = normaliseUserId2(userId);
1691
+ if (!cleanUserId) return;
1692
+ const entry = this.#userBidsEntries.get(cleanUserId);
1693
+ if (!entry) return;
1694
+ entry.refcount -= 1;
1695
+ if (entry.refcount > 0) return;
1696
+ this.#userBidsEntries.delete(cleanUserId);
1697
+ entry.channel.removeAllListeners();
1698
+ this.#transport.unsubscribeUserBids(cleanUserId);
1699
+ }
1566
1700
  /**
1567
1701
  * Subscribe to public market candle data for a pair, keyed by pairId
1568
1702
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1614,6 +1748,10 @@ var RealtimeModule = class extends import_eventemitter33.default {
1614
1748
  entry.channel.removeAllListeners();
1615
1749
  }
1616
1750
  this.#walletEntries.clear();
1751
+ for (const entry of this.#userBidsEntries.values()) {
1752
+ entry.channel.removeAllListeners();
1753
+ }
1754
+ this.#userBidsEntries.clear();
1617
1755
  this.#transport.close();
1618
1756
  }
1619
1757
  };
@@ -2980,5 +3118,6 @@ function calculateProbWin_v2(time1, time2, _price1, price2, creationTime, curren
2980
3118
  isTerminal,
2981
3119
  isWin,
2982
3120
  normalCDF,
2983
- normalPDF
3121
+ normalPDF,
3122
+ pairIdFromBidResultTopic
2984
3123
  });
package/dist/index.d.mts CHANGED
@@ -694,6 +694,7 @@ type MqttLifecycleHook = (event: MqttLifecycleEvent) => void;
694
694
  type MqttCandleHandler = (topic: string, payload: MqttWirePayload) => void;
695
695
  type MqttAgencyPairStatsHandler = (topic: string, payload: MqttWirePayload) => void;
696
696
  type MqttWalletMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
+ type MqttUserBidsMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
698
  interface MqttTransport {
698
699
  /**
699
700
  * Subscribe to a game's MQTT topics (config, ideal_config, bid_result, balance)
@@ -737,6 +738,16 @@ interface MqttTransport {
737
738
  subscribeWallet(userId: string, onMessage: MqttWalletMessageHandler, onError: MqttErrorHandler): void;
738
739
  /** Tear down the wallet subscription for a `userId`. No-op if not subscribed. */
739
740
  unsubscribeWallet(userId: string): void;
741
+ /**
742
+ * Subscribe to the user's bid-result stream across ALL pairs via the wildcard
743
+ * topic `game/+/user/{userId}/bid_result`. Independent of any game
744
+ * subscription — NOT torn down by `unsubscribeAll`, survives pair switches.
745
+ * Idempotent per `userId`. The handler receives the CONCRETE delivered topic
746
+ * (with the real gameId), so the caller can parse the pairId from it.
747
+ */
748
+ subscribeUserBids(userId: string, onMessage: MqttUserBidsMessageHandler, onError: MqttErrorHandler): void;
749
+ /** Tear down the cross-pair user-bids subscription for a `userId`. No-op if absent. */
750
+ unsubscribeUserBids(userId: string): void;
740
751
  close(): void;
741
752
  }
742
753
 
@@ -791,9 +802,19 @@ interface MqttBidWonEvent {
791
802
  payout: string;
792
803
  userId: string;
793
804
  balance?: string;
805
+ /**
806
+ * Pair the won bid belongs to, e.g. "grid-ETH-USD". Derived from the result
807
+ * topic (`game/{agencyId}:{pairId}/user/.../bid_result`) since the won payload
808
+ * carries no pair. Lets consumers attribute a result to its pair — required by
809
+ * the cross-pair subscription (a win for a non-active pair). Undefined only
810
+ * when the topic shape is unrecognised.
811
+ */
812
+ pairId?: string;
794
813
  }
795
814
  interface MqttBidLostEvent {
796
815
  bidId: string;
816
+ /** Pair the lost bid belongs to (see {@link MqttBidWonEvent.pairId}). */
817
+ pairId?: string;
797
818
  }
798
819
  interface MqttBidCancelledEvent {
799
820
  bidId: string;
@@ -838,6 +859,12 @@ interface MqttConfigEvent {
838
859
  acceptableBids: number[];
839
860
  }
840
861
  interface MqttIdealConfigEvent {
862
+ /**
863
+ * game_pairs.id (e.g. "grid-ETH-USD") the suggestion was computed for. Optional for
864
+ * backward compat with producers predating bid-260612; when present, a consumer drops
865
+ * suggestions whose pairId does not match the pair it is currently viewing (guard ①).
866
+ */
867
+ pairId?: string;
841
868
  cellSizeValue: number;
842
869
  currentPrice: number;
843
870
  reason: string;
@@ -872,6 +899,24 @@ declare class GameChannel extends EventEmitter<GameChannelEvents> {
872
899
  constructor(gameId: string);
873
900
  }
874
901
 
902
+ interface UserBidsChannelEvents {
903
+ bidWon: [MqttBidWonEvent];
904
+ bidLost: [MqttBidLostEvent];
905
+ error: [Error];
906
+ }
907
+ /**
908
+ * User-scoped, cross-pair bid-result channel. Emits `bidWon` / `bidLost` for the
909
+ * user's settlements on ANY pair, via the wildcard subscription
910
+ * `game/+/user/{userId}/bid_result`. Independent of which pair is currently
911
+ * displayed — keyed by `userId`, not `gameId`, so it survives pair switches
912
+ * (mirror of {@link WalletChannel}). Each emitted event carries the bid's
913
+ * `pairId` (parsed from the result topic) so consumers can attribute it.
914
+ */
915
+ declare class UserBidsChannel extends EventEmitter<UserBidsChannelEvents> {
916
+ readonly userId: string;
917
+ constructor(userId: string);
918
+ }
919
+
875
920
  interface WalletChannelEvents {
876
921
  walletBalanceUpdate: [MqttWalletBalanceEvent];
877
922
  error: [Error];
@@ -940,6 +985,24 @@ declare class RealtimeModule extends EventEmitter<RealtimeModuleEvents> {
940
985
  * No-op if there is no active wallet subscription for `userId`.
941
986
  */
942
987
  unsubscribeWallet(userId: string): void;
988
+ /**
989
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
990
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
991
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
992
+ * on — independent of which pair is currently displayed (mirrors
993
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
994
+ * from the concrete result topic, so consumers can attribute and label it.
995
+ *
996
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
997
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
998
+ * channel drives state + sound, this cross-pair channel drives notifications.
999
+ */
1000
+ subscribeUserBids(userId: string): UserBidsChannel;
1001
+ /**
1002
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1003
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1004
+ */
1005
+ unsubscribeUserBids(userId: string): void;
943
1006
  /**
944
1007
  * Subscribe to public market candle data for a pair, keyed by pairId
945
1008
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1169,6 +1232,20 @@ declare class TaphubSlippageError extends TaphubValidationError {
1169
1232
  });
1170
1233
  }
1171
1234
 
1235
+ /**
1236
+ * Extract the slash-free pairId from a bid-result topic.
1237
+ *
1238
+ * Accepts the concrete topic delivered by the broker, e.g.
1239
+ * `game/acme-corp:grid-ETH-USD/user/{uid}/bid_result` → `grid-ETH-USD`.
1240
+ *
1241
+ * Returns `undefined` when the topic does not match the expected shape (so the
1242
+ * caller can fall back gracefully rather than throw). The gameId segment is the
1243
+ * composite `{agencyId}:{pairId}`; the pairId is everything after the FIRST `:`
1244
+ * (pairId itself is slash-free and colon-free by the bid-260602-v2 id rules, but
1245
+ * we split on the first `:` to be safe if an agencyId ever contained one).
1246
+ */
1247
+ declare function pairIdFromBidResultTopic(topic: string): string | undefined;
1248
+
1172
1249
  declare function errorFunction(x: number): number;
1173
1250
  declare function normalCDF(x: number): number;
1174
1251
  declare function normalPDF(x: number): number;
@@ -1176,4 +1253,4 @@ declare function adaptiveSimpson(f: (x: number) => number, a: number, b: number,
1176
1253
  declare function calculateProbWin(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1177
1254
  declare function calculateProbWin_v2(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1178
1255
 
1179
- export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF };
1256
+ export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserBidsChannel, type UserBidsChannelEvents, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF, pairIdFromBidResultTopic };
package/dist/index.d.ts CHANGED
@@ -694,6 +694,7 @@ type MqttLifecycleHook = (event: MqttLifecycleEvent) => void;
694
694
  type MqttCandleHandler = (topic: string, payload: MqttWirePayload) => void;
695
695
  type MqttAgencyPairStatsHandler = (topic: string, payload: MqttWirePayload) => void;
696
696
  type MqttWalletMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
+ type MqttUserBidsMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
698
  interface MqttTransport {
698
699
  /**
699
700
  * Subscribe to a game's MQTT topics (config, ideal_config, bid_result, balance)
@@ -737,6 +738,16 @@ interface MqttTransport {
737
738
  subscribeWallet(userId: string, onMessage: MqttWalletMessageHandler, onError: MqttErrorHandler): void;
738
739
  /** Tear down the wallet subscription for a `userId`. No-op if not subscribed. */
739
740
  unsubscribeWallet(userId: string): void;
741
+ /**
742
+ * Subscribe to the user's bid-result stream across ALL pairs via the wildcard
743
+ * topic `game/+/user/{userId}/bid_result`. Independent of any game
744
+ * subscription — NOT torn down by `unsubscribeAll`, survives pair switches.
745
+ * Idempotent per `userId`. The handler receives the CONCRETE delivered topic
746
+ * (with the real gameId), so the caller can parse the pairId from it.
747
+ */
748
+ subscribeUserBids(userId: string, onMessage: MqttUserBidsMessageHandler, onError: MqttErrorHandler): void;
749
+ /** Tear down the cross-pair user-bids subscription for a `userId`. No-op if absent. */
750
+ unsubscribeUserBids(userId: string): void;
740
751
  close(): void;
741
752
  }
742
753
 
@@ -791,9 +802,19 @@ interface MqttBidWonEvent {
791
802
  payout: string;
792
803
  userId: string;
793
804
  balance?: string;
805
+ /**
806
+ * Pair the won bid belongs to, e.g. "grid-ETH-USD". Derived from the result
807
+ * topic (`game/{agencyId}:{pairId}/user/.../bid_result`) since the won payload
808
+ * carries no pair. Lets consumers attribute a result to its pair — required by
809
+ * the cross-pair subscription (a win for a non-active pair). Undefined only
810
+ * when the topic shape is unrecognised.
811
+ */
812
+ pairId?: string;
794
813
  }
795
814
  interface MqttBidLostEvent {
796
815
  bidId: string;
816
+ /** Pair the lost bid belongs to (see {@link MqttBidWonEvent.pairId}). */
817
+ pairId?: string;
797
818
  }
798
819
  interface MqttBidCancelledEvent {
799
820
  bidId: string;
@@ -838,6 +859,12 @@ interface MqttConfigEvent {
838
859
  acceptableBids: number[];
839
860
  }
840
861
  interface MqttIdealConfigEvent {
862
+ /**
863
+ * game_pairs.id (e.g. "grid-ETH-USD") the suggestion was computed for. Optional for
864
+ * backward compat with producers predating bid-260612; when present, a consumer drops
865
+ * suggestions whose pairId does not match the pair it is currently viewing (guard ①).
866
+ */
867
+ pairId?: string;
841
868
  cellSizeValue: number;
842
869
  currentPrice: number;
843
870
  reason: string;
@@ -872,6 +899,24 @@ declare class GameChannel extends EventEmitter<GameChannelEvents> {
872
899
  constructor(gameId: string);
873
900
  }
874
901
 
902
+ interface UserBidsChannelEvents {
903
+ bidWon: [MqttBidWonEvent];
904
+ bidLost: [MqttBidLostEvent];
905
+ error: [Error];
906
+ }
907
+ /**
908
+ * User-scoped, cross-pair bid-result channel. Emits `bidWon` / `bidLost` for the
909
+ * user's settlements on ANY pair, via the wildcard subscription
910
+ * `game/+/user/{userId}/bid_result`. Independent of which pair is currently
911
+ * displayed — keyed by `userId`, not `gameId`, so it survives pair switches
912
+ * (mirror of {@link WalletChannel}). Each emitted event carries the bid's
913
+ * `pairId` (parsed from the result topic) so consumers can attribute it.
914
+ */
915
+ declare class UserBidsChannel extends EventEmitter<UserBidsChannelEvents> {
916
+ readonly userId: string;
917
+ constructor(userId: string);
918
+ }
919
+
875
920
  interface WalletChannelEvents {
876
921
  walletBalanceUpdate: [MqttWalletBalanceEvent];
877
922
  error: [Error];
@@ -940,6 +985,24 @@ declare class RealtimeModule extends EventEmitter<RealtimeModuleEvents> {
940
985
  * No-op if there is no active wallet subscription for `userId`.
941
986
  */
942
987
  unsubscribeWallet(userId: string): void;
988
+ /**
989
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
990
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
991
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
992
+ * on — independent of which pair is currently displayed (mirrors
993
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
994
+ * from the concrete result topic, so consumers can attribute and label it.
995
+ *
996
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
997
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
998
+ * channel drives state + sound, this cross-pair channel drives notifications.
999
+ */
1000
+ subscribeUserBids(userId: string): UserBidsChannel;
1001
+ /**
1002
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1003
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1004
+ */
1005
+ unsubscribeUserBids(userId: string): void;
943
1006
  /**
944
1007
  * Subscribe to public market candle data for a pair, keyed by pairId
945
1008
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1169,6 +1232,20 @@ declare class TaphubSlippageError extends TaphubValidationError {
1169
1232
  });
1170
1233
  }
1171
1234
 
1235
+ /**
1236
+ * Extract the slash-free pairId from a bid-result topic.
1237
+ *
1238
+ * Accepts the concrete topic delivered by the broker, e.g.
1239
+ * `game/acme-corp:grid-ETH-USD/user/{uid}/bid_result` → `grid-ETH-USD`.
1240
+ *
1241
+ * Returns `undefined` when the topic does not match the expected shape (so the
1242
+ * caller can fall back gracefully rather than throw). The gameId segment is the
1243
+ * composite `{agencyId}:{pairId}`; the pairId is everything after the FIRST `:`
1244
+ * (pairId itself is slash-free and colon-free by the bid-260602-v2 id rules, but
1245
+ * we split on the first `:` to be safe if an agencyId ever contained one).
1246
+ */
1247
+ declare function pairIdFromBidResultTopic(topic: string): string | undefined;
1248
+
1172
1249
  declare function errorFunction(x: number): number;
1173
1250
  declare function normalCDF(x: number): number;
1174
1251
  declare function normalPDF(x: number): number;
@@ -1176,4 +1253,4 @@ declare function adaptiveSimpson(f: (x: number) => number, a: number, b: number,
1176
1253
  declare function calculateProbWin(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1177
1254
  declare function calculateProbWin_v2(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1178
1255
 
1179
- export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF };
1256
+ export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserBidsChannel, type UserBidsChannelEvents, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF, pairIdFromBidResultTopic };
package/dist/index.js CHANGED
@@ -978,6 +978,19 @@ function walletBalanceTopic(userId) {
978
978
  function agencyPairStatsTopic(aid, pairId) {
979
979
  return `public/agency/${aid}/pair/${pairId}/stats`;
980
980
  }
981
+ function userBidsWildcardTopic(userId) {
982
+ return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
983
+ }
984
+ function topicMatchesWildcard(pattern, topic) {
985
+ const pp = pattern.split("/");
986
+ const tp = topic.split("/");
987
+ if (pp.length !== tp.length) return false;
988
+ for (let i = 0; i < pp.length; i++) {
989
+ if (pp[i] === "+") continue;
990
+ if (pp[i] !== tp[i]) return false;
991
+ }
992
+ return true;
993
+ }
981
994
  function topicFor(gameId, suffix, userId) {
982
995
  if (USER_SCOPED_SUFFIXES.includes(suffix)) {
983
996
  return `${TOPIC_PREFIX}/${gameId}/user/${userId}/${suffix}`;
@@ -996,6 +1009,7 @@ function createMqttTransport(endpoint, opts = {}) {
996
1009
  const candleSubscriptions = /* @__PURE__ */ new Map();
997
1010
  const statsSubscriptions = /* @__PURE__ */ new Map();
998
1011
  const walletSubscriptions = /* @__PURE__ */ new Map();
1012
+ const userBidsSubscriptions = /* @__PURE__ */ new Map();
999
1013
  const { onLifecycle, auth } = opts;
1000
1014
  let connectStartedAt = 0;
1001
1015
  function fireLifecycle(event) {
@@ -1046,6 +1060,9 @@ function createMqttTransport(endpoint, opts = {}) {
1046
1060
  for (const entry of walletSubscriptions.values()) {
1047
1061
  c.subscribe(entry.topic, { qos: 1 });
1048
1062
  }
1063
+ for (const entry of userBidsSubscriptions.values()) {
1064
+ c.subscribe(entry.pattern, { qos: 1 });
1065
+ }
1049
1066
  fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
1050
1067
  });
1051
1068
  client.on("reconnect", () => {
@@ -1094,6 +1111,19 @@ function createMqttTransport(endpoint, opts = {}) {
1094
1111
  walletSub.onMessage(receivedTopic, payload2);
1095
1112
  return;
1096
1113
  }
1114
+ let userBidsPayload;
1115
+ for (const sub of userBidsSubscriptions.values()) {
1116
+ if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
1117
+ if (userBidsPayload === void 0) {
1118
+ try {
1119
+ userBidsPayload = JSON.parse(message.toString());
1120
+ } catch {
1121
+ userBidsPayload = null;
1122
+ sub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
1123
+ }
1124
+ }
1125
+ if (userBidsPayload != null) sub.onMessage(receivedTopic, userBidsPayload);
1126
+ }
1097
1127
  let matched;
1098
1128
  for (const sub of subscriptions.values()) {
1099
1129
  if (sub.topics.includes(receivedTopic)) {
@@ -1181,6 +1211,19 @@ function createMqttTransport(endpoint, opts = {}) {
1181
1211
  if (client) client.unsubscribe(entry.topic);
1182
1212
  walletSubscriptions.delete(userId);
1183
1213
  },
1214
+ subscribeUserBids(userId, onMessage, onError) {
1215
+ if (userBidsSubscriptions.has(userId)) return;
1216
+ const pattern = userBidsWildcardTopic(userId);
1217
+ const mqttClient = ensureConnected();
1218
+ userBidsSubscriptions.set(userId, { pattern, onMessage, onError });
1219
+ mqttClient.subscribe(pattern, { qos: 1 });
1220
+ },
1221
+ unsubscribeUserBids(userId) {
1222
+ const entry = userBidsSubscriptions.get(userId);
1223
+ if (!entry) return;
1224
+ if (client) client.unsubscribe(entry.pattern);
1225
+ userBidsSubscriptions.delete(userId);
1226
+ },
1184
1227
  unsubscribeAll(gameId, userId) {
1185
1228
  const matches = entriesForGame(gameId);
1186
1229
  if (matches.length === 0) return;
@@ -1221,11 +1264,15 @@ function createMqttTransport(endpoint, opts = {}) {
1221
1264
  for (const entry of walletSubscriptions.values()) {
1222
1265
  client.unsubscribe(entry.topic);
1223
1266
  }
1267
+ for (const entry of userBidsSubscriptions.values()) {
1268
+ client.unsubscribe(entry.pattern);
1269
+ }
1224
1270
  }
1225
1271
  subscriptions.clear();
1226
1272
  candleSubscriptions.clear();
1227
1273
  statsSubscriptions.clear();
1228
1274
  walletSubscriptions.clear();
1275
+ userBidsSubscriptions.clear();
1229
1276
  if (client) {
1230
1277
  client.end(true);
1231
1278
  client = null;
@@ -1235,7 +1282,7 @@ function createMqttTransport(endpoint, opts = {}) {
1235
1282
  }
1236
1283
 
1237
1284
  // src/modules/realtime/index.ts
1238
- import EventEmitter3 from "eventemitter3";
1285
+ import EventEmitter4 from "eventemitter3";
1239
1286
 
1240
1287
  // src/modules/realtime/GameChannel.ts
1241
1288
  import EventEmitter from "eventemitter3";
@@ -1247,9 +1294,9 @@ var GameChannel = class extends EventEmitter {
1247
1294
  }
1248
1295
  };
1249
1296
 
1250
- // src/modules/realtime/WalletChannel.ts
1297
+ // src/modules/realtime/UserBidsChannel.ts
1251
1298
  import EventEmitter2 from "eventemitter3";
1252
- var WalletChannel = class extends EventEmitter2 {
1299
+ var UserBidsChannel = class extends EventEmitter2 {
1253
1300
  userId;
1254
1301
  constructor(userId) {
1255
1302
  super();
@@ -1257,6 +1304,29 @@ var WalletChannel = class extends EventEmitter2 {
1257
1304
  }
1258
1305
  };
1259
1306
 
1307
+ // src/modules/realtime/WalletChannel.ts
1308
+ import EventEmitter3 from "eventemitter3";
1309
+ var WalletChannel = class extends EventEmitter3 {
1310
+ userId;
1311
+ constructor(userId) {
1312
+ super();
1313
+ this.userId = userId;
1314
+ }
1315
+ };
1316
+
1317
+ // src/modules/realtime/topic.ts
1318
+ var BID_RESULT_SUFFIX = "bid_result";
1319
+ function pairIdFromBidResultTopic(topic) {
1320
+ if (!topic.endsWith(`/${BID_RESULT_SUFFIX}`)) return void 0;
1321
+ const parts = topic.split("/");
1322
+ if (parts.length !== 5 || parts[0] !== "game" || parts[2] !== "user") return void 0;
1323
+ const gameId = parts[1];
1324
+ const sep = gameId.indexOf(":");
1325
+ if (sep < 0) return void 0;
1326
+ const pairId = gameId.slice(sep + 1);
1327
+ return pairId === "" ? void 0 : pairId;
1328
+ }
1329
+
1260
1330
  // src/modules/realtime/index.ts
1261
1331
  var TOPIC_SUFFIX_CANDLE = "candle";
1262
1332
  var TOPIC_SUFFIX_BID_RESULT = "bid_result";
@@ -1279,7 +1349,7 @@ function mapWireCandle(raw) {
1279
1349
  if (c.coefMults !== void 0) result.coefMults = c.coefMults;
1280
1350
  return result;
1281
1351
  }
1282
- function mapWireBidResult(raw) {
1352
+ function mapWireBidResult(raw, topicPairId) {
1283
1353
  const p = raw;
1284
1354
  switch (p.type) {
1285
1355
  case "accepted": {
@@ -1296,13 +1366,13 @@ function mapWireBidResult(raw) {
1296
1366
  userId: p.user_id
1297
1367
  };
1298
1368
  if (typeof p.balance === "string") data.balance = p.balance;
1369
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1299
1370
  return { event: "bidWon", data };
1300
1371
  }
1301
1372
  case "lost": {
1302
- return {
1303
- event: "bidLost",
1304
- data: { bidId: p.bidId }
1305
- };
1373
+ const data = { bidId: p.bidId };
1374
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1375
+ return { event: "bidLost", data };
1306
1376
  }
1307
1377
  default:
1308
1378
  console.warn(`Unknown bid_result type "${String(p.type)}"`);
@@ -1337,6 +1407,9 @@ function mapWireConfig(raw) {
1337
1407
  function mapWireIdealConfig(raw) {
1338
1408
  const p = raw;
1339
1409
  return {
1410
+ // bid-260612 guard ①: carry the producer's pairId so consumers can reject cross-pair
1411
+ // suggestions. Undefined when talking to a producer that predates the field.
1412
+ pairId: p.pairId,
1340
1413
  cellSizeValue: p.cellSizeValue,
1341
1414
  currentPrice: p.currentPrice,
1342
1415
  reason: p.reason ?? "volatility_shift"
@@ -1347,7 +1420,7 @@ function mapWireToEvent(topic, payload) {
1347
1420
  return { event: "candle", data: mapWireCandle(payload) };
1348
1421
  }
1349
1422
  if (topic.endsWith(`/${TOPIC_SUFFIX_BID_RESULT}`)) {
1350
- return mapWireBidResult(payload);
1423
+ return mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1351
1424
  }
1352
1425
  if (topic.endsWith(`/${TOPIC_SUFFIX_BALANCE_UPDATE}`)) {
1353
1426
  return { event: "balanceUpdate", data: mapWireBalanceUpdate(payload) };
@@ -1363,11 +1436,13 @@ function mapWireToEvent(topic, payload) {
1363
1436
  function normaliseUserId2(userId) {
1364
1437
  return userId && userId !== "" ? userId : null;
1365
1438
  }
1366
- var RealtimeModule = class extends EventEmitter3 {
1439
+ var RealtimeModule = class extends EventEmitter4 {
1367
1440
  #transport;
1368
1441
  #entries = /* @__PURE__ */ new Map();
1369
1442
  #walletEntries = /* @__PURE__ */ new Map();
1370
1443
  // keyed by userId
1444
+ #userBidsEntries = /* @__PURE__ */ new Map();
1445
+ // keyed by userId
1371
1446
  #agencyId;
1372
1447
  constructor(mqttEndpointOrOptions) {
1373
1448
  super();
@@ -1498,6 +1573,64 @@ var RealtimeModule = class extends EventEmitter3 {
1498
1573
  entry.channel.removeAllListeners();
1499
1574
  this.#transport.unsubscribeWallet(cleanUserId);
1500
1575
  }
1576
+ /**
1577
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
1578
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
1579
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
1580
+ * on — independent of which pair is currently displayed (mirrors
1581
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
1582
+ * from the concrete result topic, so consumers can attribute and label it.
1583
+ *
1584
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
1585
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
1586
+ * channel drives state + sound, this cross-pair channel drives notifications.
1587
+ */
1588
+ subscribeUserBids(userId) {
1589
+ const cleanUserId = normaliseUserId2(userId);
1590
+ if (!cleanUserId) {
1591
+ throw new TaphubError("userId is required to subscribe to user bids", {
1592
+ code: "UserIdRequired"
1593
+ });
1594
+ }
1595
+ if (/[/+#]/.test(cleanUserId)) {
1596
+ throw new TaphubError("userId must not contain MQTT topic separators (/ + #)", {
1597
+ code: "InvalidUserId"
1598
+ });
1599
+ }
1600
+ const existing = this.#userBidsEntries.get(cleanUserId);
1601
+ if (existing) {
1602
+ existing.refcount += 1;
1603
+ return existing.channel;
1604
+ }
1605
+ const channel = new UserBidsChannel(cleanUserId);
1606
+ const onMessage = (topic, payload) => {
1607
+ const mapped = mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1608
+ if (!mapped) return;
1609
+ if (mapped.event === "bidWon") channel.emit("bidWon", mapped.data);
1610
+ else if (mapped.event === "bidLost") channel.emit("bidLost", mapped.data);
1611
+ };
1612
+ const onError = (err) => {
1613
+ channel.emit("error", err);
1614
+ };
1615
+ this.#userBidsEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
1616
+ this.#transport.subscribeUserBids(cleanUserId, onMessage, onError);
1617
+ return channel;
1618
+ }
1619
+ /**
1620
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1621
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1622
+ */
1623
+ unsubscribeUserBids(userId) {
1624
+ const cleanUserId = normaliseUserId2(userId);
1625
+ if (!cleanUserId) return;
1626
+ const entry = this.#userBidsEntries.get(cleanUserId);
1627
+ if (!entry) return;
1628
+ entry.refcount -= 1;
1629
+ if (entry.refcount > 0) return;
1630
+ this.#userBidsEntries.delete(cleanUserId);
1631
+ entry.channel.removeAllListeners();
1632
+ this.#transport.unsubscribeUserBids(cleanUserId);
1633
+ }
1501
1634
  /**
1502
1635
  * Subscribe to public market candle data for a pair, keyed by pairId
1503
1636
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1549,6 +1682,10 @@ var RealtimeModule = class extends EventEmitter3 {
1549
1682
  entry.channel.removeAllListeners();
1550
1683
  }
1551
1684
  this.#walletEntries.clear();
1685
+ for (const entry of this.#userBidsEntries.values()) {
1686
+ entry.channel.removeAllListeners();
1687
+ }
1688
+ this.#userBidsEntries.clear();
1552
1689
  this.#transport.close();
1553
1690
  }
1554
1691
  };
@@ -2914,5 +3051,6 @@ export {
2914
3051
  isTerminal,
2915
3052
  isWin,
2916
3053
  normalCDF,
2917
- normalPDF
3054
+ normalPDF,
3055
+ pairIdFromBidResultTopic
2918
3056
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taphubhq/sdk-core",
3
- "version": "0.23.3",
3
+ "version": "0.24.0",
4
4
  "description": "Core SDK for building on the TabHub platform",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.cjs",