ton-provider-system 0.1.0 → 0.1.1

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.js CHANGED
@@ -197,7 +197,10 @@ function resolveProvider(id, config) {
197
197
  console.warn(`[ConfigParser] Provider ${id} has no valid endpoints after resolution`);
198
198
  return null;
199
199
  }
200
- const apiKey = config.apiKeyEnvVar ? getEnvVar(config.apiKeyEnvVar) : void 0;
200
+ let apiKey = config.apiKeyEnvVar ? getEnvVar(config.apiKeyEnvVar) : void 0;
201
+ if (!apiKey && config.keyEnvVar && config.type === "onfinality") {
202
+ apiKey = getEnvVar(config.keyEnvVar);
203
+ }
201
204
  return {
202
205
  id,
203
206
  name: config.name,
@@ -248,13 +251,24 @@ function getDefaultProvidersForNetwork(config, network) {
248
251
  async function loadBuiltinConfig() {
249
252
  const fs = await import('fs').then((m) => m.promises);
250
253
  const path = await import('path');
254
+ const { fileURLToPath } = await import('url');
255
+ const getDirname = () => {
256
+ try {
257
+ if (import.meta.url) {
258
+ return path.dirname(fileURLToPath(import.meta.url));
259
+ }
260
+ } catch {
261
+ }
262
+ return process.cwd();
263
+ };
264
+ const dirname = getDirname();
251
265
  const possiblePaths = [
252
266
  // When running from project root (e.g., ts-node scripts/...)
253
267
  path.resolve(process.cwd(), "provider_system", RPC_CONFIG_FILENAME),
254
268
  // When running from provider_system folder
255
269
  path.resolve(process.cwd(), RPC_CONFIG_FILENAME),
256
- // Relative to this file (CommonJS style)
257
- path.resolve(__dirname, "..", RPC_CONFIG_FILENAME)
270
+ // Relative to this file (ESM style)
271
+ path.resolve(dirname, "..", RPC_CONFIG_FILENAME)
258
272
  ];
259
273
  for (const configPath of possiblePaths) {
260
274
  try {
@@ -547,12 +561,63 @@ function normalizeV2Endpoint(endpoint) {
547
561
  if (normalized.toLowerCase().endsWith("/jsonrpc")) {
548
562
  return normalized;
549
563
  }
564
+ if (normalized.includes("gateway.tatum.io")) {
565
+ try {
566
+ const url = new URL(normalized);
567
+ if (!url.pathname || url.pathname === "/") {
568
+ return normalized + "/jsonRPC";
569
+ }
570
+ if (!url.pathname.toLowerCase().endsWith("/jsonrpc")) {
571
+ return normalized + "/jsonRPC";
572
+ }
573
+ } catch {
574
+ return normalized + "/jsonRPC";
575
+ }
576
+ }
577
+ if (normalized.includes("onfinality.io")) {
578
+ try {
579
+ const url = new URL(normalized);
580
+ const baseUrl = normalized.split("?")[0];
581
+ if (!url.pathname || url.pathname === "/") {
582
+ const apikey = url.searchParams.get("apikey");
583
+ if (apikey && apikey !== "{key}" && apikey.length > 0) {
584
+ return baseUrl.replace(/\/?$/, "/rpc");
585
+ }
586
+ return baseUrl.replace(/\/?$/, "/public");
587
+ }
588
+ if (url.pathname === "/rpc" || url.pathname === "/public") {
589
+ return baseUrl;
590
+ }
591
+ return baseUrl;
592
+ } catch {
593
+ if (normalized.includes("{key}")) {
594
+ return normalized.split("?")[0].replace(/\/?$/, "/public");
595
+ }
596
+ if (!normalized.includes("/rpc") && !normalized.includes("/public")) {
597
+ return normalized.split("?")[0] + "/public";
598
+ }
599
+ return normalized.split("?")[0];
600
+ }
601
+ }
550
602
  if (normalized.endsWith("/api/v2")) {
551
603
  return normalized + "/jsonRPC";
552
604
  }
553
605
  if (normalized.endsWith("/api/v3")) {
554
606
  return normalized.replace("/api/v3", "/api/v2/jsonRPC");
555
607
  }
608
+ if (normalized.includes("quiknode.pro") || normalized.includes("getblock.io")) {
609
+ try {
610
+ const url = new URL(normalized);
611
+ if (!url.pathname || url.pathname === "/") {
612
+ return normalized + "/jsonRPC";
613
+ }
614
+ if (!url.pathname.toLowerCase().endsWith("/jsonrpc")) {
615
+ return normalized + "/jsonRPC";
616
+ }
617
+ } catch {
618
+ return normalized + "/jsonRPC";
619
+ }
620
+ }
556
621
  try {
557
622
  const url = new URL(normalized);
558
623
  if (!url.pathname || url.pathname === "/") {
@@ -580,7 +645,7 @@ function toV2Base(endpoint) {
580
645
  return normalized;
581
646
  }
582
647
  function toV3Base(endpoint) {
583
- let normalized = toV2Base(endpoint);
648
+ const normalized = toV2Base(endpoint);
584
649
  return normalized.replace("/api/v2", "/api/v3");
585
650
  }
586
651
  function getBaseUrl(endpoint) {
@@ -680,11 +745,19 @@ var DEFAULT_CONFIG = {
680
745
  degradedLatencyMs: 3e3
681
746
  };
682
747
  var HealthChecker = class {
683
- constructor(config, logger) {
748
+ constructor(config, logger, rateLimiter) {
684
749
  this.results = /* @__PURE__ */ new Map();
685
750
  this.highestSeqno = /* @__PURE__ */ new Map();
751
+ this.rateLimiter = null;
686
752
  this.config = { ...DEFAULT_CONFIG, ...config };
687
753
  this.logger = logger || consoleLogger2;
754
+ this.rateLimiter = rateLimiter || null;
755
+ }
756
+ /**
757
+ * Set rate limiter (can be set after construction)
758
+ */
759
+ setRateLimiter(rateLimiter) {
760
+ this.rateLimiter = rateLimiter;
688
761
  }
689
762
  /**
690
763
  * Test a single provider's health
@@ -704,15 +777,42 @@ var HealthChecker = class {
704
777
  };
705
778
  this.results.set(key, testingResult);
706
779
  try {
780
+ if (this.rateLimiter) {
781
+ const acquired = await this.rateLimiter.acquire(provider.id, this.config.timeoutMs);
782
+ if (!acquired) {
783
+ throw new Error("Rate limit timeout - unable to acquire token for health check");
784
+ }
785
+ }
707
786
  const endpoint = await this.getEndpoint(provider);
708
787
  if (!endpoint) {
709
788
  throw new Error("No valid endpoint available");
710
789
  }
711
- const normalizedEndpoint = normalizeV2Endpoint(endpoint);
712
- const info = await this.callGetMasterchainInfo(normalizedEndpoint);
790
+ if (provider.type === "tatum" && !provider.apiKey) {
791
+ throw new Error("Tatum provider requires API key (set TATUM_API_KEY_TESTNET or TATUM_API_KEY_MAINNET)");
792
+ }
793
+ let normalizedEndpoint = this.normalizeEndpointForProvider(provider, endpoint);
794
+ if (provider.type === "onfinality") {
795
+ this.logger.debug(`OnFinality endpoint: ${endpoint} -> ${normalizedEndpoint}, API key: ${provider.apiKey ? "set" : "not set"}`);
796
+ }
797
+ let info;
798
+ try {
799
+ info = await this.callGetMasterchainInfo(normalizedEndpoint, provider);
800
+ } catch (error) {
801
+ if (provider.type === "onfinality" && normalizedEndpoint.includes("/rpc") && provider.apiKey && error.message?.includes("backend error")) {
802
+ this.logger.debug(`OnFinality /rpc failed, retrying with /public endpoint`);
803
+ const publicEndpoint = normalizedEndpoint.replace("/rpc", "/public");
804
+ info = await this.callGetMasterchainInfo(publicEndpoint, { ...provider, apiKey: void 0 });
805
+ } else {
806
+ throw error;
807
+ }
808
+ }
713
809
  const endTime = performance.now();
714
810
  const latencyMs = Math.round(endTime - startTime);
715
- const seqno = info.last?.seqno || 0;
811
+ const infoWithLast = info;
812
+ const seqno = infoWithLast.last?.seqno;
813
+ if (!seqno || seqno <= 0 || !Number.isInteger(seqno)) {
814
+ throw new Error("Invalid seqno in response (must be positive integer)");
815
+ }
716
816
  const currentHighest = this.highestSeqno.get(provider.network) || 0;
717
817
  if (seqno > currentHighest) {
718
818
  this.highestSeqno.set(provider.network, seqno);
@@ -746,11 +846,14 @@ var HealthChecker = class {
746
846
  const errorMsg = error.message || String(error) || "Unknown error";
747
847
  const is429 = errorMsg.includes("429") || errorMsg.toLowerCase().includes("rate limit");
748
848
  const is404 = errorMsg.includes("404") || errorMsg.toLowerCase().includes("not found");
849
+ const is503 = errorMsg.includes("503") || errorMsg.toLowerCase().includes("service unavailable");
850
+ const is502 = errorMsg.includes("502") || errorMsg.toLowerCase().includes("bad gateway");
749
851
  const isTimeout = error.name === "AbortError" || errorMsg.includes("timeout");
852
+ const isOnFinalityBackendError = provider.type === "onfinality" && (errorMsg.includes("Backend error") || errorMsg.includes("backend error"));
750
853
  let status = "offline";
751
854
  if (is429) {
752
855
  status = "degraded";
753
- } else if (is404) {
856
+ } else if (is404 || is503 || is502 || isOnFinalityBackendError) {
754
857
  status = "offline";
755
858
  } else if (isTimeout) {
756
859
  status = "offline";
@@ -773,8 +876,11 @@ var HealthChecker = class {
773
876
  }
774
877
  /**
775
878
  * Test multiple providers in parallel with staggered batches
879
+ *
880
+ * @param batchSize - Number of providers to test in parallel (default: 2)
881
+ * @param batchDelayMs - Delay between batches in milliseconds (default: 500 to avoid rate limits)
776
882
  */
777
- async testProviders(providers, batchSize = 2, batchDelayMs = 300) {
883
+ async testProviders(providers, batchSize = 2, batchDelayMs = 500) {
778
884
  const results = [];
779
885
  for (let i = 0; i < providers.length; i += batchSize) {
780
886
  const batch = providers.slice(i, i + batchSize);
@@ -888,15 +994,37 @@ var HealthChecker = class {
888
994
  return provider.endpointV2 || provider.endpointV3 || null;
889
995
  }
890
996
  /**
891
- * Call getMasterchainInfo API
997
+ * Normalize endpoint for provider-specific requirements
998
+ *
999
+ * Note: normalizeV2Endpoint now handles all provider-specific cases correctly,
1000
+ * including Tatum (/jsonRPC), OnFinality (/public or /rpc), QuickNode, and GetBlock.
1001
+ */
1002
+ normalizeEndpointForProvider(provider, endpoint) {
1003
+ if (provider.type === "tatum" && endpoint.includes("api.tatum.io/v3/blockchain/node")) {
1004
+ const network = provider.network === "testnet" ? "testnet" : "mainnet";
1005
+ endpoint = `https://ton-${network}.gateway.tatum.io`;
1006
+ }
1007
+ return normalizeV2Endpoint(endpoint);
1008
+ }
1009
+ /**
1010
+ * Call getMasterchainInfo API with provider-specific handling
892
1011
  */
893
- async callGetMasterchainInfo(endpoint) {
1012
+ async callGetMasterchainInfo(endpoint, provider) {
894
1013
  const controller = new AbortController();
895
1014
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
1015
+ const headers = {
1016
+ "Content-Type": "application/json"
1017
+ };
1018
+ if (provider.type === "tatum" && provider.apiKey) {
1019
+ headers["x-api-key"] = provider.apiKey;
1020
+ }
1021
+ if (provider.type === "onfinality" && provider.apiKey) {
1022
+ headers["apikey"] = provider.apiKey;
1023
+ }
896
1024
  try {
897
1025
  const response = await fetch(endpoint, {
898
1026
  method: "POST",
899
- headers: { "Content-Type": "application/json" },
1027
+ headers,
900
1028
  body: JSON.stringify({
901
1029
  id: "1",
902
1030
  jsonrpc: "2.0",
@@ -906,20 +1034,63 @@ var HealthChecker = class {
906
1034
  signal: controller.signal
907
1035
  });
908
1036
  clearTimeout(timeoutId);
1037
+ const contentType = response.headers.get("content-type") || "";
1038
+ let text = null;
1039
+ let data;
1040
+ if (!contentType.includes("application/json")) {
1041
+ text = await response.text();
1042
+ this.logger.debug(`${provider.type} non-JSON response (${contentType}): ${text.substring(0, 200)}`);
1043
+ if (provider.type === "onfinality" && text.includes("Backend error")) {
1044
+ throw new Error(`OnFinality backend error: ${text}`);
1045
+ }
1046
+ throw new Error(`Invalid response type: expected JSON, got ${contentType}. Response: ${text.substring(0, 100)}`);
1047
+ }
909
1048
  if (!response.ok) {
910
- throw new Error(`HTTP ${response.status}`);
1049
+ try {
1050
+ data = await response.json();
1051
+ const errorObj = data;
1052
+ const errorMsg = typeof errorObj.error === "string" ? errorObj.error : errorObj.error?.message || `HTTP ${response.status}`;
1053
+ throw new Error(errorMsg);
1054
+ } catch {
1055
+ throw new Error(`HTTP ${response.status}`);
1056
+ }
911
1057
  }
912
- const data = await response.json();
913
- if (data && typeof data === "object" && "ok" in data) {
914
- if (!data.ok) {
915
- throw new Error(data.error || "API returned ok=false");
1058
+ data = await response.json();
1059
+ let info;
1060
+ if (data && typeof data === "object") {
1061
+ const dataObj = data;
1062
+ if ("ok" in dataObj) {
1063
+ if (!dataObj.ok) {
1064
+ const error = dataObj.error;
1065
+ throw new Error(error || "API returned ok=false");
1066
+ }
1067
+ const result = dataObj.result;
1068
+ info = result || dataObj;
1069
+ } else if ("result" in dataObj) {
1070
+ info = dataObj.result;
1071
+ } else if ("last" in dataObj || "@type" in dataObj) {
1072
+ info = dataObj;
1073
+ } else if ("error" in dataObj) {
1074
+ const errorObj = dataObj.error;
1075
+ const errorMsg = typeof errorObj === "string" ? errorObj : errorObj?.message || errorObj?.code || String(errorObj);
1076
+ throw new Error(`API error: ${errorMsg}`);
1077
+ } else {
1078
+ throw new Error(`Unknown response format from ${provider.type}`);
916
1079
  }
917
- return data.result || data;
1080
+ } else {
1081
+ throw new Error(`Invalid response type: ${typeof data}`);
918
1082
  }
919
- if (data.result) {
920
- return data.result;
1083
+ if (!info || typeof info !== "object") {
1084
+ this.logger.debug(`Invalid response structure from ${provider.type}: ${JSON.stringify(data)}`);
1085
+ throw new Error("Invalid response structure");
921
1086
  }
922
- return data;
1087
+ const infoObj = info;
1088
+ const seqno = infoObj.last?.seqno;
1089
+ if (seqno === void 0 || seqno === null || seqno <= 0 || !Number.isInteger(seqno)) {
1090
+ this.logger.debug(`Invalid seqno from ${provider.type}:`, { seqno, info });
1091
+ throw new Error(`Invalid seqno: ${seqno} (must be positive integer)`);
1092
+ }
1093
+ return info;
923
1094
  } catch (error) {
924
1095
  clearTimeout(timeoutId);
925
1096
  throw error;
@@ -929,8 +1100,8 @@ var HealthChecker = class {
929
1100
  return new Promise((resolve) => setTimeout(resolve, ms));
930
1101
  }
931
1102
  };
932
- function createHealthChecker(config, logger) {
933
- return new HealthChecker(config, logger);
1103
+ function createHealthChecker(config, logger, rateLimiter) {
1104
+ return new HealthChecker(config, logger, rateLimiter);
934
1105
  }
935
1106
 
936
1107
  // src/utils/timeout.ts
@@ -1402,7 +1573,7 @@ var ProviderSelector = class {
1402
1573
  if (cachedBestId) {
1403
1574
  const cached = this.registry.getProvider(cachedBestId);
1404
1575
  const health = this.healthChecker.getResult(cachedBestId, network);
1405
- if (cached && health && this.config.minStatus.includes(health.status)) {
1576
+ if (cached && health && health.success !== false && this.config.minStatus.includes(health.status)) {
1406
1577
  return cached;
1407
1578
  }
1408
1579
  }
@@ -1423,17 +1594,39 @@ var ProviderSelector = class {
1423
1594
  })).filter((item) => item.score > 0).sort((a, b) => b.score - a.score);
1424
1595
  if (scored.length === 0) {
1425
1596
  const defaults = this.registry.getDefaultOrderForNetwork(network);
1426
- if (defaults.length > 0) {
1427
- this.logger.warn(`No healthy providers for ${network}, using first default`);
1428
- return defaults[0];
1597
+ for (const defaultProvider of defaults) {
1598
+ const health = this.healthChecker.getResult(defaultProvider.id, network);
1599
+ if (!health || health.status === "untested" || health.success === true) {
1600
+ this.logger.warn(
1601
+ `No healthy providers for ${network}, using default: ${defaultProvider.id}`
1602
+ );
1603
+ return defaultProvider;
1604
+ }
1605
+ }
1606
+ for (const provider of providers) {
1607
+ const health = this.healthChecker.getResult(provider.id, network);
1608
+ if (!health || health.status === "untested") {
1609
+ this.logger.warn(
1610
+ `No tested healthy providers for ${network}, using untested: ${provider.id}`
1611
+ );
1612
+ return provider;
1613
+ }
1429
1614
  }
1430
- return providers[0];
1615
+ this.logger.error(`No available providers for ${network} (all tested and failed)`);
1616
+ return null;
1431
1617
  }
1432
1618
  const best = scored[0].provider;
1433
- this.bestProviderByNetwork.set(network, best.id);
1434
- this.logger.debug(
1435
- `Best provider for ${network}: ${best.id} (score: ${scored[0].score.toFixed(2)})`
1436
- );
1619
+ const bestHealth = this.healthChecker.getResult(best.id, network);
1620
+ if (bestHealth && bestHealth.success === true) {
1621
+ this.bestProviderByNetwork.set(network, best.id);
1622
+ this.logger.debug(
1623
+ `Best provider for ${network}: ${best.id} (score: ${scored[0].score.toFixed(2)})`
1624
+ );
1625
+ } else {
1626
+ this.logger.debug(
1627
+ `Best provider for ${network}: ${best.id} (score: ${scored[0].score.toFixed(2)}, untested)`
1628
+ );
1629
+ }
1437
1630
  return best;
1438
1631
  }
1439
1632
  /**
@@ -1469,7 +1662,7 @@ var ProviderSelector = class {
1469
1662
  scoreProvider(provider, network) {
1470
1663
  const health = this.healthChecker.getResult(provider.id, network);
1471
1664
  if (!health || health.status === "untested") {
1472
- return 0.1 * (1 / (provider.priority + 1));
1665
+ return 0.01 * (1 / (provider.priority + 1));
1473
1666
  }
1474
1667
  if (health.success === false) {
1475
1668
  return 0;
@@ -1699,28 +1892,29 @@ var _ProviderManager = class _ProviderManager {
1699
1892
  const config = await loadConfig();
1700
1893
  const mergedConfig = mergeWithDefaults(config);
1701
1894
  this.registry = new ProviderRegistry(mergedConfig, this.options.logger);
1895
+ this.rateLimiter = createRateLimiterManager(this.options.logger);
1896
+ for (const provider of this.registry.getAllProviders()) {
1897
+ const config2 = getRateLimitForType(provider.type);
1898
+ this.rateLimiter.setConfig(provider.id, {
1899
+ ...config2,
1900
+ rps: provider.rps,
1901
+ minDelayMs: Math.ceil(1e3 / provider.rps)
1902
+ });
1903
+ }
1702
1904
  this.healthChecker = createHealthChecker(
1703
1905
  {
1704
1906
  timeoutMs: this.options.requestTimeoutMs,
1705
1907
  maxBlocksBehind: this.options.maxBlocksBehind
1706
1908
  },
1707
- this.options.logger
1909
+ this.options.logger,
1910
+ this.rateLimiter
1708
1911
  );
1709
- this.rateLimiter = createRateLimiterManager(this.options.logger);
1710
1912
  this.selector = createSelector(
1711
1913
  this.registry,
1712
1914
  this.healthChecker,
1713
1915
  void 0,
1714
1916
  this.options.logger
1715
1917
  );
1716
- for (const provider of this.registry.getAllProviders()) {
1717
- const config2 = getRateLimitForType(provider.type);
1718
- this.rateLimiter.setConfig(provider.id, {
1719
- ...config2,
1720
- rps: provider.rps,
1721
- minDelayMs: Math.ceil(1e3 / provider.rps)
1722
- });
1723
- }
1724
1918
  this.initialized = true;
1725
1919
  this.notifyListeners();
1726
1920
  if (testProviders) {