@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,298 @@
1
+ /**
2
+ * Multi-signal fill detector for cross-chain bridge fills.
3
+ *
4
+ * Public RPCs aggressively cache ALL RPC responses (eth_call, eth_getLogs)
5
+ * for 30-120s server-side. WebSocket transport does NOT bypass this.
6
+ *
7
+ * This module provides five detection methods:
8
+ *
9
+ * 1. **eth_subscribe("logs")** — push-based real-time subscription via raw
10
+ * WebSocket. The server pushes matching logs as new blocks are mined.
11
+ * NOT cached because each block is novel. Detects in 3-15s.
12
+ * Uses BROAD filter (token + Transfer topic only) with client-side
13
+ * recipient filtering, because some RPCs don't support topic[2] filtering
14
+ * in subscriptions. ONLY uses drpc (publicnode silently drops log events).
15
+ *
16
+ * 2. **newHeads + targeted getLogs** — subscribe to new block headers via WS,
17
+ * then do a getLogs for just that block number. Bypasses RPC caching
18
+ * because we query a specific (newly mined) block.
19
+ *
20
+ * 3. HTTP getLogs — rotated across RPCs, 30-120s (server-side cached)
21
+ * 4. HTTP balance check — rotated across RPCs, 30-120s (server-side cached)
22
+ * 5. Status API — Persistence backend (often broken)
23
+ *
24
+ * IMPORTANT: Create the FillWatcher BEFORE the initiate transaction to
25
+ * allow time for WebSocket connection + subscription establishment.
26
+ */
27
+ import { ethers } from "ethers";
28
+ import WebSocket from "ws";
29
+ import { getFreshProvider } from "./gas-estimator.js";
30
+ /** keccak256("Transfer(address,address,uint256)") */
31
+ const TRANSFER_TOPIC = ethers.id("Transfer(address,address,uint256)");
32
+ const ERC20_BALANCE_ABI = ["function balanceOf(address) view returns (uint256)"];
33
+ /** Timeout for each individual RPC call */
34
+ const RPC_CALL_TIMEOUT_MS = 6_000;
35
+ /**
36
+ * Chain ID → WebSocket RPC URLs for eth_subscribe("logs")
37
+ *
38
+ * NOTE: publicnode accepts eth_subscribe("logs") but SILENTLY DROPS events —
39
+ * it never pushes log notifications despite returning a valid subscription ID.
40
+ * Only drpc reliably delivers log push events.
41
+ */
42
+ const WS_RPCS_LOGS = {
43
+ 1: ["wss://eth.drpc.org"],
44
+ 56: ["wss://bsc.drpc.org"],
45
+ 8453: ["wss://base.drpc.org"],
46
+ 42161: ["wss://arbitrum.drpc.org"],
47
+ 10: ["wss://optimism.drpc.org"],
48
+ 137: ["wss://polygon.drpc.org"],
49
+ };
50
+ /** WS endpoints for newHeads (both publicnode and drpc work) */
51
+ const WS_RPCS_HEADS = {
52
+ 1: ["wss://ethereum-rpc.publicnode.com", "wss://eth.drpc.org"],
53
+ 56: ["wss://bsc-rpc.publicnode.com", "wss://bsc.drpc.org"],
54
+ 8453: ["wss://base-rpc.publicnode.com", "wss://base.drpc.org"],
55
+ 42161: ["wss://arbitrum-one-rpc.publicnode.com", "wss://arbitrum.drpc.org"],
56
+ 10: ["wss://optimism-rpc.publicnode.com", "wss://optimism.drpc.org"],
57
+ 137: ["wss://polygon-bor-rpc.publicnode.com", "wss://polygon.drpc.org"],
58
+ };
59
+ /**
60
+ * Create a push-based fill watcher using raw WebSocket eth_subscribe.
61
+ *
62
+ * Two strategies run in parallel:
63
+ * A) eth_subscribe("logs") on drpc — direct Transfer event push (fastest)
64
+ * B) eth_subscribe("newHeads") — on each new block, do a targeted
65
+ * getLogs(blockNum, blockNum) which bypasses RPC caching
66
+ *
67
+ * Call BEFORE signAndExecute to give connections time to establish (~2-5s).
68
+ * Check isDetected() in the polling loop — returns true as soon as a
69
+ * matching Transfer event is found by ANY method.
70
+ */
71
+ export function createFillWatcher(chainId, tokenAddress, walletAddress, onDetected) {
72
+ let detected = false;
73
+ const sockets = [];
74
+ let connected = 0;
75
+ const paddedWallet = ethers.zeroPadValue(walletAddress, 32).toLowerCase();
76
+ const markDetected = () => {
77
+ if (detected)
78
+ return;
79
+ detected = true;
80
+ if (onDetected)
81
+ onDetected();
82
+ };
83
+ // ─── Strategy A: Direct log subscription (drpc only) ───────────
84
+ const logUrls = WS_RPCS_LOGS[chainId] ?? [];
85
+ for (const url of logUrls) {
86
+ try {
87
+ const ws = new WebSocket(url);
88
+ sockets.push(ws);
89
+ ws.on("open", () => {
90
+ connected++;
91
+ console.error(`[fill-watcher] logs subscription connected: ${url}`);
92
+ ws.send(JSON.stringify({
93
+ jsonrpc: "2.0",
94
+ method: "eth_subscribe",
95
+ params: ["logs", { address: tokenAddress, topics: [TRANSFER_TOPIC] }],
96
+ id: 1,
97
+ }));
98
+ });
99
+ ws.on("message", (data) => {
100
+ if (detected)
101
+ return;
102
+ try {
103
+ const msg = JSON.parse(data.toString());
104
+ if (msg.method === "eth_subscription" && msg.params?.result?.topics) {
105
+ const topics = msg.params.result.topics;
106
+ if (topics[2]?.toLowerCase() === paddedWallet) {
107
+ markDetected();
108
+ }
109
+ }
110
+ }
111
+ catch { /* ignore */ }
112
+ });
113
+ ws.on("close", () => { connected = Math.max(0, connected - 1); });
114
+ ws.on("error", () => { });
115
+ }
116
+ catch { /* ignore */ }
117
+ }
118
+ // ─── Strategy B: newHeads + targeted getLogs ────────────────────
119
+ // On each new block, query that specific block for Transfer events to
120
+ // our wallet. Bypasses RPC caching because the block number is new.
121
+ const headUrls = WS_RPCS_HEADS[chainId] ?? [];
122
+ const headUrl = headUrls[0]; // one subscription is enough
123
+ if (headUrl) {
124
+ try {
125
+ const ws = new WebSocket(headUrl);
126
+ sockets.push(ws);
127
+ let subscribed = false;
128
+ ws.on("open", () => {
129
+ connected++;
130
+ console.error(`[fill-watcher] newHeads subscription connected: ${headUrl}`);
131
+ ws.send(JSON.stringify({
132
+ jsonrpc: "2.0",
133
+ method: "eth_subscribe",
134
+ params: ["newHeads"],
135
+ id: 2,
136
+ }));
137
+ });
138
+ ws.on("message", (data) => {
139
+ if (detected)
140
+ return;
141
+ try {
142
+ const msg = JSON.parse(data.toString());
143
+ if (msg.id === 2 && msg.result) {
144
+ subscribed = true;
145
+ return;
146
+ }
147
+ if (subscribed && msg.method === "eth_subscription" && msg.params?.result?.number) {
148
+ const blockNum = parseInt(msg.params.result.number, 16);
149
+ // Fire-and-forget: check this specific block for our Transfer
150
+ checkSingleBlockForTransfer(chainId, tokenAddress, paddedWallet, blockNum)
151
+ .then(found => { if (found)
152
+ markDetected(); })
153
+ .catch(() => { });
154
+ }
155
+ }
156
+ catch { /* ignore */ }
157
+ });
158
+ ws.on("close", () => { connected = Math.max(0, connected - 1); });
159
+ ws.on("error", () => { });
160
+ }
161
+ catch { /* ignore */ }
162
+ }
163
+ const cleanup = () => {
164
+ for (const ws of sockets) {
165
+ try {
166
+ ws.close();
167
+ }
168
+ catch { /* ignore */ }
169
+ }
170
+ };
171
+ const watcher = {
172
+ isDetected: () => detected,
173
+ connectedCount: () => connected,
174
+ cleanup: () => {
175
+ cleanup();
176
+ _activeWatchers.delete(watcher);
177
+ },
178
+ };
179
+ _activeWatchers.add(watcher);
180
+ return watcher;
181
+ }
182
+ // Track active watchers for process-exit cleanup
183
+ const _activeWatchers = new Set();
184
+ process.on("exit", () => { for (const w of _activeWatchers) {
185
+ try {
186
+ w.cleanup();
187
+ }
188
+ catch { }
189
+ } });
190
+ /**
191
+ * Check a single specific block for Transfer events to our wallet.
192
+ * Uses a fresh HTTP provider — since the block is brand new, this
193
+ * bypasses server-side RPC caching.
194
+ */
195
+ async function checkSingleBlockForTransfer(chainId, tokenAddress, paddedWallet, blockNumber) {
196
+ const provider = getFreshProvider(chainId, blockNumber % 10);
197
+ if (!provider)
198
+ return false;
199
+ try {
200
+ const logs = await Promise.race([
201
+ provider.getLogs({
202
+ address: tokenAddress,
203
+ topics: [TRANSFER_TOPIC, null, paddedWallet],
204
+ fromBlock: blockNumber,
205
+ toBlock: blockNumber,
206
+ }),
207
+ rejectAfter(3_000, "single-block getLogs timeout"),
208
+ ]);
209
+ return logs.length > 0;
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ finally {
215
+ provider.destroy();
216
+ }
217
+ }
218
+ /**
219
+ * Check for fill via ERC20 Transfer event logs over HTTP.
220
+ * Uses fresh providers rotated across RPCs. Subject to server-side
221
+ * caching (30-120s), kept as a fallback for when WS subscription fails.
222
+ */
223
+ export async function checkTransferEvents(chainId, tokenAddress, walletAddress, fromBlock, rpcIndex) {
224
+ const provider = getFreshProvider(chainId, rpcIndex);
225
+ if (!provider)
226
+ return { found: false };
227
+ try {
228
+ const paddedAddress = ethers.zeroPadValue(walletAddress, 32);
229
+ const [logs, currentBlock] = await Promise.all([
230
+ Promise.race([
231
+ provider.getLogs({
232
+ address: tokenAddress,
233
+ topics: [TRANSFER_TOPIC, null, paddedAddress],
234
+ fromBlock,
235
+ toBlock: "latest",
236
+ }),
237
+ rejectAfter(RPC_CALL_TIMEOUT_MS, "getLogs timeout"),
238
+ ]),
239
+ Promise.race([
240
+ provider.getBlockNumber(),
241
+ rejectAfter(RPC_CALL_TIMEOUT_MS, "getBlockNumber timeout"),
242
+ ]).catch(() => fromBlock),
243
+ ]);
244
+ return { found: logs.length > 0, latestBlock: currentBlock };
245
+ }
246
+ catch {
247
+ return { found: false };
248
+ }
249
+ finally {
250
+ provider.destroy();
251
+ }
252
+ }
253
+ // ─── HTTP balance check (fallback, server-side cached) ──────────────
254
+ /**
255
+ * Check for fill via balance change using a fresh (uncached) provider.
256
+ * Each call cycles through a different RPC via rpcIndex.
257
+ */
258
+ export async function checkBalanceChange(chainId, tokenAddress, walletAddress, preBalance, rpcIndex) {
259
+ const provider = getFreshProvider(chainId, rpcIndex);
260
+ if (!provider)
261
+ return { changed: false, newBalance: preBalance };
262
+ try {
263
+ const contract = new ethers.Contract(tokenAddress, ERC20_BALANCE_ABI, provider);
264
+ const bal = await Promise.race([
265
+ contract.balanceOf(walletAddress),
266
+ rejectAfter(RPC_CALL_TIMEOUT_MS, "balanceOf timeout"),
267
+ ]);
268
+ return { changed: bal > preBalance, newBalance: bal };
269
+ }
270
+ catch {
271
+ return { changed: false, newBalance: preBalance };
272
+ }
273
+ finally {
274
+ provider.destroy();
275
+ }
276
+ }
277
+ // ─── Utilities ──────────────────────────────────────────────────────
278
+ /**
279
+ * Get the current block number on a chain using a fresh provider.
280
+ */
281
+ export async function getCurrentBlockNumber(chainId) {
282
+ const provider = getFreshProvider(chainId, 0);
283
+ if (!provider)
284
+ throw new Error(`No RPC configured for chain ${chainId}`);
285
+ try {
286
+ return await Promise.race([
287
+ provider.getBlockNumber(),
288
+ rejectAfter(RPC_CALL_TIMEOUT_MS, "getBlockNumber timeout"),
289
+ ]);
290
+ }
291
+ finally {
292
+ provider.destroy();
293
+ }
294
+ }
295
+ /** Helper: reject a promise after ms with the given message */
296
+ function rejectAfter(ms, message) {
297
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms));
298
+ }
@@ -0,0 +1,67 @@
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
+ /** Resolve RPC URLs for a chain: env var override → default RPCs with failover */
19
+ export declare function getChainRpcUrls(chainId: number): string[];
20
+ /** Resolve primary RPC URL for a chain (backward compat) */
21
+ export declare function getChainRpcUrl(chainId: number): string | undefined;
22
+ /**
23
+ * Get a fresh (uncached) JsonRpcProvider for a specific RPC index.
24
+ * Used by the fill-detector polling loop to rotate across RPCs and avoid
25
+ * hitting the same stale eth_call cache on every poll.
26
+ *
27
+ * Unlike getProvider(), this:
28
+ * - Does NOT use the provider cache
29
+ * - Does NOT validate the provider (caller handles errors)
30
+ * - Creates a new provider instance each call
31
+ * - Caller MUST call provider.destroy() when done to prevent zombie retries
32
+ */
33
+ export declare function getFreshProvider(chainId: number, index: number): ethers.JsonRpcProvider | null;
34
+ /** Get the number of available RPC endpoints for a chain. */
35
+ export declare function getRpcCount(chainId: number): number;
36
+ /**
37
+ * Get a working JsonRpcProvider for a chain, trying multiple RPCs with failover.
38
+ * Caches the working provider for 60s to avoid repeated connection overhead.
39
+ * Destroys failed providers to prevent ethers.js background retry loops.
40
+ * Throws if no RPC can be reached.
41
+ */
42
+ export declare function getProvider(chainId: number): Promise<ethers.JsonRpcProvider>;
43
+ /**
44
+ * Get estimated gas units for a backend's source chain transaction.
45
+ * Returns null if we can't reliably estimate (unknown backend or chain).
46
+ *
47
+ * These are estimates for the USER's on-chain transaction:
48
+ * - deBridge: createOrder (~65k on L2/BSC, ~150k on ETH mainnet)
49
+ * - Across: depositV3 (~65k on L2/BSC, ~120k on ETH mainnet)
50
+ * - Persistence: approve + escrow (~80k, only Base/BSC)
51
+ */
52
+ export declare function getGasUnits(backend: string, chainId: number): number | null;
53
+ /** Result of gas cost estimation with staleness indicator */
54
+ export interface GasCostEstimate {
55
+ /** Estimated gas cost in USD */
56
+ costUsd: number;
57
+ /** True if fallback (hardcoded) prices were used instead of live data */
58
+ usingFallbackPrices: boolean;
59
+ }
60
+ /**
61
+ * Estimate gas cost in USD for a transaction on the given chain.
62
+ *
63
+ * @param chainId - EVM chain ID
64
+ * @param gasUnits - Estimated gas units for the transaction, or null if unknown
65
+ * @returns Estimated cost with staleness indicator, or null if we can't estimate
66
+ */
67
+ export declare function estimateGasCostUsd(chainId: number, gasUnits: number | null): Promise<GasCostEstimate | null>;