@imbingox/acex 0.3.1-beta.0 → 0.4.0-beta.2

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.
@@ -1,5 +1,10 @@
1
1
  import { createHmac } from "node:crypto";
2
2
  import BigNumber from "bignumber.js";
3
+ import {
4
+ type HttpClientMessages,
5
+ type HttpRetryPolicy,
6
+ httpRequest,
7
+ } from "../../internal/http-client.ts";
3
8
  import { createManagedWebSocket } from "../../internal/managed-websocket.ts";
4
9
  import type {
5
10
  AccountCredentials,
@@ -25,6 +30,7 @@ import type {
25
30
 
26
31
  type TimerHandle = ReturnType<typeof setInterval>;
27
32
  type SignedRequestMethod = "GET" | "POST" | "DELETE";
33
+ type FetchLike = typeof fetch;
28
34
 
29
35
  interface BinancePapiBalance {
30
36
  asset?: string;
@@ -144,7 +150,31 @@ type BinancePrivateMessage =
144
150
  const BINANCE_PAPI_REST_BASE_URL = "https://papi.binance.com";
145
151
  const BINANCE_PAPI_WS_BASE_URL = "wss://fstream.binance.com/pm/ws";
146
152
  const DEFAULT_RECV_WINDOW = 5_000;
153
+ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
147
154
  const USDM_QUOTE_ASSETS = ["FDUSD", "USDC", "BUSD", "USDT"];
155
+ const SAFE_READ_RETRY_POLICY: HttpRetryPolicy = {
156
+ idempotent: true,
157
+ maxAttempts: 3,
158
+ };
159
+ const NO_RETRY_POLICY: HttpRetryPolicy = {
160
+ idempotent: false,
161
+ maxAttempts: 1,
162
+ };
163
+ const LISTEN_KEY_KEEPALIVE_RETRY_POLICY: HttpRetryPolicy = {
164
+ idempotent: true,
165
+ maxAttempts: 3,
166
+ };
167
+ function getBinancePapiHttpMessages(timeoutMs: number): HttpClientMessages {
168
+ return {
169
+ http: ({ status, statusText, url, rawBody }) =>
170
+ `Binance PAPI request failed: ${status} ${statusText ?? ""} ${url}${
171
+ rawBody ? ` ${rawBody}` : ""
172
+ }`,
173
+ timeout: () => `Binance PAPI fetch timeout after ${timeoutMs}ms`,
174
+ aborted: () => "Binance PAPI fetch aborted",
175
+ parse: ({ url }) => `Binance PAPI response parse failed: ${url}`,
176
+ };
177
+ }
148
178
 
149
179
  function requirePrivateCredentials(credentials: AccountCredentials): {
150
180
  apiKey: string;
@@ -521,21 +551,6 @@ function mapOrderUpdate(
521
551
  };
522
552
  }
523
553
 
524
- async function readJson<T>(response: Response, url: string): Promise<T> {
525
- const text = await response.text();
526
- if (!response.ok) {
527
- throw new Error(
528
- `Binance PAPI request failed: ${response.status} ${response.statusText} ${url} ${text}`,
529
- );
530
- }
531
-
532
- if (!text) {
533
- return {} as T;
534
- }
535
-
536
- return JSON.parse(text) as T;
537
- }
538
-
539
554
  export class BinancePrivateAdapter implements PrivateUserDataAdapter {
540
555
  readonly venue = "binance" as const;
541
556
  readonly readOnly = false;
@@ -569,6 +584,13 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
569
584
  clientOrderId: true,
570
585
  };
571
586
 
587
+ constructor(
588
+ private readonly options: {
589
+ readonly fetchFn?: FetchLike;
590
+ readonly httpTimeoutMs?: number;
591
+ } = {},
592
+ ) {}
593
+
572
594
  async bootstrapAccount(
573
595
  credentials: AccountCredentials,
574
596
  accountOptions?: Record<string, unknown>,
@@ -580,18 +602,24 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
580
602
  "/papi/v1/balance",
581
603
  credentials,
582
604
  accountOptions,
605
+ undefined,
606
+ SAFE_READ_RETRY_POLICY,
583
607
  ),
584
608
  this.signedRequest<BinancePapiAccount>(
585
609
  "GET",
586
610
  "/papi/v1/account",
587
611
  credentials,
588
612
  accountOptions,
613
+ undefined,
614
+ SAFE_READ_RETRY_POLICY,
589
615
  ),
590
616
  this.signedRequest<BinancePapiUmPosition[]>(
591
617
  "GET",
592
618
  "/papi/v1/um/positionRisk",
593
619
  credentials,
594
620
  accountOptions,
621
+ undefined,
622
+ SAFE_READ_RETRY_POLICY,
595
623
  ),
596
624
  ]);
597
625
 
@@ -621,12 +649,16 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
621
649
  "/papi/v1/account",
622
650
  credentials,
623
651
  accountOptions,
652
+ undefined,
653
+ SAFE_READ_RETRY_POLICY,
624
654
  ),
625
655
  this.signedRequest<BinancePapiUmPosition[]>(
626
656
  "GET",
627
657
  "/papi/v1/um/positionRisk",
628
658
  credentials,
629
659
  accountOptions,
660
+ undefined,
661
+ SAFE_READ_RETRY_POLICY,
630
662
  ),
631
663
  ]);
632
664
 
@@ -643,6 +675,8 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
643
675
  "/papi/v1/um/openOrders",
644
676
  credentials,
645
677
  accountOptions,
678
+ undefined,
679
+ SAFE_READ_RETRY_POLICY,
646
680
  );
647
681
 
648
682
  return orders.flatMap((order) => {
@@ -681,6 +715,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
681
715
  : `${request.reduceOnly}`,
682
716
  positionSide: encodePositionSide(request.positionSide),
683
717
  },
718
+ NO_RETRY_POLICY,
684
719
  );
685
720
 
686
721
  const mapped = mapOpenOrder(response, receivedAt);
@@ -709,6 +744,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
709
744
  orderId: request.orderId,
710
745
  origClientOrderId: request.clientOrderId,
711
746
  },
747
+ NO_RETRY_POLICY,
712
748
  );
713
749
 
714
750
  const mapped = mapOpenOrder(response, receivedAt);
@@ -735,6 +771,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
735
771
  {
736
772
  symbol: encodeUmSymbol(request.symbol),
737
773
  },
774
+ NO_RETRY_POLICY,
738
775
  );
739
776
 
740
777
  return responses.flatMap((response) => {
@@ -863,6 +900,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
863
900
  credentials: AccountCredentials,
864
901
  accountOptions?: Record<string, unknown>,
865
902
  queryParams?: Record<string, string | undefined>,
903
+ retryPolicy?: HttpRetryPolicy,
866
904
  ): Promise<T> {
867
905
  const { apiKey, secret } = requirePrivateCredentials(credentials);
868
906
  const params = new URLSearchParams();
@@ -882,14 +920,22 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
882
920
  params.set("signature", signQuery(params.toString(), secret));
883
921
 
884
922
  const url = `${BINANCE_PAPI_REST_BASE_URL}${path}?${params.toString()}`;
885
- const response = await fetch(url, {
923
+ const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
924
+ const response = await httpRequest<T>({
925
+ fetchFn: this.options.fetchFn,
926
+ url,
886
927
  method,
887
928
  headers: {
888
929
  "X-MBX-APIKEY": apiKey,
889
930
  },
931
+ timeoutMs,
932
+ parseAs: "json",
933
+ emptyBody: "empty_object",
934
+ retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
935
+ messages: getBinancePapiHttpMessages(timeoutMs),
890
936
  });
891
937
 
892
- return readJson<T>(response, url);
938
+ return response.body;
893
939
  }
894
940
 
895
941
  private async startUserDataStream(
@@ -898,6 +944,8 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
898
944
  const response = await this.userStreamRequest<BinanceListenKeyResponse>(
899
945
  "POST",
900
946
  credentials,
947
+ undefined,
948
+ NO_RETRY_POLICY,
901
949
  );
902
950
  if (!response.listenKey) {
903
951
  throw new Error("Binance PAPI did not return a listenKey");
@@ -914,6 +962,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
914
962
  "PUT",
915
963
  credentials,
916
964
  listenKey,
965
+ LISTEN_KEY_KEEPALIVE_RETRY_POLICY,
917
966
  );
918
967
  }
919
968
 
@@ -925,6 +974,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
925
974
  "DELETE",
926
975
  credentials,
927
976
  listenKey,
977
+ NO_RETRY_POLICY,
928
978
  );
929
979
  }
930
980
 
@@ -932,6 +982,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
932
982
  method: "POST" | "PUT" | "DELETE",
933
983
  credentials: AccountCredentials,
934
984
  listenKey?: string,
985
+ retryPolicy: HttpRetryPolicy = NO_RETRY_POLICY,
935
986
  ): Promise<T> {
936
987
  const { apiKey } = requirePrivateCredentials(credentials);
937
988
  const params = new URLSearchParams();
@@ -943,13 +994,21 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
943
994
  const url = `${BINANCE_PAPI_REST_BASE_URL}/papi/v1/listenKey${
944
995
  query ? `?${query}` : ""
945
996
  }`;
946
- const response = await fetch(url, {
997
+ const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
998
+ const response = await httpRequest<T>({
999
+ fetchFn: this.options.fetchFn,
1000
+ url,
947
1001
  method,
948
1002
  headers: {
949
1003
  "X-MBX-APIKEY": apiKey,
950
1004
  },
1005
+ timeoutMs,
1006
+ parseAs: "json",
1007
+ emptyBody: "empty_object",
1008
+ retryPolicy,
1009
+ messages: getBinancePapiHttpMessages(timeoutMs),
951
1010
  });
952
1011
 
953
- return readJson<T>(response, url);
1012
+ return response.body;
954
1013
  }
955
1014
  }
@@ -1,5 +1,9 @@
1
1
  import BigNumber from "bignumber.js";
2
2
  import { AcexError } from "../../errors.ts";
3
+ import {
4
+ type HttpClientMessages,
5
+ httpRequest,
6
+ } from "../../internal/http-client.ts";
3
7
  import type {
4
8
  AccountCredentials,
5
9
  VenueAccountCapabilities,
@@ -66,6 +70,8 @@ interface JuplendPriceApiEntry {
66
70
  decimals?: number | string;
67
71
  }
68
72
 
73
+ type FetchLike = typeof fetch;
74
+
69
75
  interface JuplendTokenSearchEntry {
70
76
  id?: string;
71
77
  address?: string;
@@ -86,6 +92,13 @@ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
86
92
  // not mint-atomic token amounts.
87
93
  const POSITION_AMOUNT_SCALE_DECIMALS = 9;
88
94
  const VAULT_CACHE_TTL_MS = 60 * 60 * 1_000;
95
+ function getJuplendHttpMessages(timeoutMs: number): HttpClientMessages {
96
+ return {
97
+ http: ({ status, statusText }) => `Juplend HTTP ${status}: ${statusText}`,
98
+ timeout: () => `Juplend fetch timeout after ${timeoutMs}ms`,
99
+ aborted: () => "Juplend fetch aborted",
100
+ };
101
+ }
89
102
 
90
103
  interface JuplendVaultEnrichmentCacheEntry {
91
104
  loadedAt: number;
@@ -260,52 +273,30 @@ function buildRisk(input: {
260
273
  };
261
274
  }
262
275
 
263
- async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
264
- const controller = new AbortController();
265
- const upstreamSignal = init?.signal;
266
- let timedOut = false;
267
- const onUpstreamAbort = () => {
268
- controller.abort();
269
- };
270
-
271
- if (upstreamSignal?.aborted) {
272
- controller.abort();
273
- } else if (upstreamSignal) {
274
- upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
275
- }
276
-
277
- const timeout = setTimeout(() => {
278
- timedOut = true;
279
- controller.abort();
280
- }, DEFAULT_HTTP_TIMEOUT_MS);
281
-
282
- try {
283
- const response = await fetch(url, {
284
- ...init,
285
- signal: controller.signal,
286
- });
287
- if (!response.ok) {
288
- throw new Error(
289
- `Juplend HTTP ${response.status}: ${response.statusText}`,
290
- );
291
- }
276
+ async function requestJuplendJson<T>(
277
+ url: string,
278
+ init: RequestInit | undefined,
279
+ fetchFn: FetchLike | undefined,
280
+ timeoutMs: number,
281
+ ): Promise<T> {
282
+ const response = await httpRequest<T>({
283
+ fetchFn,
284
+ url,
285
+ method: init?.method,
286
+ headers: init?.headers,
287
+ body: init?.body,
288
+ signal: init?.signal ?? undefined,
289
+ timeoutMs,
290
+ parseAs: "json",
291
+ jsonParseMode: "response",
292
+ retryPolicy: {
293
+ idempotent: true,
294
+ maxAttempts: 3,
295
+ },
296
+ messages: getJuplendHttpMessages(timeoutMs),
297
+ });
292
298
 
293
- return (await response.json()) as T;
294
- } catch (error) {
295
- if (error instanceof Error && error.name === "AbortError") {
296
- throw new Error(
297
- timedOut
298
- ? `Juplend fetch timeout after ${DEFAULT_HTTP_TIMEOUT_MS}ms`
299
- : "Juplend fetch aborted",
300
- );
301
- }
302
- throw error;
303
- } finally {
304
- clearTimeout(timeout);
305
- if (upstreamSignal) {
306
- upstreamSignal.removeEventListener("abort", onUpstreamAbort);
307
- }
308
- }
299
+ return response.body;
309
300
  }
310
301
 
311
302
  function getJupApiKey(explicitApiKey?: string): string | undefined {
@@ -326,12 +317,19 @@ function withBaseUrl(baseUrl: string, path: string): string {
326
317
 
327
318
  async function loadVaultMetadataFromLiteApi(
328
319
  apiKey?: string,
320
+ fetchFn?: FetchLike,
321
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
329
322
  ): Promise<Map<string, JuplendVaultMetadata>> {
330
- const response = await readJson<
323
+ const response = await requestJuplendJson<
331
324
  JuplendVaultMetadata[] | { data?: JuplendVaultMetadata[] }
332
- >(withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH), {
333
- headers: buildApiHeaders(apiKey),
334
- });
325
+ >(
326
+ withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH),
327
+ {
328
+ headers: buildApiHeaders(apiKey),
329
+ },
330
+ fetchFn,
331
+ timeoutMs,
332
+ );
335
333
  const rawVaults = Array.isArray(response) ? response : response.data;
336
334
  const vaults = new Map<string, JuplendVaultMetadata>();
337
335
 
@@ -348,17 +346,21 @@ async function loadVaultMetadataFromLiteApi(
348
346
  async function loadTokenSearchMap(
349
347
  mintAddresses: string[],
350
348
  apiKey?: string,
349
+ fetchFn?: FetchLike,
350
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
351
351
  ): Promise<Map<string, JuplendTokenMetadata>> {
352
352
  if (mintAddresses.length === 0) {
353
353
  return new Map();
354
354
  }
355
355
 
356
356
  const query = encodeURIComponent(mintAddresses.join(","));
357
- const response = await readJson<JuplendTokenSearchEntry[]>(
357
+ const response = await requestJuplendJson<JuplendTokenSearchEntry[]>(
358
358
  `${withBaseUrl(JUP_API_BASE_URL, TOKENS_SEARCH_PATH)}?query=${query}`,
359
359
  {
360
360
  headers: buildApiHeaders(apiKey),
361
361
  },
362
+ fetchFn,
363
+ timeoutMs,
362
364
  );
363
365
 
364
366
  const tokens = new Map<string, JuplendTokenMetadata>();
@@ -385,17 +387,23 @@ async function loadTokenSearchMap(
385
387
  async function loadPriceMap(
386
388
  mintAddresses: string[],
387
389
  apiKey?: string,
390
+ fetchFn?: FetchLike,
391
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
388
392
  ): Promise<Map<string, JuplendPriceApiEntry>> {
389
393
  if (mintAddresses.length === 0) {
390
394
  return new Map();
391
395
  }
392
396
 
393
397
  const ids = encodeURIComponent(mintAddresses.join(","));
394
- const response = await readJson<Record<string, JuplendPriceApiEntry>>(
398
+ const response = await requestJuplendJson<
399
+ Record<string, JuplendPriceApiEntry>
400
+ >(
395
401
  `${withBaseUrl(JUP_API_BASE_URL, PRICE_V3_PATH)}?ids=${ids}`,
396
402
  {
397
403
  headers: buildApiHeaders(apiKey),
398
404
  },
405
+ fetchFn,
406
+ timeoutMs,
399
407
  );
400
408
 
401
409
  return new Map(Object.entries(response ?? {}));
@@ -436,6 +444,8 @@ function mergeTokenMetadata(
436
444
  async function enrichVaultsWithJupApi(input: {
437
445
  apiKey?: string;
438
446
  baseVaults: Map<string, JuplendVaultMetadata>;
447
+ fetchFn?: FetchLike;
448
+ timeoutMs: number;
439
449
  }): Promise<Map<string, JuplendVaultMetadata>> {
440
450
  const mintAddresses = new Set<string>();
441
451
  for (const vault of input.baseVaults.values()) {
@@ -450,8 +460,18 @@ async function enrichVaultsWithJupApi(input: {
450
460
  }
451
461
 
452
462
  const [tokenMap, priceMap] = await Promise.all([
453
- loadTokenSearchMap([...mintAddresses], input.apiKey),
454
- loadPriceMap([...mintAddresses], input.apiKey),
463
+ loadTokenSearchMap(
464
+ [...mintAddresses],
465
+ input.apiKey,
466
+ input.fetchFn,
467
+ input.timeoutMs,
468
+ ),
469
+ loadPriceMap(
470
+ [...mintAddresses],
471
+ input.apiKey,
472
+ input.fetchFn,
473
+ input.timeoutMs,
474
+ ),
455
475
  ]);
456
476
 
457
477
  const enriched = new Map<string, JuplendVaultMetadata>();
@@ -480,6 +500,8 @@ async function enrichVaultsWithJupApi(input: {
480
500
  async function loadVaults(
481
501
  now: number,
482
502
  apiKey?: string,
503
+ fetchFn?: FetchLike,
504
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
483
505
  ): Promise<Map<string, JuplendVaultMetadata>> {
484
506
  const cacheKey = getEnrichmentCacheKey(apiKey);
485
507
  const cached = enrichmentCache.get(cacheKey);
@@ -492,7 +514,11 @@ async function loadVaults(
492
514
  const inflight = enrichmentCachePromise.get(cacheKey);
493
515
  if (!inflight) {
494
516
  const nextPromise = (async () => {
495
- const baseVaults = await loadVaultMetadataFromLiteApi(apiKey);
517
+ const baseVaults = await loadVaultMetadataFromLiteApi(
518
+ apiKey,
519
+ fetchFn,
520
+ timeoutMs,
521
+ );
496
522
  if (!apiKey) {
497
523
  enrichmentCache.set(cacheKey, {
498
524
  loadedAt: now,
@@ -506,6 +532,8 @@ async function loadVaults(
506
532
  const enrichedVaults = await enrichVaultsWithJupApi({
507
533
  apiKey,
508
534
  baseVaults,
535
+ fetchFn,
536
+ timeoutMs,
509
537
  });
510
538
  enrichmentCache.set(cacheKey, {
511
539
  loadedAt: now,
@@ -545,9 +573,11 @@ async function mapAccount(
545
573
  receivedAt: number,
546
574
  rpcUrl: string | undefined,
547
575
  jupApiKey: string | undefined,
576
+ fetchFn: FetchLike | undefined,
577
+ timeoutMs: number,
548
578
  ): Promise<JuplendMappedAccount> {
549
579
  const [vaults, positionResult] = await Promise.all([
550
- loadVaults(receivedAt, jupApiKey),
580
+ loadVaults(receivedAt, jupApiKey, fetchFn, timeoutMs),
551
581
  readJuplendPositions({
552
582
  walletAddress: accountOptions.walletAddress,
553
583
  vaultId: accountOptions.vaultId,
@@ -666,6 +696,10 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
666
696
  constructor(
667
697
  private readonly rpcUrl?: string,
668
698
  private readonly jupApiKey?: string,
699
+ private readonly options: {
700
+ readonly fetchFn?: FetchLike;
701
+ readonly httpTimeoutMs?: number;
702
+ } = {},
669
703
  ) {}
670
704
 
671
705
  async bootstrapAccount(
@@ -679,6 +713,8 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
679
713
  receivedAt,
680
714
  this.rpcUrl,
681
715
  getJupApiKey(this.jupApiKey),
716
+ this.options.fetchFn,
717
+ this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
682
718
  );
683
719
 
684
720
  return {
@@ -3,7 +3,12 @@ import type {
3
3
  StreamHandle,
4
4
  } from "../adapters/types.ts";
5
5
  import { AcexError } from "../errors.ts";
6
- import type { AccountRuntimeOptions, Venue } from "../types/index.ts";
6
+ import { isTransportError, redactSecrets } from "../internal/http-client.ts";
7
+ import type {
8
+ AccountRuntimeOptions,
9
+ PrivateRuntimeReason,
10
+ Venue,
11
+ } from "../types/index.ts";
7
12
  import type {
8
13
  ClientContext,
9
14
  PrivateAccountDataConsumer,
@@ -41,6 +46,23 @@ function normalizePositiveInterval(
41
46
  : fallback;
42
47
  }
43
48
 
49
+ function transportReason(
50
+ error: unknown,
51
+ fallback: PrivateRuntimeReason,
52
+ ): PrivateRuntimeReason {
53
+ return isTransportError(error) && error.kind === "rate_limited"
54
+ ? "rate_limited"
55
+ : fallback;
56
+ }
57
+
58
+ function bootstrapErrorDetail(error: unknown): string {
59
+ if (!(error instanceof Error) || !error.message) {
60
+ return "";
61
+ }
62
+
63
+ return ` (${redactSecrets(error.message)})`;
64
+ }
65
+
44
66
  export class PrivateSubscriptionCoordinator {
45
67
  private readonly context: ClientContext;
46
68
  private readonly adapters: Map<Venue, PrivateUserDataAdapter>;
@@ -435,7 +457,7 @@ export class PrivateSubscriptionCoordinator {
435
457
  {
436
458
  runtimeStatus: "degraded",
437
459
  ready: record.accountReady,
438
- reason: "http_failed",
460
+ reason: transportReason(error, "http_failed"),
439
461
  },
440
462
  );
441
463
  }
@@ -559,7 +581,7 @@ export class PrivateSubscriptionCoordinator {
559
581
  {
560
582
  runtimeStatus: "degraded",
561
583
  ready: record.accountReady,
562
- reason: "http_failed",
584
+ reason: transportReason(error, "http_failed"),
563
585
  },
564
586
  );
565
587
  }
@@ -676,11 +698,13 @@ export class PrivateSubscriptionCoordinator {
676
698
  {
677
699
  runtimeStatus: "degraded",
678
700
  ready: false,
679
- reason: record.venue === "juplend" ? "http_failed" : "auth_failed",
701
+ reason: transportReason(
702
+ error,
703
+ record.venue === "juplend" ? "http_failed" : "auth_failed",
704
+ ),
680
705
  },
681
706
  );
682
- const reason =
683
- error instanceof Error && error.message ? ` (${error.message})` : "";
707
+ const reason = bootstrapErrorDetail(error);
684
708
  throw new AcexError(
685
709
  "ACCOUNT_BOOTSTRAP_FAILED",
686
710
  `Failed to bootstrap account data: ${record.accountId}${reason}`,
@@ -727,7 +751,7 @@ export class PrivateSubscriptionCoordinator {
727
751
  {
728
752
  runtimeStatus: "degraded",
729
753
  ready: false,
730
- reason: "auth_failed",
754
+ reason: transportReason(error, "auth_failed"),
731
755
  },
732
756
  );
733
757
  throw new AcexError(
@@ -0,0 +1,19 @@
1
+ import BigNumber from "bignumber.js";
2
+ import type { DecimalInput } from "../types/index.ts";
3
+
4
+ /**
5
+ * Convert a decimal value to its canonical string form: full precision, no
6
+ * scientific notation, no trailing zeros.
7
+ *
8
+ * Throws on non-finite input (NaN / Infinity) so producers can never leak
9
+ * sentinel strings into public output fields. Call sites that legitimately
10
+ * accept non-finite input (e.g. order-input validation) must guard before
11
+ * calling this.
12
+ */
13
+ export function toCanonical(value: DecimalInput): string {
14
+ const bn = new BigNumber(value);
15
+ if (!bn.isFinite()) {
16
+ throw new RangeError(`invalid non-finite DecimalInput: ${bn.toString()}`);
17
+ }
18
+ return bn.toFixed();
19
+ }