@persistenceone/bridgekitty 0.3.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/dist/backends/across.d.ts +10 -0
  4. package/dist/backends/across.js +285 -0
  5. package/dist/backends/debridge.d.ts +11 -0
  6. package/dist/backends/debridge.js +380 -0
  7. package/dist/backends/lifi.d.ts +19 -0
  8. package/dist/backends/lifi.js +295 -0
  9. package/dist/backends/persistence.d.ts +86 -0
  10. package/dist/backends/persistence.js +642 -0
  11. package/dist/backends/relay.d.ts +11 -0
  12. package/dist/backends/relay.js +292 -0
  13. package/dist/backends/squid.d.ts +31 -0
  14. package/dist/backends/squid.js +476 -0
  15. package/dist/backends/types.d.ts +125 -0
  16. package/dist/backends/types.js +11 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +154 -0
  19. package/dist/routing/engine.d.ts +49 -0
  20. package/dist/routing/engine.js +336 -0
  21. package/dist/tools/check-status.d.ts +3 -0
  22. package/dist/tools/check-status.js +93 -0
  23. package/dist/tools/execute-bridge.d.ts +3 -0
  24. package/dist/tools/execute-bridge.js +428 -0
  25. package/dist/tools/get-chains.d.ts +3 -0
  26. package/dist/tools/get-chains.js +162 -0
  27. package/dist/tools/get-quote.d.ts +3 -0
  28. package/dist/tools/get-quote.js +534 -0
  29. package/dist/tools/get-tokens.d.ts +3 -0
  30. package/dist/tools/get-tokens.js +128 -0
  31. package/dist/tools/help.d.ts +2 -0
  32. package/dist/tools/help.js +204 -0
  33. package/dist/tools/multi-quote.d.ts +3 -0
  34. package/dist/tools/multi-quote.js +310 -0
  35. package/dist/tools/onboard.d.ts +3 -0
  36. package/dist/tools/onboard.js +218 -0
  37. package/dist/tools/wallet.d.ts +14 -0
  38. package/dist/tools/wallet.js +744 -0
  39. package/dist/tools/xprt-farm.d.ts +3 -0
  40. package/dist/tools/xprt-farm.js +1308 -0
  41. package/dist/tools/xprt-rewards.d.ts +2 -0
  42. package/dist/tools/xprt-rewards.js +177 -0
  43. package/dist/tools/xprt-staking.d.ts +2 -0
  44. package/dist/tools/xprt-staking.js +565 -0
  45. package/dist/utils/chains.d.ts +22 -0
  46. package/dist/utils/chains.js +154 -0
  47. package/dist/utils/circuit-breaker.d.ts +64 -0
  48. package/dist/utils/circuit-breaker.js +160 -0
  49. package/dist/utils/evm.d.ts +18 -0
  50. package/dist/utils/evm.js +46 -0
  51. package/dist/utils/fill-detector.d.ts +70 -0
  52. package/dist/utils/fill-detector.js +298 -0
  53. package/dist/utils/gas-estimator.d.ts +67 -0
  54. package/dist/utils/gas-estimator.js +340 -0
  55. package/dist/utils/sanitize-error.d.ts +23 -0
  56. package/dist/utils/sanitize-error.js +101 -0
  57. package/dist/utils/token-registry.d.ts +70 -0
  58. package/dist/utils/token-registry.js +669 -0
  59. package/dist/utils/tokens.d.ts +17 -0
  60. package/dist/utils/tokens.js +37 -0
  61. package/dist/utils/tx-simulator.d.ts +27 -0
  62. package/dist/utils/tx-simulator.js +105 -0
  63. package/package.json +75 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Gas cost estimator for backends that don't provide gas fee estimates.
3
+ * Uses chain-aware gas unit estimates + live gas price + native token USD price.
4
+ *
5
+ * Also provides centralized RPC access with multi-endpoint failover:
6
+ * getChainRpcUrls(chainId) — list of RPC URLs (env override → public defaults)
7
+ * getChainRpcUrl(chainId) — first URL only (backward compat, no failover)
8
+ * getProvider(chainId) — JsonRpcProvider with automatic failover across all RPCs
9
+ *
10
+ * Strategy:
11
+ * 1. Chain-aware gas units per backend type (L1 vs L2 differentiation)
12
+ * 2. Live gas price via eth_gasPrice RPC (cached 30s)
13
+ * 3. Native token USD price via LI.FI /v1/token or hardcoded fallback (cached 5min)
14
+ * 4. Calculate: gasUnits × gasPrice × nativeTokenPriceUSD
15
+ * 5. If we can't reliably estimate → return null (displayed as "unknown")
16
+ */
17
+ import { ethers } from "ethers";
18
+ const GAS_PRICE_CACHE_TTL_MS = 30_000; // 30 seconds
19
+ const TOKEN_PRICE_CACHE_TTL_MS = 300_000; // 5 minutes
20
+ const RPC_TIMEOUT_MS = 5_000;
21
+ const LIFI_TIMEOUT_MS = 5_000;
22
+ // Chain ID → env var name mapping for RPC overrides
23
+ const CHAIN_RPC_ENV_KEYS = {
24
+ 1: "RPC_ETHEREUM",
25
+ 10: "RPC_OPTIMISM",
26
+ 56: "RPC_BSC",
27
+ 137: "RPC_POLYGON",
28
+ 42161: "RPC_ARBITRUM",
29
+ 43114: "RPC_AVALANCHE",
30
+ 8453: "RPC_BASE",
31
+ 59144: "RPC_LINEA",
32
+ 534352: "RPC_SCROLL",
33
+ 324: "RPC_ZKSYNC",
34
+ 5000: "RPC_MANTLE",
35
+ 81457: "RPC_BLAST",
36
+ };
37
+ // Default public RPCs per chain with failover (used when env var not set)
38
+ // Ordered by measured latency (fastest first). Sourced from chainlist.org.
39
+ // Base & BSC have extra endpoints since they're critical for XPRT farming.
40
+ const DEFAULT_CHAIN_RPCS = {
41
+ 1: ["https://eth.drpc.org", "https://1rpc.io/eth", "https://ethereum-rpc.publicnode.com"],
42
+ 10: ["https://optimism.drpc.org", "https://1rpc.io/op", "https://optimism-rpc.publicnode.com"],
43
+ 56: ["https://1rpc.io/bnb", "https://bsc.drpc.org", "https://bsc-rpc.publicnode.com", "https://bsc-dataseed1.defibit.io", "https://bsc-dataseed2.defibit.io"],
44
+ 137: ["https://polygon.drpc.org", "https://1rpc.io/matic", "https://polygon-bor-rpc.publicnode.com"],
45
+ 42161: ["https://arbitrum.drpc.org", "https://1rpc.io/arb", "https://arbitrum-one-rpc.publicnode.com"],
46
+ 43114: ["https://avax.drpc.org", "https://1rpc.io/avax/c", "https://avalanche-c-chain-rpc.publicnode.com"],
47
+ 8453: ["https://mainnet.base.org", "https://1rpc.io/base", "https://gateway.tenderly.co/public/base", "https://base-rpc.publicnode.com", "https://base.drpc.org"],
48
+ 59144: ["https://linea.drpc.org", "https://1rpc.io/linea", "https://linea-rpc.publicnode.com"],
49
+ 534352: ["https://scroll.drpc.org", "https://1rpc.io/scroll", "https://scroll-rpc.publicnode.com"],
50
+ 324: ["https://zksync.drpc.org", "https://1rpc.io/zksync2-era", "https://zksync-era-rpc.publicnode.com"],
51
+ 5000: ["https://mantle.drpc.org", "https://1rpc.io/mantle", "https://mantle-rpc.publicnode.com"],
52
+ 81457: ["https://blast.drpc.org", "https://blast-rpc.publicnode.com"],
53
+ };
54
+ /** M-3: Validate that an RPC URL uses HTTPS (except localhost) */
55
+ function validateRpcUrl(url) {
56
+ if (url.startsWith("https://"))
57
+ return url;
58
+ if (url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1"))
59
+ return url;
60
+ throw new Error(`Insecure RPC URL rejected: only HTTPS URLs are allowed (got ${url.split("/").slice(0, 3).join("/")})`);
61
+ }
62
+ /** Resolve RPC URLs for a chain: env var override → default RPCs with failover */
63
+ export function getChainRpcUrls(chainId) {
64
+ const envKey = CHAIN_RPC_ENV_KEYS[chainId];
65
+ if (envKey && process.env[envKey]) {
66
+ return [validateRpcUrl(process.env[envKey])];
67
+ }
68
+ const defaults = DEFAULT_CHAIN_RPCS[chainId];
69
+ return defaults ? defaults.map(validateRpcUrl) : [];
70
+ }
71
+ /** Resolve primary RPC URL for a chain (backward compat) */
72
+ export function getChainRpcUrl(chainId) {
73
+ const urls = getChainRpcUrls(chainId);
74
+ return urls.length > 0 ? urls[0] : undefined;
75
+ }
76
+ const PROVIDER_TIMEOUT_MS = 8_000;
77
+ const PROVIDER_CACHE_TTL_MS = 60_000; // Cache working providers for 60s
78
+ /**
79
+ * Get a fresh (uncached) JsonRpcProvider for a specific RPC index.
80
+ * Used by the fill-detector polling loop to rotate across RPCs and avoid
81
+ * hitting the same stale eth_call cache on every poll.
82
+ *
83
+ * Unlike getProvider(), this:
84
+ * - Does NOT use the provider cache
85
+ * - Does NOT validate the provider (caller handles errors)
86
+ * - Creates a new provider instance each call
87
+ * - Caller MUST call provider.destroy() when done to prevent zombie retries
88
+ */
89
+ export function getFreshProvider(chainId, index) {
90
+ const urls = getChainRpcUrls(chainId);
91
+ if (urls.length === 0)
92
+ return null;
93
+ return new ethers.JsonRpcProvider(urls[index % urls.length]);
94
+ }
95
+ /** Get the number of available RPC endpoints for a chain. */
96
+ export function getRpcCount(chainId) {
97
+ return getChainRpcUrls(chainId).length;
98
+ }
99
+ // Provider cache: avoids creating new JsonRpcProviders (and zombie retry loops) on every call
100
+ const providerCache = new Map();
101
+ /**
102
+ * Get a working JsonRpcProvider for a chain, trying multiple RPCs with failover.
103
+ * Caches the working provider for 60s to avoid repeated connection overhead.
104
+ * Destroys failed providers to prevent ethers.js background retry loops.
105
+ * Throws if no RPC can be reached.
106
+ */
107
+ export async function getProvider(chainId) {
108
+ // Return cached provider if still fresh
109
+ const cached = providerCache.get(chainId);
110
+ if (cached && Date.now() - cached.fetchedAt < PROVIDER_CACHE_TTL_MS) {
111
+ return cached.provider;
112
+ }
113
+ const urls = getChainRpcUrls(chainId);
114
+ if (urls.length === 0)
115
+ throw new Error(`No RPC configured for chain ${chainId}`);
116
+ let lastError = null;
117
+ for (const url of urls) {
118
+ const provider = new ethers.JsonRpcProvider(url);
119
+ try {
120
+ await Promise.race([
121
+ provider.getBlockNumber(),
122
+ new Promise((_, reject) => setTimeout(() => reject(new Error("RPC timeout")), PROVIDER_TIMEOUT_MS)),
123
+ ]);
124
+ // Cache the working provider
125
+ providerCache.set(chainId, { provider, fetchedAt: Date.now() });
126
+ return provider;
127
+ }
128
+ catch (err) {
129
+ lastError = err;
130
+ // Destroy failed provider to stop ethers.js internal retry loop
131
+ // (prevents "retry in 1s" messages flooding stderr indefinitely)
132
+ provider.destroy();
133
+ }
134
+ }
135
+ throw new Error(`All ${urls.length} RPCs failed for chain ${chainId}: ${lastError?.message}`);
136
+ }
137
+ // Native token address (used by LI.FI) — zero address for EVM chains
138
+ const NATIVE_TOKEN_ADDRESS = "0x0000000000000000000000000000000000000000";
139
+ // Fallback gas prices in gwei (conservative estimates if RPC fails)
140
+ const FALLBACK_GAS_PRICE_GWEI = {
141
+ 1: 30, // Ethereum mainnet
142
+ 10: 0.05, // Optimism (L2, very cheap)
143
+ 56: 3, // BSC
144
+ 137: 50, // Polygon
145
+ 42161: 0.1, // Arbitrum (L2)
146
+ 43114: 30, // Avalanche
147
+ 8453: 0.05, // Base (L2)
148
+ 59144: 0.1, // Linea (L2)
149
+ 534352: 0.1, // Scroll (L2)
150
+ 324: 0.25, // zkSync
151
+ 5000: 0.05, // Mantle (L2)
152
+ 81457: 0.05, // Blast (L2)
153
+ };
154
+ // L-4: Fallback native token USD prices — STALE DATA, used only when LI.FI API is unreachable.
155
+ // Last updated: 2026-02-26. These values drift significantly; live prices are always preferred.
156
+ const FALLBACK_NATIVE_PRICE_USD = {
157
+ 1: 1850, // ETH
158
+ 10: 1850, // ETH (Optimism)
159
+ 56: 600, // BNB
160
+ 137: 0.50, // MATIC/POL
161
+ 42161: 1850, // ETH (Arbitrum)
162
+ 43114: 35, // AVAX
163
+ 8453: 1850, // ETH (Base)
164
+ 59144: 1850, // ETH (Linea)
165
+ 534352: 1850, // ETH (Scroll)
166
+ 324: 1850, // ETH (zkSync)
167
+ 5000: 0.60, // MNT (Mantle)
168
+ 81457: 1850, // ETH (Blast)
169
+ };
170
+ // Ethereum mainnet chain ID
171
+ const ETH_MAINNET = 1;
172
+ // Caches
173
+ const gasPriceCache = new Map();
174
+ const nativePriceCache = new Map();
175
+ /**
176
+ * Get estimated gas units for a backend's source chain transaction.
177
+ * Returns null if we can't reliably estimate (unknown backend or chain).
178
+ *
179
+ * These are estimates for the USER's on-chain transaction:
180
+ * - deBridge: createOrder (~65k on L2/BSC, ~150k on ETH mainnet)
181
+ * - Across: depositV3 (~65k on L2/BSC, ~120k on ETH mainnet)
182
+ * - Persistence: approve + escrow (~80k, only Base/BSC)
183
+ */
184
+ export function getGasUnits(backend, chainId) {
185
+ switch (backend) {
186
+ case "debridge":
187
+ return chainId === ETH_MAINNET ? 150_000 : 65_000;
188
+ case "across":
189
+ return chainId === ETH_MAINNET ? 120_000 : 65_000;
190
+ case "relay":
191
+ return chainId === ETH_MAINNET ? 130_000 : 65_000;
192
+ case "skip":
193
+ return chainId === ETH_MAINNET ? 150_000 : 80_000;
194
+ case "persistence":
195
+ // Only supports Base (8453) and BSC (56)
196
+ if (chainId === 8453 || chainId === 56)
197
+ return 80_000;
198
+ return null;
199
+ default:
200
+ return null; // Unknown backend — can't estimate
201
+ }
202
+ }
203
+ /**
204
+ * Fetch gas price (in wei) from an EVM RPC node.
205
+ * Returns cached value if still fresh. Returns null on total failure
206
+ * for unknown chains (no RPC and no fallback).
207
+ */
208
+ async function getGasPriceWei(chainId) {
209
+ const now = Date.now();
210
+ // Check cache
211
+ const cached = gasPriceCache.get(chainId);
212
+ if (cached && now - cached.fetchedAt < GAS_PRICE_CACHE_TTL_MS) {
213
+ return { priceWei: cached.priceWei, isFallback: false };
214
+ }
215
+ const rpcUrls = getChainRpcUrls(chainId);
216
+ if (rpcUrls.length === 0) {
217
+ // No RPC configured — use fallback if available
218
+ const fallbackGwei = FALLBACK_GAS_PRICE_GWEI[chainId];
219
+ if (fallbackGwei === undefined)
220
+ return null; // Truly unknown chain
221
+ return { priceWei: BigInt(Math.round(fallbackGwei * 1e9)), isFallback: true };
222
+ }
223
+ // Try each RPC in order until one succeeds
224
+ let lastError = null;
225
+ for (const rpcUrl of rpcUrls) {
226
+ try {
227
+ const controller = new AbortController();
228
+ const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);
229
+ const res = await fetch(rpcUrl, {
230
+ method: "POST",
231
+ headers: { "Content-Type": "application/json" },
232
+ body: JSON.stringify({
233
+ jsonrpc: "2.0",
234
+ method: "eth_gasPrice",
235
+ params: [],
236
+ id: 1,
237
+ }),
238
+ signal: controller.signal,
239
+ });
240
+ clearTimeout(timer);
241
+ if (!res.ok)
242
+ throw new Error(`RPC ${res.status}`);
243
+ const data = await res.json();
244
+ const hexPrice = data.result;
245
+ if (!hexPrice)
246
+ throw new Error("No result from eth_gasPrice");
247
+ const priceWei = BigInt(hexPrice);
248
+ // Cache it
249
+ gasPriceCache.set(chainId, { priceWei, fetchedAt: now });
250
+ return { priceWei, isFallback: false };
251
+ }
252
+ catch (err) {
253
+ lastError = err;
254
+ // Try next RPC
255
+ }
256
+ }
257
+ console.error(`[gas-estimator] eth_gasPrice failed for chain ${chainId} (tried ${rpcUrls.length} RPCs):`, lastError?.message);
258
+ // All RPCs failed — use fallback
259
+ const fallbackGwei = FALLBACK_GAS_PRICE_GWEI[chainId];
260
+ if (fallbackGwei === undefined)
261
+ return null;
262
+ return { priceWei: BigInt(Math.round(fallbackGwei * 1e9)), isFallback: true };
263
+ }
264
+ /**
265
+ * Fetch native token USD price via LI.FI /v1/token endpoint.
266
+ * Falls back to hardcoded prices on failure. Returns null for unknown chains.
267
+ * Returns { priceUsd, isFallback } to indicate data freshness.
268
+ */
269
+ async function getNativeTokenPriceUsd(chainId) {
270
+ const now = Date.now();
271
+ // Check cache
272
+ const cached = nativePriceCache.get(chainId);
273
+ if (cached && now - cached.fetchedAt < TOKEN_PRICE_CACHE_TTL_MS) {
274
+ return { priceUsd: cached.priceUsd, isFallback: false };
275
+ }
276
+ try {
277
+ const controller = new AbortController();
278
+ const timer = setTimeout(() => controller.abort(), LIFI_TIMEOUT_MS);
279
+ const url = `https://li.quest/v1/token?chain=${chainId}&token=${NATIVE_TOKEN_ADDRESS}`;
280
+ const res = await fetch(url, { signal: controller.signal });
281
+ clearTimeout(timer);
282
+ if (!res.ok)
283
+ throw new Error(`LI.FI token ${res.status}`);
284
+ const data = await res.json();
285
+ const priceUsd = data.priceUSD ? parseFloat(data.priceUSD) : 0;
286
+ if (priceUsd > 0) {
287
+ nativePriceCache.set(chainId, { priceUsd, fetchedAt: now });
288
+ return { priceUsd, isFallback: false };
289
+ }
290
+ throw new Error("No price returned");
291
+ }
292
+ catch (err) {
293
+ console.error(`[gas-estimator] LI.FI token price failed for chain ${chainId}:`, err.message);
294
+ // Fallback to hardcoded
295
+ const fallback = FALLBACK_NATIVE_PRICE_USD[chainId];
296
+ if (fallback === undefined)
297
+ return null; // Truly unknown chain
298
+ // Still cache the fallback briefly to avoid hammering a failing endpoint
299
+ nativePriceCache.set(chainId, { priceUsd: fallback, fetchedAt: now });
300
+ return { priceUsd: fallback, isFallback: true };
301
+ }
302
+ }
303
+ /**
304
+ * Estimate gas cost in USD for a transaction on the given chain.
305
+ *
306
+ * @param chainId - EVM chain ID
307
+ * @param gasUnits - Estimated gas units for the transaction, or null if unknown
308
+ * @returns Estimated cost with staleness indicator, or null if we can't estimate
309
+ */
310
+ export async function estimateGasCostUsd(chainId, gasUnits) {
311
+ // If gas units are unknown, we can't estimate
312
+ if (gasUnits === null)
313
+ return null;
314
+ try {
315
+ const [gasPriceResult, nativePriceResult] = await Promise.all([
316
+ getGasPriceWei(chainId),
317
+ getNativeTokenPriceUsd(chainId),
318
+ ]);
319
+ // If either lookup failed completely, return null
320
+ if (gasPriceResult === null || nativePriceResult === null)
321
+ return null;
322
+ const usingFallbackPrices = gasPriceResult.isFallback || nativePriceResult.isFallback;
323
+ // gasCost in ETH = gasUnits * gasPriceWei / 1e18
324
+ // gasCost in USD = gasCost in ETH * nativePriceUsd
325
+ const gasCostWei = BigInt(gasUnits) * gasPriceResult.priceWei;
326
+ // (NEW-LOW-001) Divide in BigInt first to keep intermediate values within safe Number range
327
+ const gasCostEth = Number(gasCostWei / 10n ** 9n) / 1e9;
328
+ const gasCostUsd = gasCostEth * nativePriceResult.priceUsd;
329
+ // Round to 2 decimal places
330
+ return {
331
+ costUsd: Math.round(gasCostUsd * 100) / 100,
332
+ usingFallbackPrices,
333
+ };
334
+ }
335
+ catch (err) {
336
+ console.error(`[gas-estimator] estimation failed for chain ${chainId}:`, err.message);
337
+ // Can't estimate — return null instead of a misleading 0
338
+ return null;
339
+ }
340
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Structured error format returned to agents.
3
+ * Agents can switch on errorType for programmatic handling.
4
+ */
5
+ export interface BridgeKittyError {
6
+ errorType: string;
7
+ message: string;
8
+ chain?: string;
9
+ retryAfterSeconds?: number;
10
+ rpcProvider?: string;
11
+ rpcRetries?: number;
12
+ }
13
+ /**
14
+ * Detect and wrap upstream RPC rate-limit / tier-limit errors.
15
+ * Returns a structured BridgeKittyError if detected, null otherwise.
16
+ */
17
+ export declare function detectRpcError(err: Error, chainId?: number): BridgeKittyError | null;
18
+ /**
19
+ * M-1: Sanitize error messages before returning to MCP clients.
20
+ * Strips file paths, RPC URLs, and internal details.
21
+ * Never passes through raw upstream error messages.
22
+ */
23
+ export declare function sanitizeError(err: Error): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Detect and wrap upstream RPC rate-limit / tier-limit errors.
3
+ * Returns a structured BridgeKittyError if detected, null otherwise.
4
+ */
5
+ export function detectRpcError(err, chainId) {
6
+ const msg = err.message || "";
7
+ const lowerMsg = msg.toLowerCase();
8
+ // Rate limit detection patterns
9
+ if (lowerMsg.includes("rate limit") ||
10
+ lowerMsg.includes("too many requests") ||
11
+ lowerMsg.includes("429") ||
12
+ lowerMsg.includes("upgrade your tier") ||
13
+ lowerMsg.includes("please upgrade") ||
14
+ lowerMsg.includes("capacity exceeded") ||
15
+ lowerMsg.includes("request limit")) {
16
+ return {
17
+ errorType: "rpc_rate_limit",
18
+ message: "RPC rate limit reached. Retrying with a different provider.",
19
+ chain: chainId ? `chain ${chainId}` : undefined,
20
+ retryAfterSeconds: 5,
21
+ };
22
+ }
23
+ // Timeout detection
24
+ if (lowerMsg.includes("timeout") ||
25
+ lowerMsg.includes("timed out") ||
26
+ lowerMsg.includes("etimedout") ||
27
+ lowerMsg.includes("econnaborted")) {
28
+ return {
29
+ errorType: "rpc_timeout",
30
+ message: "RPC request timed out. Retrying with a different provider.",
31
+ chain: chainId ? `chain ${chainId}` : undefined,
32
+ retryAfterSeconds: 3,
33
+ };
34
+ }
35
+ // Connection refused / network errors
36
+ if (lowerMsg.includes("econnrefused") ||
37
+ lowerMsg.includes("enotfound") ||
38
+ lowerMsg.includes("network error") ||
39
+ lowerMsg.includes("fetch failed")) {
40
+ return {
41
+ errorType: "rpc_unavailable",
42
+ message: "RPC provider is unavailable. Trying alternative provider.",
43
+ chain: chainId ? `chain ${chainId}` : undefined,
44
+ retryAfterSeconds: 2,
45
+ };
46
+ }
47
+ // All RPCs exhausted
48
+ if (lowerMsg.includes("all rpcs failed") ||
49
+ lowerMsg.includes("no rpc available")) {
50
+ return {
51
+ errorType: "rpc_exhausted",
52
+ message: "All RPC providers failed. Please retry after a short delay.",
53
+ chain: chainId ? `chain ${chainId}` : undefined,
54
+ retryAfterSeconds: 15,
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * M-1: Sanitize error messages before returning to MCP clients.
61
+ * Strips file paths, RPC URLs, and internal details.
62
+ * Never passes through raw upstream error messages.
63
+ */
64
+ export function sanitizeError(err) {
65
+ let msg = err.message || "Unknown error";
66
+ // First check for known RPC error patterns and return clean message
67
+ const rpcError = detectRpcError(err);
68
+ if (rpcError) {
69
+ return rpcError.message;
70
+ }
71
+ // Strip file paths (Unix and Windows)
72
+ msg = msg.replace(/\/[\w./-]+\.(ts|js|json|env)/g, "[path]");
73
+ msg = msg.replace(/[A-Z]:\\[\w.\\-]+\.(ts|js|json|env)/gi, "[path]");
74
+ // Strip RPC/HTTP URLs (but keep domain for context)
75
+ msg = msg.replace(/https?:\/\/[^\s"',)]+/g, (url) => {
76
+ try {
77
+ const u = new URL(url);
78
+ return `[${u.hostname}]`;
79
+ }
80
+ catch {
81
+ return "[url]";
82
+ }
83
+ });
84
+ // Strip stack traces
85
+ msg = msg.replace(/\n\s+at\s+.*/g, "");
86
+ // Strip hex data dumps (long hex strings > 20 chars)
87
+ msg = msg.replace(/0x[a-fA-F0-9]{20,}/g, "[hex-data]");
88
+ // Strip bare hex private keys (64 hex chars not prefixed with 0x)
89
+ msg = msg.replace(/\b[a-fA-F0-9]{64}\b/g, "[key-redacted]");
90
+ // Strip mnemonic phrases (sequences of 12+ lowercase words that look like BIP-39)
91
+ msg = msg.replace(/\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/g, "[mnemonic-redacted]");
92
+ // Strip base58 strings (Solana private keys are 44-88 base58 chars)
93
+ msg = msg.replace(/[1-9A-HJ-NP-Za-km-z]{43,88}/g, "[key-redacted]");
94
+ // Strip raw upstream "upgrade your tier" messages
95
+ msg = msg.replace(/please upgrade[^.]*\./gi, "RPC provider rate limit reached.");
96
+ // Truncate overly long messages
97
+ if (msg.length > 300) {
98
+ msg = msg.slice(0, 297) + "...";
99
+ }
100
+ return msg;
101
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Verified Token Registry
3
+ *
4
+ * Security-first token symbol resolution. Only resolves to verified, canonical
5
+ * token addresses from official deployments. NEVER falls back to unverified tokens.
6
+ *
7
+ * Sources:
8
+ * - Circle's official USDC deployments
9
+ * - Tether's official USDT deployments
10
+ * - LI.FI's verified token list
11
+ * - CoinGecko verified contracts
12
+ * - Official bridge/wrapper contracts per chain
13
+ *
14
+ * If a token is not in this registry, resolution fails with a clear error.
15
+ * Agents can always pass raw 0x addresses to bypass the registry.
16
+ */
17
+ export interface VerifiedToken {
18
+ /** Canonical symbol (uppercase) */
19
+ symbol: string;
20
+ /** Human-readable name */
21
+ name: string;
22
+ /** Default token decimals */
23
+ decimals: number;
24
+ /** Per-chain decimal overrides (e.g. USDT is 18 decimals on BSC, 6 elsewhere) */
25
+ decimalOverrides?: Record<number, number>;
26
+ /** chainId → verified contract address */
27
+ addresses: Record<number, string>;
28
+ }
29
+ export type TokenResolveResult = {
30
+ ok: true;
31
+ address: string;
32
+ decimals: number;
33
+ symbol: string;
34
+ } | {
35
+ ok: false;
36
+ error: string;
37
+ };
38
+ export declare const VERIFIED_TOKENS: VerifiedToken[];
39
+ /**
40
+ * Resolve a token input (symbol or 0x address) to a verified address.
41
+ *
42
+ * Security rules:
43
+ * - If input is a 0x address: pass through unchanged (with known decimals if available)
44
+ * - If input is a symbol: ONLY resolve to verified canonical addresses
45
+ * - If symbol is unknown: return error with guidance
46
+ * - If symbol is ambiguous (multiple tokens): return error listing options
47
+ * - NEVER falls back to unverified tokens
48
+ */
49
+ export declare function resolveToken(input: string, chainId: number): TokenResolveResult;
50
+ /**
51
+ * Look up a verified token by address on a specific chain.
52
+ * Returns null if not in the registry (the token may still be valid, just not curated).
53
+ */
54
+ export declare function lookupByAddress(address: string, chainId: number): VerifiedToken | null;
55
+ /**
56
+ * Get all verified tokens available on a specific chain.
57
+ */
58
+ export declare function getVerifiedTokensForChain(chainId: number): Array<{
59
+ symbol: string;
60
+ name: string;
61
+ address: string;
62
+ decimals: number;
63
+ }>;
64
+ /**
65
+ * Get the total number of unique tokens in the registry.
66
+ */
67
+ export declare function getRegistryStats(): {
68
+ tokenCount: number;
69
+ chainCount: number;
70
+ };