@pioneer-platform/eth-network 8.41.19 → 8.41.20

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,5 @@
1
1
 
2
2
  
3
- > @pioneer-platform/eth-network@8.41.19 build /Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer/modules/coins/eth/eth-network
3
+ > @pioneer-platform/eth-network@8.41.20 build /Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer/modules/coins/eth/eth-network
4
4
  > tsc -p .
5
5
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @pioneer-platform/eth-network
2
2
 
3
+ ## 8.41.20
4
+
5
+ ### Patch Changes
6
+
7
+ - d1d2546: Production hotfix sweep landed via #28, #29, #30, #31, #32:
8
+
9
+ - `eth-network`: detect structurally-dead EVM RPCs via error-body signatures ("no longer available", "has been sunset", etc.) and quarantine for 24h instead of retrying every 5 min. Stop misclassifying transient network failures (ECONNRESET, timeout, 5xx wrapped as CALL_EXCEPTION) as contract-not-found — was reporting USDT as nonexistent on Ethereum.
10
+ - `evm-network`: pin `staticNetwork` on `JsonRpcProvider` and `destroy()` after use, so a failed detect doesn't spin up ethers v6's unbounded retry loop and leak a log-spamming async task per request.
11
+ - `pioneer-nodes`: filter known-dead providers (`*.public.blastapi.io`, `1rpc.io`) at `getWeb3Nodes()` boundary via regex.
12
+
13
+ - Updated dependencies [d1d2546]
14
+ - @pioneer-platform/pioneer-nodes@8.37.11
15
+ - @pioneer-platform/blockbook@8.38.20
16
+
3
17
  ## 8.41.19
4
18
 
5
19
  ### Patch Changes
package/lib/index.js CHANGED
@@ -104,6 +104,50 @@ let NODES = [];
104
104
  // Dead node tracker - nodes that failed recently (service URL -> { timestamp, ttl })
105
105
  const DEAD_NODES = new Map();
106
106
  const DEAD_NODE_TTL_MS = parseInt(process.env.ETH_DEAD_NODE_TTL || '300000'); // 5 minutes before retry
107
+ // Long quarantine TTL for nodes that return a structural-failure signature
108
+ // (provider says "discontinued" / "no longer available" / "sunset"). 24h so
109
+ // the dead TTL doesn't resurrect them every 5 minutes.
110
+ const STRUCTURAL_FAILURE_TTL_MS = 24 * 60 * 60 * 1000;
111
+ // Nodes we've already logged as "permanently" failing, so we don't spam
112
+ // the same WARN every time a caller tries to mark the same URL dead.
113
+ // Cleared on pod restart.
114
+ const LOGGED_STRUCTURAL_FAILURE = new Set();
115
+ /**
116
+ * Detect error responses that indicate the provider is structurally gone —
117
+ * the endpoint explicitly says it's discontinued, not that it's merely having
118
+ * a bad moment. Matched strings come straight from the JSON-RPC error body
119
+ * or axios message. When this returns true we quarantine for 24h on the
120
+ * first failure instead of burning a fresh request every 5 minutes.
121
+ *
122
+ * Examples we want to catch (case-insensitive):
123
+ * "Blast API is no longer available. Please update your integration..."
124
+ * "This service has been discontinued"
125
+ * "This endpoint has been sunset"
126
+ */
127
+ const isPermanentProviderFailure = (error) => {
128
+ const fragments = [];
129
+ const push = (v) => {
130
+ if (typeof v === 'string')
131
+ fragments.push(v);
132
+ };
133
+ push(error?.message);
134
+ push(error?.body);
135
+ push(error?.response?.body);
136
+ push(error?.response?.data);
137
+ push(error?.serverError?.message);
138
+ push(error?.error?.message);
139
+ push(error?.error?.body);
140
+ const haystack = fragments.join(' ').toLowerCase();
141
+ if (!haystack)
142
+ return false;
143
+ return (haystack.includes('no longer available') ||
144
+ haystack.includes('has been discontinued') ||
145
+ haystack.includes('has been sunset') ||
146
+ haystack.includes('has been retired') ||
147
+ haystack.includes('please update your integration') ||
148
+ haystack.includes('endpoint is deprecated') ||
149
+ haystack.includes('service is deprecated'));
150
+ };
107
151
  const RACE_BATCH_SIZE = parseInt(process.env.ETH_RACE_BATCH_SIZE || '1'); // Try 1 node at a time (SEQUENTIAL, not parallel racing)
108
152
  // NOTE: Changed from 5 to 1 to reduce API request load by 80%
109
153
  // Fast timeout (500ms) ensures quick fallback if node is slow/down
@@ -144,23 +188,50 @@ const isNodeDead = (nodeUrl) => {
144
188
  return true;
145
189
  };
146
190
  /**
147
- * Mark a node as dead with optional custom TTL
148
- * Only logs at WARN level for premium/reliable nodes, DEBUG for others
149
- * @param nodeUrl Node service URL
150
- * @param tier Node tier (for logging)
151
- * @param customTTL Optional custom TTL in ms (defaults to DEAD_NODE_TTL_MS)
191
+ * Mark a node as dead with a TTL chosen from these signals, in priority order:
192
+ * 1. Explicit customTTL (used by startup validation).
193
+ * 2. Structural-failure signature in the error body ("no longer available",
194
+ * "has been sunset", etc.) 24h quarantine and one-shot log per URL.
195
+ * 3. Default 5 min TTL.
196
+ *
197
+ * We intentionally do NOT adjust the TTL based on consecutive-failure counts
198
+ * from node-health metrics here — the token-balance and token-metadata hot
199
+ * paths don't currently call recordSuccess on their success branches, so the
200
+ * count only ever goes up for those, and intermittent token failures would
201
+ * ratchet otherwise-healthy nodes into longer and longer quarantines. If
202
+ * we want automatic escalation later, it needs symmetric success tracking.
152
203
  */
153
- const markNodeDead = (nodeUrl, tier, customTTL) => {
154
- // Use custom TTL if provided, otherwise use default
155
- const ttl = customTTL || DEAD_NODE_TTL_MS;
204
+ const markNodeDead = (nodeUrl, tier, customTTL, error) => {
205
+ let ttl = DEAD_NODE_TTL_MS;
206
+ let reason = 'default';
207
+ if (customTTL) {
208
+ ttl = customTTL;
209
+ reason = 'explicit';
210
+ }
211
+ else if (error && isPermanentProviderFailure(error)) {
212
+ ttl = STRUCTURAL_FAILURE_TTL_MS;
213
+ reason = 'structural';
214
+ }
156
215
  DEAD_NODES.set(nodeUrl, { timestamp: Date.now(), ttl });
157
- // Only log failures for premium/reliable nodes (reduces spam)
216
+ const logTag = TAG + ' | markNodeDead | ';
158
217
  const ttlMinutes = Math.round(ttl / 1000 / 60);
218
+ const label = tier ?? 'public';
219
+ const urlShort = nodeUrl.substring(0, 50);
220
+ // One-shot log for structural failures so we don't repeat the WARN every
221
+ // time a different caller re-discovers that a known-dead provider is dead.
222
+ // Reset on pod restart, so it re-appears after redeploy.
223
+ if (reason === 'structural') {
224
+ if (!LOGGED_STRUCTURAL_FAILURE.has(nodeUrl)) {
225
+ LOGGED_STRUCTURAL_FAILURE.add(nodeUrl);
226
+ log.warn(logTag, `🛑 ${label} node quarantined (structural failure): ${urlShort}... dead for ${ttlMinutes}min`);
227
+ }
228
+ return;
229
+ }
159
230
  if (tier === 'premium' || tier === 'reliable') {
160
- log.warn(TAG + ' | markNodeDead | ', `⚠️ ${tier} node failed: ${nodeUrl.substring(0, 50)}... (dead for ${ttlMinutes}min)`);
231
+ log.warn(logTag, `⚠️ ${label} node failed: ${urlShort}... (dead for ${ttlMinutes}min)`);
161
232
  }
162
233
  else {
163
- log.debug(TAG + ' | markNodeDead | ', `Public/untrusted node failed: ${nodeUrl.substring(0, 50)}... (dead for ${ttlMinutes}min)`);
234
+ log.debug(logTag, `Public/untrusted node failed: ${urlShort}... (dead for ${ttlMinutes}min)`);
164
235
  }
165
236
  };
166
237
  /**
@@ -879,7 +950,7 @@ const getBalanceAddressByNetwork = async function (networkId, address) {
879
950
  e.code === 'CERT_HAS_EXPIRED' ||
880
951
  e.code === 'SELF_SIGNED_CERT_IN_CHAIN');
881
952
  if (!isValidationError && !isSSLError) {
882
- markNodeDead(node.service, node.tier);
953
+ markNodeDead(node.service, node.tier, undefined, e);
883
954
  await NodeHealth.recordFailure(node.service, node.networkId);
884
955
  }
885
956
  else if (isValidationError) {
@@ -957,6 +1028,14 @@ const getBalanceTokenByNetwork = async function (networkId, address, tokenAddres
957
1028
  catch (e) {
958
1029
  // Check error type before marking node dead
959
1030
  log.warn(tag, `❌ Node ${batchStartIndex + index + 1}/${nodes.length} failed: ${node.service.substring(0, 50)} - ${e.message}`);
1031
+ // FAIL FAST: Contract doesn't exist on this chain — no point trying other nodes
1032
+ if (isContractNotFoundError(e)) {
1033
+ log.error(tag, `Contract not found on ${networkId} (CALL_EXCEPTION with empty data)`);
1034
+ const err = new Error(`Contract not found on network ${networkId}. The token may not be deployed on this chain.`);
1035
+ err.code = 'CONTRACT_NOT_FOUND';
1036
+ err.statusCode = 404;
1037
+ throw err;
1038
+ }
960
1039
  // Don't mark nodes dead for validation or SSL errors
961
1040
  const isValidationError = e.message && (e.message.includes('invalid address') ||
962
1041
  e.message.includes('INVALID_ARGUMENT') ||
@@ -969,7 +1048,7 @@ const getBalanceTokenByNetwork = async function (networkId, address, tokenAddres
969
1048
  e.code === 'CERT_HAS_EXPIRED' ||
970
1049
  e.code === 'SELF_SIGNED_CERT_IN_CHAIN');
971
1050
  if (!isValidationError && !isSSLError) {
972
- markNodeDead(node.service, node.tier);
1051
+ markNodeDead(node.service, node.tier, undefined, e);
973
1052
  }
974
1053
  else if (isValidationError) {
975
1054
  log.warn(tag, `Skipping node death marking - validation error`);
@@ -982,6 +1061,11 @@ const getBalanceTokenByNetwork = async function (networkId, address, tokenAddres
982
1061
  });
983
1062
  // Use allSettled to let all nodes complete, return first success
984
1063
  const results = await Promise.allSettled(racePromises);
1064
+ // FAIL FAST: If any node got CONTRACT_NOT_FOUND, re-throw immediately
1065
+ const contractNotFound = results.find(r => r.status === 'rejected' && r.reason?.code === 'CONTRACT_NOT_FOUND');
1066
+ if (contractNotFound && contractNotFound.status === 'rejected') {
1067
+ throw contractNotFound.reason;
1068
+ }
985
1069
  // Find first successful result
986
1070
  const successResult = results.find(r => r.status === 'fulfilled' && r.value.success);
987
1071
  if (successResult && successResult.status === 'fulfilled') {
@@ -1013,6 +1097,39 @@ const getBalanceTokensByNetwork = async function (networkId, address) {
1013
1097
  }
1014
1098
  };
1015
1099
  exports.getBalanceTokensByNetwork = getBalanceTokensByNetwork;
1100
+ /**
1101
+ * Check if an error indicates the contract doesn't exist on this chain.
1102
+ *
1103
+ * ethers v5 wraps any failure during an `eth_call` as `CALL_EXCEPTION` — including
1104
+ * transient network errors like ECONNRESET, timeouts, and 5xx responses, which
1105
+ * arrive with `data="0x"` too. A naive `code === 'CALL_EXCEPTION' && data === '0x'`
1106
+ * check therefore misclassifies network errors as "contract not found" and, when
1107
+ * two such failures happen in a row, causes e.g. USDT to be reported as non-existent
1108
+ * on Ethereum mainnet. See: https://links.ethers.org/v5-errors-CALL_EXCEPTION
1109
+ *
1110
+ * We only conclude "not found" when ethers actually saw a response from the node
1111
+ * (no wrapped SERVER_ERROR / TIMEOUT / NETWORK_ERROR, and no "missing response"/
1112
+ * "bad response" reason).
1113
+ */
1114
+ const isContractNotFoundError = (error) => {
1115
+ if (error?.code !== 'CALL_EXCEPTION')
1116
+ return false;
1117
+ // Wrapped transport failures — the RPC never returned an eth_call result.
1118
+ const innerCode = error?.error?.code;
1119
+ if (innerCode === 'SERVER_ERROR' || innerCode === 'TIMEOUT' || innerCode === 'NETWORK_ERROR') {
1120
+ return false;
1121
+ }
1122
+ const reason = typeof error?.reason === 'string' ? error.reason : '';
1123
+ if (reason === 'missing response' || reason === 'bad response' || reason === 'could not detect network') {
1124
+ return false;
1125
+ }
1126
+ // Our own withTimeout wrapper bubbles up as a plain Error with a timeout message.
1127
+ if (typeof error?.message === 'string' && error.message.includes('timeout after')) {
1128
+ return false;
1129
+ }
1130
+ // Genuine contract-not-found: eth_call returned "0x" with no revert data.
1131
+ return !error?.data || error?.data === '0x';
1132
+ };
1016
1133
  /**
1017
1134
  * Get ERC20 token decimals by network - CRITICAL for correct amount calculations
1018
1135
  */
@@ -1029,6 +1146,12 @@ const getTokenDecimalsByNetwork = async function (networkId, tokenAddress) {
1029
1146
  chainId = parseInt(networkId.split(':')[1]);
1030
1147
  }
1031
1148
  // Try each node until one succeeds
1149
+ // Require multiple nodes to agree before concluding contract doesn't exist,
1150
+ // because public RPCs can return CALL_EXCEPTION with empty data for rate limiting.
1151
+ // 3 is chosen to tolerate two correlated transient failures (e.g. 1rpc.io
1152
+ // and ankr hiccuping at the same time) without mislabelling real tokens.
1153
+ const MIN_CONTRACT_NOT_FOUND_CONFIRMATIONS = 3;
1154
+ let contractNotFoundCount = 0;
1032
1155
  let lastError = null;
1033
1156
  for (let i = 0; i < nodes.length; i++) {
1034
1157
  const node = nodes[i];
@@ -1046,11 +1169,31 @@ const getTokenDecimalsByNetwork = async function (networkId, tokenAddress) {
1046
1169
  }
1047
1170
  catch (e) {
1048
1171
  lastError = e;
1172
+ if (isContractNotFoundError(e)) {
1173
+ contractNotFoundCount++;
1174
+ log.warn(tag, `Node ${i + 1}/${nodes.length} returned CALL_EXCEPTION for ${tokenAddress} (${contractNotFoundCount}/${MIN_CONTRACT_NOT_FOUND_CONFIRMATIONS} confirmations needed)`);
1175
+ // Only throw CONTRACT_NOT_FOUND if multiple nodes agree
1176
+ if (contractNotFoundCount >= MIN_CONTRACT_NOT_FOUND_CONFIRMATIONS) {
1177
+ log.error(tag, `Contract ${tokenAddress} confirmed not found on ${networkId} by ${contractNotFoundCount} nodes.`);
1178
+ const err = new Error(`Contract ${tokenAddress} not found on network ${networkId}. The token may not be deployed on this chain.`);
1179
+ err.code = 'CONTRACT_NOT_FOUND';
1180
+ err.statusCode = 404;
1181
+ throw err;
1182
+ }
1183
+ // Otherwise fall through to try next node
1184
+ }
1049
1185
  log.warn(tag, `Node ${i + 1}/${nodes.length} failed (${node.service.substring(0, 50)}): ${e.message}`);
1050
1186
  // Continue to next node
1051
1187
  }
1052
1188
  }
1053
- // All nodes failed
1189
+ // All nodes failed — if every failure was CALL_EXCEPTION, it's likely genuinely not found
1190
+ if (contractNotFoundCount > 0 && contractNotFoundCount === nodes.length) {
1191
+ log.error(tag, `All ${nodes.length} nodes returned CALL_EXCEPTION for ${tokenAddress} on ${networkId}`);
1192
+ const err = new Error(`Contract ${tokenAddress} not found on network ${networkId}. The token may not be deployed on this chain.`);
1193
+ err.code = 'CONTRACT_NOT_FOUND';
1194
+ err.statusCode = 404;
1195
+ throw err;
1196
+ }
1054
1197
  log.error(tag, `All ${nodes.length} nodes failed for ${networkId}`);
1055
1198
  throw lastError || Error(`Failed to get token decimals from any node for ${networkId}`);
1056
1199
  };
@@ -1131,6 +1274,14 @@ const getTokenMetadata = async function (networkId, contractAddress, userAddress
1131
1274
  catch (e) {
1132
1275
  // Check error type before marking node dead
1133
1276
  log.warn(tag, `❌ Node ${batchStartIndex + index + 1}/${nodes.length} failed: ${node.service.substring(0, 50)} - ${e.message}`);
1277
+ // FAIL FAST: Contract doesn't exist on this chain — no point trying other nodes
1278
+ if (isContractNotFoundError(e)) {
1279
+ log.error(tag, `Contract not found on ${networkId} (CALL_EXCEPTION with empty data)`);
1280
+ const err = new Error(`Contract not found on network ${networkId}. The token may not be deployed on this chain.`);
1281
+ err.code = 'CONTRACT_NOT_FOUND';
1282
+ err.statusCode = 404;
1283
+ throw err;
1284
+ }
1134
1285
  // Don't mark nodes dead for validation or SSL errors
1135
1286
  const isValidationError = e.message && (e.message.includes('invalid address') ||
1136
1287
  e.message.includes('INVALID_ARGUMENT') ||
@@ -1143,7 +1294,7 @@ const getTokenMetadata = async function (networkId, contractAddress, userAddress
1143
1294
  e.code === 'CERT_HAS_EXPIRED' ||
1144
1295
  e.code === 'SELF_SIGNED_CERT_IN_CHAIN');
1145
1296
  if (!isValidationError && !isSSLError) {
1146
- markNodeDead(node.service, node.tier);
1297
+ markNodeDead(node.service, node.tier, undefined, e);
1147
1298
  }
1148
1299
  else if (isValidationError) {
1149
1300
  log.warn(tag, `Skipping node death marking - validation error`);
@@ -1156,6 +1307,11 @@ const getTokenMetadata = async function (networkId, contractAddress, userAddress
1156
1307
  });
1157
1308
  // Use allSettled to let all nodes complete, return first success
1158
1309
  const results = await Promise.allSettled(racePromises);
1310
+ // FAIL FAST: If any node got CONTRACT_NOT_FOUND, re-throw immediately
1311
+ const contractNotFound = results.find(r => r.status === 'rejected' && r.reason?.code === 'CONTRACT_NOT_FOUND');
1312
+ if (contractNotFound && contractNotFound.status === 'rejected') {
1313
+ throw contractNotFound.reason;
1314
+ }
1159
1315
  // Find first successful result
1160
1316
  const successResult = results.find(r => r.status === 'fulfilled' && r.value.success);
1161
1317
  if (successResult && successResult.status === 'fulfilled') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pioneer-platform/eth-network",
3
- "version": "8.41.19",
3
+ "version": "8.41.20",
4
4
  "main": "./lib/index.js",
5
5
  "types": "./lib/index.d.ts",
6
6
  "dependencies": {
@@ -17,10 +17,10 @@
17
17
  "ethers": "5.7.2",
18
18
  "request-promise": "^4.2.6",
19
19
  "wait-promise": "^0.4.1",
20
- "@pioneer-platform/blockbook": "8.38.19",
21
- "@pioneer-platform/pioneer-nodes": "8.37.10",
22
- "@pioneer-platform/pioneer-caip": "9.27.10",
23
- "@pioneer-platform/loggerdog": "8.11.0"
20
+ "@pioneer-platform/pioneer-nodes": "8.37.11",
21
+ "@pioneer-platform/loggerdog": "8.11.0",
22
+ "@pioneer-platform/blockbook": "8.38.20",
23
+ "@pioneer-platform/pioneer-caip": "9.27.10"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^18.16.0",