@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.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/backends/across.d.ts +10 -0
- package/dist/backends/across.js +285 -0
- package/dist/backends/debridge.d.ts +11 -0
- package/dist/backends/debridge.js +380 -0
- package/dist/backends/lifi.d.ts +19 -0
- package/dist/backends/lifi.js +295 -0
- package/dist/backends/persistence.d.ts +86 -0
- package/dist/backends/persistence.js +642 -0
- package/dist/backends/relay.d.ts +11 -0
- package/dist/backends/relay.js +292 -0
- package/dist/backends/squid.d.ts +31 -0
- package/dist/backends/squid.js +476 -0
- package/dist/backends/types.d.ts +125 -0
- package/dist/backends/types.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +154 -0
- package/dist/routing/engine.d.ts +49 -0
- package/dist/routing/engine.js +336 -0
- package/dist/tools/check-status.d.ts +3 -0
- package/dist/tools/check-status.js +93 -0
- package/dist/tools/execute-bridge.d.ts +3 -0
- package/dist/tools/execute-bridge.js +428 -0
- package/dist/tools/get-chains.d.ts +3 -0
- package/dist/tools/get-chains.js +162 -0
- package/dist/tools/get-quote.d.ts +3 -0
- package/dist/tools/get-quote.js +534 -0
- package/dist/tools/get-tokens.d.ts +3 -0
- package/dist/tools/get-tokens.js +128 -0
- package/dist/tools/help.d.ts +2 -0
- package/dist/tools/help.js +204 -0
- package/dist/tools/multi-quote.d.ts +3 -0
- package/dist/tools/multi-quote.js +310 -0
- package/dist/tools/onboard.d.ts +3 -0
- package/dist/tools/onboard.js +218 -0
- package/dist/tools/wallet.d.ts +14 -0
- package/dist/tools/wallet.js +744 -0
- package/dist/tools/xprt-farm.d.ts +3 -0
- package/dist/tools/xprt-farm.js +1308 -0
- package/dist/tools/xprt-rewards.d.ts +2 -0
- package/dist/tools/xprt-rewards.js +177 -0
- package/dist/tools/xprt-staking.d.ts +2 -0
- package/dist/tools/xprt-staking.js +565 -0
- package/dist/utils/chains.d.ts +22 -0
- package/dist/utils/chains.js +154 -0
- package/dist/utils/circuit-breaker.d.ts +64 -0
- package/dist/utils/circuit-breaker.js +160 -0
- package/dist/utils/evm.d.ts +18 -0
- package/dist/utils/evm.js +46 -0
- package/dist/utils/fill-detector.d.ts +70 -0
- package/dist/utils/fill-detector.js +298 -0
- package/dist/utils/gas-estimator.d.ts +67 -0
- package/dist/utils/gas-estimator.js +340 -0
- package/dist/utils/sanitize-error.d.ts +23 -0
- package/dist/utils/sanitize-error.js +101 -0
- package/dist/utils/token-registry.d.ts +70 -0
- package/dist/utils/token-registry.js +669 -0
- package/dist/utils/tokens.d.ts +17 -0
- package/dist/utils/tokens.js +37 -0
- package/dist/utils/tx-simulator.d.ts +27 -0
- package/dist/utils/tx-simulator.js +105 -0
- 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
|
+
};
|