@taphubhq/sdk-core 0.25.5 → 0.26.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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @taphubhq/sdk-core
2
2
 
3
- Framework-agnostic TypeScript SDK for the TabHub platform.
3
+ Framework-agnostic TypeScript SDK for the TapHub platform. Wraps the grid-api and
4
+ user-service GraphQL endpoints plus the MQTT realtime streams behind one client.
5
+
6
+ For React bindings (hooks, providers, i18n), use
7
+ [`@taphubhq/sdk-react`](https://github.com/TaphubHQ/taphub-sdk/blob/main/packages/sdk-react/README.md), which builds on this package.
4
8
 
5
9
  ## Install
6
10
 
@@ -16,9 +20,11 @@ import { TaphubClient } from '@taphubhq/sdk-core';
16
20
  const client = new TaphubClient({
17
21
  agencyId: 'acme-corp',
18
22
  // Shared API base. The SDK derives:
19
- // - grid-api → ${endpoint}/grid-api/grid-gql
23
+ // - grid-api → ${endpoint}/grid-api/grid-gql
20
24
  // - user-service → ${endpoint}/taphub-user-service/th-user-gql
21
25
  endpoint: 'https://builder.taptrading.net/api',
26
+ // Optional — omit to run without realtime (client.realtime stays undefined).
27
+ mqttEndpoint: 'wss://mqtt.taptrading.net/mqtt',
22
28
  });
23
29
 
24
30
  // Session-token login via user-service GraphQL (preferred).
@@ -27,16 +33,276 @@ await client.auth.loginWithSession(upstreamSessionToken);
27
33
  const me = await client.user.me();
28
34
  ```
29
35
 
30
- ### Login methods
36
+ ### Config
37
+
38
+ | Field | Required | Notes |
39
+ |----------------|----------|-------|
40
+ | `agencyId` | yes | Sent as `x-builder-code` on every request. Also namespaces token storage and scopes agency MQTT topics. |
41
+ | `endpoint` | yes | Absolute URL of the shared API base. Must parse as a URL — the constructor throws `TaphubValidationError` (`code: 'InvalidConfig'`) otherwise. |
42
+ | `mqttEndpoint` | no | When absent, `client.realtime` is `undefined`; every other module still works. |
43
+ | `mqttAuth` | no | `{ username, password }`, forwarded verbatim to the broker. For the TapHub/EMQX JWT-in-username setup, pass the session token as `username` and `''` as `password`. Read once when the connection opens — no refresh on reconnect. Ignored without `mqttEndpoint`. |
44
+ | `storage` | no | `TaphubStorageAdapter` for token persistence. Defaults to `localStorage` in the browser, in-memory elsewhere. |
45
+ | `fetch` | no | Custom `fetch` implementation (SSR, instrumentation, test doubles). |
46
+
47
+ ### Naming: `pairId` vs `agencyPairId`
48
+
49
+ - **`pairId`** — the system-wide pair id, e.g. `grid-ETH-USD`. **Slash-free** by
50
+ design so it is safe inside MQTT topics, URLs, and Redis keys. This is what
51
+ every SDK method takes.
52
+ - **`agencyPairId`** — the agency-scoped composite `{agencyId}:{pairId}`, e.g.
53
+ `acme-corp:grid-ETH-USD`. The server composes it from your `x-builder-code` /
54
+ JWT; you only meet it as an MQTT `gameId` and on `PairInfo.agencyPairId`.
55
+ - **`ETH/USD`** is a *display* name (`Pair.pair`, `AgencyPairStats.pair`), never
56
+ an id.
57
+
58
+ The pre-`bid-260602-v2` names `game` / `gameId` / `gamePairId` are retired from
59
+ the public surface.
60
+
61
+ ## Client surface
62
+
63
+ | Accessor | Module | Auth |
64
+ |---------------------|-------------------------|------|
65
+ | `client.auth` | `AuthModule` | — |
66
+ | `client.user` | `UserModule` | JWT |
67
+ | `client.pair` | `PairModule` | JWT (anon-tolerant) |
68
+ | `client.bid` | `BidModule` | JWT |
69
+ | `client.leaderboard`| `LeaderboardModule` | mixed (see below) |
70
+ | `client.agencyPairs`| `AgencyPairModule` | anon (`x-builder-code`) |
71
+ | `client.locale` | `LocaleModule` | anon |
72
+ | `client.realtime` | `RealtimeModule \| undefined` | broker creds |
73
+ | `client.network` | `NetworkQualityMonitor` | — |
74
+
75
+ Token helpers live on the client itself: `getToken()`, `setToken(token, { isDemo })`,
76
+ `isDemo()`.
77
+
78
+ > **Anon vs JWT reads.** `agencyPairs.list()`, `leaderboard.list()` and
79
+ > `leaderboard.rankBoard()` deliberately send **`x-builder-code` only, never the
80
+ > JWT**. A signed-in user can hold a JWT for a different agency than the one in
81
+ > context (a builder's demo user is always issued a `taptrading` JWT); attaching
82
+ > it would make the backend take its "JWT wins" branch and return the wrong
83
+ > agency's data.
84
+
85
+ ## Auth
86
+
87
+ ```ts
88
+ await client.auth.loginWithSession(sessionToken); // → LoginResult
89
+ await client.auth.logout();
90
+ ```
31
91
 
32
- | Method | Transport | Status |
33
- |---------------------------------|--------------------|-------------|
92
+ | Method | Transport | Status |
93
+ |----------------------------------|----------------------|---------------|
34
94
  | `loginWithSession(sessionToken)` | user-service GraphQL | **Preferred** |
35
- | `createDemoUser(username?)` | grid-api GraphQL | Active |
36
- | `loginWithGoogle(idToken)` | grid-api REST | `@deprecated` |
37
- | `demoLogin(username?)` | grid-api REST | `@deprecated` |
95
+ | `createDemoUser(username?, opts?)` | grid-api GraphQL | Active |
96
+ | `loginWithGoogle(idToken)` | grid-api REST | `@deprecated` |
97
+ | `demoLogin(username?)` | grid-api REST | `@deprecated` |
98
+
99
+ REST-backed methods continue to work until the gateway retires the routes in a
100
+ follow-up change.
101
+
102
+ ## User
103
+
104
+ ```ts
105
+ const me = await client.user.me(); // Me
106
+ const pnl = await client.user.pnl({ period: 'weekly' }); // UserPnL — 'weekly' | '30d'
107
+ const wallets = await client.user.wallets(); // UserWallet[]
108
+ const usdc = await client.user.walletByCurrency('USDC.e'); // UserWallet
109
+ const enabled = await client.user.refreshCurrencies(); // Currency[]
110
+ client.user.clearCurrencies(); // drop the cached list
111
+ ```
112
+
113
+ `UserPnL` carries `gain` (= `totalPayout − totalWagered`), the wagered/payout
114
+ totals, bid/win counts, and both competition ranks (`pnlRank`, `volRank`; `0`
115
+ means unranked).
116
+
117
+ > `client.user.wallets()` returns `UserWallet` (`{ id, amount, currency, isEnable }`),
118
+ > which is **not** the `Wallet` returned by the auth module (`{ balance, isEnabled }`).
119
+ > The distinct export alias is deliberate — see the note in `src/index.ts`.
120
+
121
+ ## Pairs
122
+
123
+ ```ts
124
+ const pairs = await client.pair.availableGamePairs({ gameplayId: 'taptrading' }); // PairInfo[]
125
+ const pair = await client.pair.get('grid-ETH-USD'); // Pair
126
+ const bars = await client.pair.chartHistory('grid-ETH-USD'); // Candle[]
127
+ ```
128
+
129
+ - `get(pairId)` throws `TaphubValidationError` with `code: 'GameNotFound'` when
130
+ the pair has no configured game for the agency in context. It also feeds the
131
+ clock-offset estimate (see [Clock sync](#clock-sync)).
132
+ - `chartHistory(pairId, limit?)` defaults to `DEFAULT_CHART_HISTORY_LIMIT` (600);
133
+ the server clamps any request to its cache size (1500).
134
+ - **`Candle.time` from GraphQL is Unix epoch SECONDS.** The MQTT
135
+ `MqttCandleEvent.time` is **milliseconds**. Do not multiply the MQTT value.
136
+ - `PairInfo.agencyPairId` is populated only for authenticated callers, and is the
137
+ value to feed into `realtime.subscribe()`.
138
+ - `Pair.config` carries `gridConfig` (`cellSizeTime`, `cellSizeValue`,
139
+ `candleSize`, `baseline`, `baselineTime`), `constraints`, bid amount bounds, and
140
+ the effective cancel policy (`bidCancelMinSeconds`, `bidCancelRefundRate`).
141
+ Treat `undefined` / `0` on the cancel fields as "use the platform default".
142
+
143
+ ## Bids
144
+
145
+ ```ts
146
+ const bid = await client.bid.placeBid({
147
+ pairId: 'grid-ETH-USD',
148
+ walletId, // required — grid-api's wallet_id is ID!
149
+ time1, time2, // window, unix seconds
150
+ price1, price2, // decimal strings
151
+ coefficient: '2.35',
152
+ amount: '10',
153
+ slippage: 5, // tolerance percent
154
+ });
155
+
156
+ const result = await client.bid.cancelBid(bid.id); // { bid, refundAmount, newBalance }
157
+ const mine = await client.bid.listBids({ statuses: ['pending'], limit: 20, pairId });
158
+ ```
159
+
160
+ `placeBid` auto-stamps `ct` (raw client send time) and `clockOffset` at dispatch
161
+ so the server can record skew-corrected latency into `bid.meta`. Both are
162
+ untrusted tracing data — do not supply them yourself.
163
+
164
+ `listBids` takes `statuses: BidStatus[]` (include-semantics). The singular
165
+ `status` is `@deprecated` and folded into a one-element list; `statuses` wins when
166
+ both are present.
167
+
168
+ Status classifiers avoid string-literal typos:
169
+
170
+ ```ts
171
+ import { isPending, isTerminal, isWin, isLoss, isCancelled } from '@taphubhq/sdk-core';
172
+ ```
173
+
174
+ `Bid.refundRate` / `Bid.refundAmount` are present only on cancelled bids. Prefer
175
+ `refundAmount` for display — it is the exact credited value, so it needs no
176
+ recompute from a config rate. `Bid.meta.pairName` holds the display pair frozen
177
+ at placement; read it instead of mapping `pairId` yourself.
178
+
179
+ ## Leaderboard & rank
180
+
181
+ ```ts
182
+ const board = await client.leaderboard.rankBoard({
183
+ period: 'last_7d', // 'today' | 'yesterday' | 'last_7d' | 'last_30d' | 'all_time' | 'custom'
184
+ sort: 'pnl', // 'pnl' | 'vol'
185
+ sortDir: 'desc',
186
+ limit: 50, offset: 0,
187
+ }); // RankBoard — public, identical for every caller
188
+
189
+ const me = await client.leaderboard.myRank({ period: 'last_7d' }); // MyRank | null
190
+ ```
191
+
192
+ - `period: 'custom'` requires an **hour-aligned** ISO-8601 UTC `range`.
193
+ - The board carries no per-user data by design (`bid-260606-leaderboard-split-my-rank`)
194
+ so it stays cacheable — `myRank()` is the per-user companion and does attach
195
+ the JWT. It returns `null` when nobody is signed in, and `MyRank.entry === null`
196
+ when the caller has no activity in the window.
197
+ - `leaderboard.list({ period, sortBy })` is the v1 query and is `@deprecated` on
198
+ both the SDK and the backend.
199
+
200
+ ## Agency pairs (market list)
201
+
202
+ ```ts
203
+ const rows = await client.agencyPairs.list({ filter: { status: 'active' } }); // AgencyPairStats[]
204
+ ```
205
+
206
+ Returns the agency-in-context's pairs with `currentPrice`, `currentVol24h`
207
+ (sliding 24h bid-amount sum), `maxCoef`, `thumb`, and `ordering`. There is no
208
+ argument to target another agency by design. Seed your UI from this call, then
209
+ keep it live with `realtime.subscribeAgencyPairStats()` — the backend suppresses
210
+ unchanged ticks, so a subscriber may receive nothing until price or volume moves.
211
+
212
+ ## Locale & error copy
213
+
214
+ ```ts
215
+ const res = await client.locale.get('vi', lastSeenVersion);
216
+ // → { lang, version, notModified, translations }
217
+ ```
218
+
219
+ Pass the last-seen `version` as `knownVersion` to get `{ notModified: true,
220
+ translations: null }` when the body is unchanged, and keep your prior state.
221
+
222
+ Error copy is a separate catalog keyed by the backend `extensions.code`:
38
223
 
39
- REST-backed methods continue to work until the gateway retires the routes in a follow-up change.
224
+ ```ts
225
+ await client.locale.loadErrorMessages(navigator.language); // never throws
226
+
227
+ // …later, inside a catch:
228
+ showToast(client.locale.errorMessage(e.code, 'Could not place your bid.'));
229
+
230
+ // with placeholders filled from the error's meta:
231
+ client.locale.errorMessage('Bid_CoefficientMismatch', 'Odds changed — try again.', {
232
+ serverCoef: e.serverCoef,
233
+ });
234
+ ```
235
+
236
+ - `loadErrorMessages(lang)` is background hydration: it **never throws and never
237
+ rejects**. Any failure leaves the last-known-good catalog in place — failing to
238
+ translate an error must not itself become an error. The tag is normalised
239
+ (region dropped, Chinese scripts kept apart), and concurrent calls for the same
240
+ language share one request.
241
+ - `errorMessage(code, fallback, meta?)` is **synchronous by design** — error copy
242
+ is read inside a `catch`, where React hooks are illegal. It returns `fallback`
243
+ whenever the catalog cannot produce a usable sentence, never an empty string and
244
+ never the raw code. `meta` fills `{name}` placeholders; unfilled placeholders are
245
+ stripped so raw braces never reach the user.
246
+ - `getErrorMessages(lang, knownVersion?)` is the throwing sibling, for callers who
247
+ want to see why a fetch failed.
248
+ - `refresh()` is an admin operation — the backend re-pulls the source sheet for
249
+ every supported language and requires an `X-API-Key` header.
250
+
251
+ ## Realtime (MQTT)
252
+
253
+ `client.realtime` is `undefined` unless `mqttEndpoint` was configured. Channels
254
+ are refcounted: N `subscribe()` calls for the same key return the same channel and
255
+ need N matching `unsubscribe()` calls.
256
+
257
+ ```ts
258
+ const channel = client.realtime?.subscribe(agencyPairId, userId); // GameChannel
259
+ channel?.on('candle', (e) => drawCandle(e)); // MqttCandleEvent
260
+ channel?.on('bidAccepted', (e) => addLiveTrade(e)); // MqttBidAcceptedEvent
261
+ channel?.on('bidWon', (e) => celebrate(e));
262
+ channel?.on('bidLost', (e) => dim(e));
263
+ channel?.on('bidCancelled', (e) => remove(e.bidId));
264
+ channel?.on('balanceUpdate', (e) => setBalance(e.balance));
265
+ channel?.on('configUpdate', (e) => setBidBounds(e));
266
+ channel?.on('idealConfigUpdate', (e) => suggestCellSize(e));
267
+ channel?.on('error', (err) => report(err));
268
+ client.realtime?.unsubscribe(agencyPairId, userId);
269
+ ```
270
+
271
+ | Subscription | Key | Emits |
272
+ |---|---|---|
273
+ | `subscribe(gameId, userId?)` | `agencyPairId` (+ user) | `GameChannel` — candle, bid lifecycle, balance, config |
274
+ | `subscribeWallet(userId)` | `userId` | `WalletChannel` — `walletBalanceUpdate` on **every** balance change, no game context |
275
+ | `subscribeUserBids(userId)` | `userId` | `UserBidsChannel` — `bidWon` / `bidLost` across **all** pairs, survives pair switches |
276
+ | `subscribeCandle(pairId, handler)` | `pairId` | raw `MqttCandleEvent`, market-wide public data |
277
+ | `subscribeAgencyPairStats(pairId, handler)` | `pairId` | `MqttAgencyPairStatsEvent` for the client's own agency |
278
+
279
+ Each has a matching `unsubscribe*`; `disconnect()` tears everything down.
280
+
281
+ Gotchas worth reading twice:
282
+
283
+ - **`MqttCandleEvent.time` is MILLISECONDS**, unlike the GraphQL `Candle.time`
284
+ (seconds). Compare against `CANDLE_EVENT.NEW` / `CANDLE_EVENT.UPDATE` rather
285
+ than bare string literals.
286
+ - `volatility` is the **fast** σ that prices coefficients; `slowVolatility` is the
287
+ slow σ used for grid geometry only.
288
+ - `MqttAcceptedBid` mirrors the wire shape, **not** the GraphQL `Bid`:
289
+ `coordinates` is nested, `coefficient` / `price1` / `price2` are numbers, and
290
+ `createdAt` is epoch milliseconds. `gameUuid` is `@deprecated` — read `pairId`.
291
+ - Won/lost payloads carry no pair, so the SDK recovers `pairId` from the result
292
+ topic. `pairIdFromBidResultTopic(topic)` is exported if you parse topics
293
+ yourself; it returns `undefined` on an unrecognised shape rather than throwing.
294
+ - `subscribeAgencyPairStats` throws `TaphubError` (`code: 'AgencyIdRequired'`) if
295
+ the client was built without an `agencyId`.
296
+ - `MqttWalletBalanceEvent.reason` is an open enum — unknown values are passed
297
+ through rather than dropped, so handle the default case.
298
+
299
+ ## Clock sync
300
+
301
+ `client.pair.get()` brackets its request to measure RTT and records a one-shot
302
+ server-clock offset. `placeBid` then stamps that offset alongside the raw client
303
+ send time. This is best-effort: the estimate is set-once and finite-guarded, so it
304
+ never throws and never blocks the pair load. Nothing to configure — just be aware
305
+ that a client which never calls `pair.get()` reports an offset of `0`.
40
306
 
41
307
  ## Error Handling
42
308
 
@@ -81,6 +347,9 @@ The GraphQL transport classifies errors by the first entry's `extensions.code` a
81
347
 
82
348
  `TaphubSlippageError extends TaphubValidationError`, so existing `instanceof TaphubValidationError` catch blocks keep working. Place the `TaphubSlippageError` check first when both branches exist.
83
349
 
350
+ To turn a thrown `code` into a user-facing sentence, use
351
+ [`client.locale.errorMessage()`](#locale--error-copy) rather than hardcoding copy.
352
+
84
353
  ### Slippage error fallback
85
354
 
86
355
  `TaphubSlippageError` is only thrown when `extensions.meta.clientCoef`, `extensions.meta.serverCoef`, and `extensions.meta.slippage` are all finite numbers. The `slippage` field is the client-submitted tolerance percent (e.g. `5` for 5%). If `meta` is missing or malformed, the SDK falls back to a generic `TaphubValidationError` with `code === 'Bid_CoefficientMismatch'`. Consumers SHOULD treat the generic fallback as the same user-facing rejection but without typed coefficient access.
@@ -114,6 +383,79 @@ The monitor is always present, even when `mqttEndpoint` is not configured. In SS
114
383
  - Connection samples from `navigator.connection` are used only as a cold-start fallback; once 3 real samples accumulate they no longer contribute to the EMA.
115
384
  - MQTT QoS1 `puback` timing is not yet plumbed; v1 derives MQTT signal from `connect` / `reconnect` / `disconnect` events only.
116
385
 
386
+ ## Events
387
+
388
+ A small client-wide bus for cross-cutting signals:
389
+
390
+ ```ts
391
+ import type { TaphubEventMap } from '@taphubhq/sdk-core';
392
+ // 'auth-expired' → void
393
+ // 'network:change' → { previous: NetworkQuality; current: NetworkQuality }
394
+ ```
395
+
396
+ ## Storage
397
+
398
+ Token persistence goes through a 3-method adapter, namespaced per agency
399
+ (`taphub:{agencyId}:token`):
400
+
401
+ ```ts
402
+ import { TaphubStorage, autoDetectStorage } from '@taphubhq/sdk-core';
403
+
404
+ new TaphubClient({ ..., storage: TaphubStorage.memory() }); // SSR / tests
405
+ ```
406
+
407
+ `autoDetectStorage()` is the default: `localStorage` in the browser, in-memory
408
+ elsewhere. Implement `TaphubStorageAdapter` (`get` / `set` / `remove`) for
409
+ anything else.
410
+
411
+ ## Coefficient math
412
+
413
+ `placeBid` takes a `coefficient`, so a headless client has to compute one before it
414
+ can bid. `calculateCoefficientWrapper` is the model the server validates against —
415
+ the same first-passage computation the grid chart renders with, and the same one
416
+ grid-api runs at placement:
417
+
418
+ ```ts
419
+ import {
420
+ calculateCoefficientWrapper,
421
+ type CoefficientInput,
422
+ } from '@taphubhq/sdk-core';
423
+
424
+ const coefficient = calculateCoefficientWrapper({
425
+ time1, time2, price1, price2, // the cell you are bidding on
426
+ candleTime, candleClose, volatility, // the live candle
427
+ coefMults, minCoef, // from pair.config
428
+ cellSizeTime, candleSize, // from pair.config
429
+ });
430
+ ```
431
+
432
+ A return of `Number.POSITIVE_INFINITY` means the cell cannot be priced right now —
433
+ do not pass it to `placeBid`.
434
+
435
+ The server stays authoritative: a value that disagrees beyond your `slippage`
436
+ tolerance is rejected with `Bid_CoefficientMismatch` (see
437
+ [Slippage error fallback](#slippage-error-fallback)).
438
+
439
+ **Candle-interval variants.** `candleSize` is part of `CoefficientInput` but does not
440
+ enter the coefficient: the cell width is measured in bars (`cellSizeTime`), and
441
+ `calculateProbHit` already prices the real seconds window, so dividing by
442
+ `candleSize` a second time would double-penalise the 1m/5m variants by roughly
443
+ 60×/300× (`bid-260703`). Pass the value from `pair.config` anyway —
444
+ `computeBaseline` uses it.
445
+
446
+ ### Deprecated helpers
447
+
448
+ `calculateProbWin` and `calculateProbWin_v2` are **deprecated**. They compute a
449
+ one-sided exceedance probability and ignore `price1`, so they do not match the model
450
+ placement validates against. A coefficient derived from them can pass the
451
+ server's one-directional slippage check and lock a lower payout than the cell is
452
+ worth. Use `calculateCoefficientWrapper`. They remain exported, unchanged, for
453
+ existing callers.
454
+
455
+ The lower-level pieces — `calculateProbHit`, `roundCoefToSignificantDigits`,
456
+ `computeBaseline`, `normalCDF`, `normalPDF`, `errorFunction`, `adaptiveSimpson` —
457
+ are also exported for consumers that need to build on them directly.
458
+
117
459
  ## License
118
460
 
119
461
  MIT