@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/index.js +171 -15
- package/package.json +5 -5
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @pioneer-platform/eth-network@8.41.
|
|
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
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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(
|
|
231
|
+
log.warn(logTag, `⚠️ ${label} node failed: ${urlShort}... (dead for ${ttlMinutes}min)`);
|
|
161
232
|
}
|
|
162
233
|
else {
|
|
163
|
-
log.debug(
|
|
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.
|
|
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/
|
|
21
|
-
"@pioneer-platform/
|
|
22
|
-
"@pioneer-platform/
|
|
23
|
-
"@pioneer-platform/
|
|
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",
|