@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { LiFiBackend } from "./backends/lifi.js";
|
|
5
|
+
import { PersistenceBackend } from "./backends/persistence.js";
|
|
6
|
+
import { DeBridgeBackend } from "./backends/debridge.js";
|
|
7
|
+
import { RelayBackend } from "./backends/relay.js";
|
|
8
|
+
import { AcrossBackend } from "./backends/across.js";
|
|
9
|
+
import { SquidBackend } from "./backends/squid.js";
|
|
10
|
+
import { RoutingEngine } from "./routing/engine.js";
|
|
11
|
+
import { CircuitBreaker } from "./utils/circuit-breaker.js";
|
|
12
|
+
import { registerGetQuote } from "./tools/get-quote.js";
|
|
13
|
+
import { registerExecuteBridge } from "./tools/execute-bridge.js";
|
|
14
|
+
import { registerCheckStatus } from "./tools/check-status.js";
|
|
15
|
+
import { registerGetChains } from "./tools/get-chains.js";
|
|
16
|
+
import { registerGetTokens } from "./tools/get-tokens.js";
|
|
17
|
+
import { registerXprtFarmTools } from "./tools/xprt-farm.js";
|
|
18
|
+
import { registerWalletTools, getKey, getConfigDir } from "./tools/wallet.js";
|
|
19
|
+
import { registerHelpTool } from "./tools/help.js";
|
|
20
|
+
import { registerXprtRewardsCheck } from "./tools/xprt-rewards.js";
|
|
21
|
+
import { registerMultiQuote } from "./tools/multi-quote.js";
|
|
22
|
+
import { registerOnboardTool } from "./tools/onboard.js";
|
|
23
|
+
import { registerXprtStakingTools } from "./tools/xprt-staking.js";
|
|
24
|
+
import * as fs from "fs";
|
|
25
|
+
import * as path from "path";
|
|
26
|
+
// Auto-load .env from stable config directory (~/.bridgekitty/ or BRIDGEKITTY_HOME)
|
|
27
|
+
function loadDotEnv() {
|
|
28
|
+
const envPath = path.resolve(getConfigDir(), ".env");
|
|
29
|
+
if (!fs.existsSync(envPath))
|
|
30
|
+
return;
|
|
31
|
+
// L-1: Warn if .env permissions are too permissive
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.statSync(envPath);
|
|
34
|
+
const mode = stat.mode & 0o777;
|
|
35
|
+
if (mode > 0o600) {
|
|
36
|
+
console.error(`⚠️ WARNING: .env file has permissive permissions (${mode.toString(8)}). ` +
|
|
37
|
+
`Recommended: chmod 600 .env (currently readable by group/others).`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { /* stat failed — non-fatal */ }
|
|
41
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
42
|
+
for (const line of content.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
45
|
+
continue;
|
|
46
|
+
const eqIdx = trimmed.indexOf("=");
|
|
47
|
+
if (eqIdx === -1)
|
|
48
|
+
continue;
|
|
49
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
50
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
51
|
+
if (!process.env[key]) {
|
|
52
|
+
process.env[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
loadDotEnv();
|
|
57
|
+
// Migration hint: if old CWD-based .env exists but config dir one doesn't, warn user
|
|
58
|
+
try {
|
|
59
|
+
const oldEnvPath = path.resolve(process.cwd(), ".env");
|
|
60
|
+
const newEnvPath = path.resolve(getConfigDir(), ".env");
|
|
61
|
+
if (oldEnvPath !== newEnvPath && fs.existsSync(oldEnvPath) && !fs.existsSync(newEnvPath)) {
|
|
62
|
+
const oldContent = fs.readFileSync(oldEnvPath, "utf-8");
|
|
63
|
+
if (oldContent.includes("PRIVATE_KEY")) {
|
|
64
|
+
console.error(`⚠️ Found .env with PRIVATE_KEY at ${oldEnvPath} (old CWD-based location). ` +
|
|
65
|
+
`BridgeKitty now uses ${path.resolve(getConfigDir(), ".env")}. ` +
|
|
66
|
+
`Move your .env: mv "${oldEnvPath}" "${newEnvPath}"`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { /* non-fatal */ }
|
|
71
|
+
// MEDIUM-002: Immediately move sensitive keys from process.env to in-memory store.
|
|
72
|
+
// loadDotEnv puts everything into process.env; calling getKey() moves them to the
|
|
73
|
+
// in-memory keyStore and deletes from process.env, minimizing the exposure window.
|
|
74
|
+
getKey("privateKey");
|
|
75
|
+
getKey("mnemonic");
|
|
76
|
+
getKey("solanaKey");
|
|
77
|
+
// ─── BridgeKitty fee configuration (hardcoded — not user-configurable) ────────
|
|
78
|
+
// These are the BridgeKitty project's integrator/affiliate addresses.
|
|
79
|
+
// Revenue from bridge fees funds ongoing development.
|
|
80
|
+
// Persistence Interop routes are always fee-free (direct protocol integration).
|
|
81
|
+
const BRIDGEKITTY_FEE_WALLET = "0xb24aCFcda187135490d81517ab56709FdDe6a81A";
|
|
82
|
+
const BRIDGEKITTY_DEBRIDGE_FEE = undefined; // disabled for now
|
|
83
|
+
const BRIDGEKITTY_LIFI_FEE = undefined; // needs portal.li.fi registration first
|
|
84
|
+
const BRIDGEKITTY_LIFI_INTEGRATOR = undefined; // needs portal.li.fi registration first
|
|
85
|
+
const BRIDGEKITTY_RELAY_FEE = undefined; // disabled for now
|
|
86
|
+
function createEngine() {
|
|
87
|
+
const lifi = new LiFiBackend(process.env.LIFI_API_KEY, BRIDGEKITTY_LIFI_INTEGRATOR, BRIDGEKITTY_LIFI_FEE);
|
|
88
|
+
const persistence = new PersistenceBackend();
|
|
89
|
+
const debridge = new DeBridgeBackend(BRIDGEKITTY_DEBRIDGE_FEE, BRIDGEKITTY_FEE_WALLET);
|
|
90
|
+
const relay = new RelayBackend(BRIDGEKITTY_FEE_WALLET, BRIDGEKITTY_RELAY_FEE);
|
|
91
|
+
const across = new AcrossBackend(BRIDGEKITTY_FEE_WALLET);
|
|
92
|
+
const squid = new SquidBackend(process.env.SQUID_INTEGRATOR_ID);
|
|
93
|
+
const circuitBreaker = new CircuitBreaker();
|
|
94
|
+
return new RoutingEngine([lifi, persistence, debridge, relay, across, squid], circuitBreaker);
|
|
95
|
+
}
|
|
96
|
+
// Read version from package.json to avoid duplication
|
|
97
|
+
const PKG_VERSION = (() => {
|
|
98
|
+
try {
|
|
99
|
+
const pkgPath = new URL("../package.json", import.meta.url);
|
|
100
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
101
|
+
return pkg.version ?? "0.1.0";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "0.1.0";
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
async function main() {
|
|
108
|
+
// TTY detection: if run directly in a terminal (not piped), show help and exit.
|
|
109
|
+
// MCP servers communicate over stdio JSON-RPC — running in a TTY means the user
|
|
110
|
+
// probably ran `npx bridgekitty` directly instead of configuring it as an MCP server.
|
|
111
|
+
if (process.stdin.isTTY && !process.argv.includes("--stdio")) {
|
|
112
|
+
console.log(`BridgeKitty 🐱 v${PKG_VERSION} — Cross-chain bridge aggregator MCP server\n`);
|
|
113
|
+
console.log("This is an MCP (Model Context Protocol) server. Add it to your AI tool's config:\n");
|
|
114
|
+
console.log(" Claude Desktop / Claude Code:");
|
|
115
|
+
console.log(' { "mcpServers": { "bridgekitty": { "command": "npx", "args": ["bridgekitty"] } } }\n');
|
|
116
|
+
console.log(" Cursor:");
|
|
117
|
+
console.log(" Add to .cursor/mcp.json with the same format.\n");
|
|
118
|
+
console.log(" Direct (stdio):");
|
|
119
|
+
console.log(" npx bridgekitty --stdio\n");
|
|
120
|
+
console.log("Config: ~/.bridgekitty/.env (override with BRIDGEKITTY_HOME env var)");
|
|
121
|
+
console.log("Docs: https://github.com/persistenceOne/bridgekitty");
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
const engine = createEngine();
|
|
125
|
+
const server = new McpServer({
|
|
126
|
+
name: "bridgekitty",
|
|
127
|
+
version: PKG_VERSION,
|
|
128
|
+
});
|
|
129
|
+
registerGetQuote(server, engine);
|
|
130
|
+
registerExecuteBridge(server, engine);
|
|
131
|
+
registerCheckStatus(server, engine);
|
|
132
|
+
registerGetChains(server, engine);
|
|
133
|
+
registerGetTokens(server, engine);
|
|
134
|
+
registerWalletTools(server);
|
|
135
|
+
registerXprtFarmTools(server, engine);
|
|
136
|
+
registerXprtStakingTools(server);
|
|
137
|
+
registerHelpTool(server);
|
|
138
|
+
registerXprtRewardsCheck(server);
|
|
139
|
+
registerMultiQuote(server, engine);
|
|
140
|
+
registerOnboardTool(server, engine);
|
|
141
|
+
const transport = new StdioServerTransport();
|
|
142
|
+
await server.connect(transport);
|
|
143
|
+
console.error("BridgeKitty 🐱 MCP server running on stdio");
|
|
144
|
+
}
|
|
145
|
+
main().catch((err) => {
|
|
146
|
+
// Sanitize fatal errors to avoid leaking keys/paths in crash output
|
|
147
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
148
|
+
const safeMsg = msg
|
|
149
|
+
.replace(/\/[\w./-]+\.(ts|js|json|env)/g, "[path]")
|
|
150
|
+
.replace(/0x[a-fA-F0-9]{20,}/g, "[hex-data]")
|
|
151
|
+
.replace(/\b[a-fA-F0-9]{64}\b/g, "[key-redacted]");
|
|
152
|
+
console.error("Fatal error:", safeMsg);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { BridgeBackend, BridgeQuote, QuoteParams } from "../backends/types.js";
|
|
2
|
+
import { CircuitBreaker } from "../utils/circuit-breaker.js";
|
|
3
|
+
export interface CachedQuote extends BridgeQuote {
|
|
4
|
+
quoteId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface FailedProvider {
|
|
7
|
+
provider: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class RoutingEngine {
|
|
11
|
+
private backends;
|
|
12
|
+
private quoteCache;
|
|
13
|
+
private quoteResponseCache;
|
|
14
|
+
private circuitBreaker;
|
|
15
|
+
/** Track per-request backend outcomes for error differentiation */
|
|
16
|
+
private lastRequestErrors;
|
|
17
|
+
/** Track per-request backend failure reasons */
|
|
18
|
+
private lastFailedProviders;
|
|
19
|
+
constructor(backends: BridgeBackend[], circuitBreaker?: CircuitBreaker);
|
|
20
|
+
getQuotes(params: QuoteParams): Promise<CachedQuote[]>;
|
|
21
|
+
getCachedQuote(quoteId: string): BridgeQuote | null;
|
|
22
|
+
/**
|
|
23
|
+
* Get a cached quote even if it's expired. Returns { quote, expired } so callers
|
|
24
|
+
* can decide to auto-refresh. Does NOT delete expired entries.
|
|
25
|
+
*/
|
|
26
|
+
getCachedQuoteWithExpiry(quoteId: string): {
|
|
27
|
+
quote: BridgeQuote;
|
|
28
|
+
expired: boolean;
|
|
29
|
+
} | null;
|
|
30
|
+
getBackend(name: string): BridgeBackend | undefined;
|
|
31
|
+
getAllBackends(): BridgeBackend[];
|
|
32
|
+
/**
|
|
33
|
+
* Get the circuit breaker instance (for monitoring/testing).
|
|
34
|
+
*/
|
|
35
|
+
getCircuitBreaker(): CircuitBreaker;
|
|
36
|
+
/**
|
|
37
|
+
* Get the list of providers that failed or returned no results in the last request.
|
|
38
|
+
*/
|
|
39
|
+
getLastFailedProviders(): FailedProvider[];
|
|
40
|
+
/**
|
|
41
|
+
* Differentiate why no quotes were returned.
|
|
42
|
+
* Call after getQuotes returns empty to understand the cause.
|
|
43
|
+
*/
|
|
44
|
+
getLastRequestDiagnosis(): {
|
|
45
|
+
allErrored: boolean;
|
|
46
|
+
allEmpty: boolean;
|
|
47
|
+
circuitBroken: string[];
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { BackendValidationError } from "../backends/types.js";
|
|
3
|
+
import { isValidEvmAddress } from "../utils/evm.js";
|
|
4
|
+
import { CircuitBreaker } from "../utils/circuit-breaker.js";
|
|
5
|
+
import { getAllChains, isCosmosChain, isSolanaChain } from "../utils/chains.js";
|
|
6
|
+
/** Minimum buffer (ms) before a quote's expiry — quotes expiring within this window are filtered out. */
|
|
7
|
+
const EXPIRY_BUFFER_MS = 5_000;
|
|
8
|
+
/** Timeout for each backend quote request (ms). */
|
|
9
|
+
const BACKEND_TIMEOUT_MS = 10_000;
|
|
10
|
+
/** Quote response cache TTL (ms). Same params within this window return cached results. */
|
|
11
|
+
const QUOTE_CACHE_TTL_MS = 15_000;
|
|
12
|
+
/**
|
|
13
|
+
* Validate quote params before sending to any backend.
|
|
14
|
+
* Throws BackendValidationError for invalid inputs.
|
|
15
|
+
*/
|
|
16
|
+
function validateQuoteParams(params) {
|
|
17
|
+
// Chain IDs must be positive integers
|
|
18
|
+
if (!Number.isInteger(params.fromChainId) || params.fromChainId <= 0) {
|
|
19
|
+
throw new BackendValidationError(`Invalid source chain ID: ${params.fromChainId}. Must be a positive integer.`);
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isInteger(params.toChainId) || params.toChainId <= 0) {
|
|
22
|
+
throw new BackendValidationError(`Invalid destination chain ID: ${params.toChainId}. Must be a positive integer.`);
|
|
23
|
+
}
|
|
24
|
+
// Cannot bridge to same chain
|
|
25
|
+
if (params.fromChainId === params.toChainId) {
|
|
26
|
+
throw new BackendValidationError(`Source and destination chains are the same (${params.fromChainId}). Use a DEX for same-chain swaps.`);
|
|
27
|
+
}
|
|
28
|
+
// Amount must be positive
|
|
29
|
+
let amountBig;
|
|
30
|
+
try {
|
|
31
|
+
amountBig = BigInt(params.amountRaw);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new BackendValidationError(`Invalid amount: "${params.amountRaw}" is not a valid number.`);
|
|
35
|
+
}
|
|
36
|
+
if (amountBig <= 0n) {
|
|
37
|
+
throw new BackendValidationError(`Amount must be positive. Got: ${params.amountRaw}`);
|
|
38
|
+
}
|
|
39
|
+
// Determine if source/destination are non-EVM chains (relaxed address validation)
|
|
40
|
+
const fromIsCosmos = isCosmosChain(params.fromChainId);
|
|
41
|
+
const toIsCosmos = isCosmosChain(params.toChainId);
|
|
42
|
+
const fromIsSolana = isSolanaChain(params.fromChainId);
|
|
43
|
+
const toIsSolana = isSolanaChain(params.toChainId);
|
|
44
|
+
// Validate sender address based on source chain ecosystem
|
|
45
|
+
if (!fromIsCosmos && !fromIsSolana && !isValidEvmAddress(params.fromAddress)) {
|
|
46
|
+
throw new BackendValidationError(`Invalid sender address: "${params.fromAddress}". Expected 0x followed by 40 hex characters.`);
|
|
47
|
+
}
|
|
48
|
+
// Solana addresses are base58 encoded, 32-44 characters
|
|
49
|
+
if (fromIsSolana && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(params.fromAddress)) {
|
|
50
|
+
throw new BackendValidationError(`Invalid Solana sender address: "${params.fromAddress}". Expected base58-encoded address.`);
|
|
51
|
+
}
|
|
52
|
+
// Validate recipient address based on destination chain ecosystem
|
|
53
|
+
if (params.toAddress) {
|
|
54
|
+
if (!toIsCosmos && !toIsSolana && !isValidEvmAddress(params.toAddress)) {
|
|
55
|
+
throw new BackendValidationError(`Invalid recipient address: "${params.toAddress}". Expected 0x followed by 40 hex characters.`);
|
|
56
|
+
}
|
|
57
|
+
if (toIsSolana && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(params.toAddress)) {
|
|
58
|
+
throw new BackendValidationError(`Invalid Solana recipient address: "${params.toAddress}". Expected base58-encoded address.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Token addresses: must look like EVM addresses, Cosmos denoms, or Solana base58 mint addresses
|
|
62
|
+
const isValidTokenAddress = (addr, chainIsCosmos, chainIsSolana) => {
|
|
63
|
+
if (isValidEvmAddress(addr))
|
|
64
|
+
return true;
|
|
65
|
+
if (chainIsCosmos && /^[a-z][a-z0-9/]{1,128}$/.test(addr))
|
|
66
|
+
return true;
|
|
67
|
+
if (chainIsSolana && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr))
|
|
68
|
+
return true;
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
if (!isValidTokenAddress(params.fromTokenAddress, fromIsCosmos, fromIsSolana)) {
|
|
72
|
+
throw new BackendValidationError(`Invalid source token address: "${params.fromTokenAddress}". Provide a valid 0x address or a recognized token symbol.`);
|
|
73
|
+
}
|
|
74
|
+
if (!isValidTokenAddress(params.toTokenAddress, toIsCosmos, toIsSolana)) {
|
|
75
|
+
throw new BackendValidationError(`Invalid destination token address: "${params.toTokenAddress}". Provide a valid 0x address or a recognized token symbol.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a chain identifier for cache key consistency.
|
|
80
|
+
* Ensures "bsc", "56", "BSC" all produce the same cache key component.
|
|
81
|
+
* Since the engine already resolves chain IDs to numbers before reaching here,
|
|
82
|
+
* this is mainly for defensive normalization.
|
|
83
|
+
*/
|
|
84
|
+
function normalizeChainForCache(chainId) {
|
|
85
|
+
return String(chainId);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build a cache key from normalized quote params.
|
|
89
|
+
* All addresses are lowercased and chain IDs are stringified for consistency.
|
|
90
|
+
*/
|
|
91
|
+
function buildQuoteCacheKey(params) {
|
|
92
|
+
return [
|
|
93
|
+
normalizeChainForCache(params.fromChainId),
|
|
94
|
+
normalizeChainForCache(params.toChainId),
|
|
95
|
+
params.fromTokenAddress.toLowerCase(),
|
|
96
|
+
params.toTokenAddress.toLowerCase(),
|
|
97
|
+
params.amountRaw,
|
|
98
|
+
params.fromAddress.toLowerCase(),
|
|
99
|
+
params.preference,
|
|
100
|
+
].join(":");
|
|
101
|
+
}
|
|
102
|
+
export class RoutingEngine {
|
|
103
|
+
backends;
|
|
104
|
+
quoteCache = new Map();
|
|
105
|
+
quoteResponseCache = new Map();
|
|
106
|
+
circuitBreaker;
|
|
107
|
+
/** Track per-request backend outcomes for error differentiation */
|
|
108
|
+
lastRequestErrors = new Map();
|
|
109
|
+
/** Track per-request backend failure reasons */
|
|
110
|
+
lastFailedProviders = [];
|
|
111
|
+
constructor(backends, circuitBreaker) {
|
|
112
|
+
this.backends = backends;
|
|
113
|
+
this.circuitBreaker = circuitBreaker ?? new CircuitBreaker();
|
|
114
|
+
}
|
|
115
|
+
async getQuotes(params) {
|
|
116
|
+
// Validate inputs before calling any backend
|
|
117
|
+
validateQuoteParams(params);
|
|
118
|
+
// Validate chain IDs against known supported chains (NEW-LOW-003)
|
|
119
|
+
const supportedChains = getAllChains();
|
|
120
|
+
const supportedIds = supportedChains.map((c) => c.id);
|
|
121
|
+
const supportedList = supportedChains.map((c) => `${c.name} (${c.id})`).join(", ");
|
|
122
|
+
if (!supportedIds.includes(params.fromChainId)) {
|
|
123
|
+
throw new BackendValidationError(`Unsupported source chain ID: ${params.fromChainId}. Supported chains: ${supportedList}`);
|
|
124
|
+
}
|
|
125
|
+
if (!supportedIds.includes(params.toChainId)) {
|
|
126
|
+
throw new BackendValidationError(`Unsupported destination chain ID: ${params.toChainId}. Supported chains: ${supportedList}`);
|
|
127
|
+
}
|
|
128
|
+
// Check quote response cache
|
|
129
|
+
const cacheKey = buildQuoteCacheKey(params);
|
|
130
|
+
const cached = this.quoteResponseCache.get(cacheKey);
|
|
131
|
+
if (cached && Date.now() - cached.fetchedAt < QUOTE_CACHE_TTL_MS) {
|
|
132
|
+
// Return cached quotes that haven't expired
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const valid = cached.quotes.filter((q) => q.expiresAt > now + EXPIRY_BUFFER_MS);
|
|
135
|
+
if (valid.length > 0)
|
|
136
|
+
return valid;
|
|
137
|
+
}
|
|
138
|
+
// Reset error tracking for this request
|
|
139
|
+
this.lastRequestErrors.clear();
|
|
140
|
+
this.lastFailedProviders = [];
|
|
141
|
+
// Filter backends by providers filter (if specified)
|
|
142
|
+
let eligibleBackends = this.backends;
|
|
143
|
+
if (params.providers && params.providers.length > 0) {
|
|
144
|
+
const allowed = new Set(params.providers.map(p => p.toLowerCase()));
|
|
145
|
+
eligibleBackends = this.backends.filter((b) => allowed.has(b.name.toLowerCase()));
|
|
146
|
+
// Track filtered-out providers
|
|
147
|
+
for (const b of this.backends) {
|
|
148
|
+
if (!allowed.has(b.name.toLowerCase())) {
|
|
149
|
+
this.lastFailedProviders.push({ provider: b.name, reason: "filtered out by providers parameter" });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Filter out backends that don't support Solana chains
|
|
154
|
+
const involvesSolana = isSolanaChain(params.fromChainId) || isSolanaChain(params.toChainId);
|
|
155
|
+
if (involvesSolana) {
|
|
156
|
+
const solanaCapable = new Set(["debridge"]); // Only deBridge supports Solana
|
|
157
|
+
const preFilter = eligibleBackends;
|
|
158
|
+
eligibleBackends = eligibleBackends.filter((b) => solanaCapable.has(b.name.toLowerCase()));
|
|
159
|
+
for (const b of preFilter) {
|
|
160
|
+
if (!solanaCapable.has(b.name.toLowerCase())) {
|
|
161
|
+
this.lastFailedProviders.push({ provider: b.name, reason: "chain not supported (Solana)" });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Filter backends by circuit breaker state
|
|
166
|
+
const allowedBackends = eligibleBackends.filter((b) => {
|
|
167
|
+
const allowed = this.circuitBreaker.isAllowed(b.name);
|
|
168
|
+
if (!allowed) {
|
|
169
|
+
this.lastRequestErrors.set(b.name, "error"); // circuit-broken = effectively errored
|
|
170
|
+
this.lastFailedProviders.push({ provider: b.name, reason: "circuit breaker open (too many recent failures)" });
|
|
171
|
+
}
|
|
172
|
+
return allowed;
|
|
173
|
+
});
|
|
174
|
+
// Use getQuotes (multi-route) when available, fall back to getQuote (single)
|
|
175
|
+
const results = await Promise.allSettled(allowedBackends.map((b) => Promise.race([
|
|
176
|
+
(b.getQuotes
|
|
177
|
+
? b.getQuotes(params)
|
|
178
|
+
: b.getQuote(params).then((q) => (q ? [q] : []))).then((quotes) => ({ backendName: b.name, quotes })),
|
|
179
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Backend ${b.name} timed out after ${BACKEND_TIMEOUT_MS}ms`)), BACKEND_TIMEOUT_MS)),
|
|
180
|
+
])));
|
|
181
|
+
// Check for validation errors from backends and propagate the first one
|
|
182
|
+
for (const r of results) {
|
|
183
|
+
if (r.status === "rejected" && r.reason instanceof BackendValidationError) {
|
|
184
|
+
throw r.reason;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
// Process results and track circuit breaker state
|
|
189
|
+
const allQuotes = [];
|
|
190
|
+
for (let i = 0; i < results.length; i++) {
|
|
191
|
+
const r = results[i];
|
|
192
|
+
const backendName = allowedBackends[i].name;
|
|
193
|
+
if (r.status === "rejected") {
|
|
194
|
+
this.circuitBreaker.recordFailure(backendName);
|
|
195
|
+
this.lastRequestErrors.set(backendName, "error");
|
|
196
|
+
const errMsg = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
197
|
+
// Classify the error
|
|
198
|
+
let reason = "unknown error";
|
|
199
|
+
if (errMsg.includes("timed out"))
|
|
200
|
+
reason = `timeout after ${BACKEND_TIMEOUT_MS / 1000}s`;
|
|
201
|
+
else if (errMsg.includes("rate limit") || errMsg.includes("429"))
|
|
202
|
+
reason = "rate limited";
|
|
203
|
+
else if (errMsg.includes("ECONNREFUSED") || errMsg.includes("ENOTFOUND"))
|
|
204
|
+
reason = "connection failed";
|
|
205
|
+
else
|
|
206
|
+
reason = errMsg.slice(0, 100);
|
|
207
|
+
this.lastFailedProviders.push({ provider: backendName, reason });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const quotes = r.value.quotes.filter((q) => q !== null);
|
|
211
|
+
if (quotes.length === 0) {
|
|
212
|
+
// Empty result is not a failure for circuit breaker (route might not exist)
|
|
213
|
+
this.circuitBreaker.recordSuccess(backendName);
|
|
214
|
+
this.lastRequestErrors.set(backendName, "empty");
|
|
215
|
+
this.lastFailedProviders.push({ provider: backendName, reason: "no routes for this token pair" });
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
this.circuitBreaker.recordSuccess(backendName);
|
|
219
|
+
this.lastRequestErrors.set(backendName, "success");
|
|
220
|
+
allQuotes.push(...quotes);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Filter expired quotes (with buffer)
|
|
224
|
+
const validQuotes = allQuotes.filter((q) => q.expiresAt > now + EXPIRY_BUFFER_MS);
|
|
225
|
+
// Sort by preference
|
|
226
|
+
if (params.preference === "fastest") {
|
|
227
|
+
validQuotes.sort((a, b) => a.estimatedTimeSeconds - b.estimatedTimeSeconds);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Cheapest = highest output amount (BigInt raw), with gas as tiebreaker
|
|
231
|
+
// (NEW-MEDIUM-001: don't mix token units with USD gas costs)
|
|
232
|
+
validQuotes.sort((a, b) => {
|
|
233
|
+
try {
|
|
234
|
+
const aOutputRaw = BigInt(a.minOutputAmountRaw);
|
|
235
|
+
const bOutputRaw = BigInt(b.minOutputAmountRaw);
|
|
236
|
+
// Normalize to 18 decimals for cross-backend comparison (V3-MEDIUM-001)
|
|
237
|
+
const aOutput = aOutputRaw * 10n ** BigInt(18 - (a.outputDecimals ?? 18));
|
|
238
|
+
const bOutput = bOutputRaw * 10n ** BigInt(18 - (b.outputDecimals ?? 18));
|
|
239
|
+
// Primary sort: highest output wins
|
|
240
|
+
// Use gas as tiebreaker only when outputs are within 0.1% of each other
|
|
241
|
+
const larger = aOutput > bOutput ? aOutput : bOutput;
|
|
242
|
+
const diff = aOutput > bOutput ? aOutput - bOutput : bOutput - aOutput;
|
|
243
|
+
const isNearEqual = larger > 0n && diff * 1000n <= larger; // within 0.1%
|
|
244
|
+
if (!isNearEqual) {
|
|
245
|
+
// Outputs differ meaningfully — highest output wins
|
|
246
|
+
return bOutput > aOutput ? 1 : bOutput < aOutput ? -1 : 0;
|
|
247
|
+
}
|
|
248
|
+
// Outputs are near-equal — use gas cost as tiebreaker (lower gas wins)
|
|
249
|
+
const aGas = a.estimatedGasCostUsd ?? Infinity;
|
|
250
|
+
const bGas = b.estimatedGasCostUsd ?? Infinity;
|
|
251
|
+
if (aGas !== bGas)
|
|
252
|
+
return aGas - bGas;
|
|
253
|
+
// Gas also equal — fall back to raw output
|
|
254
|
+
return bOutput > aOutput ? 1 : bOutput < aOutput ? -1 : 0;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Cache quotes for execution and assign stable IDs
|
|
262
|
+
const cachedQuotes = [];
|
|
263
|
+
for (const q of validQuotes) {
|
|
264
|
+
const quoteId = crypto.randomUUID();
|
|
265
|
+
this.quoteCache.set(quoteId, { quote: q, expiresAt: q.expiresAt });
|
|
266
|
+
cachedQuotes.push({ ...q, quoteId });
|
|
267
|
+
}
|
|
268
|
+
// Store in response cache
|
|
269
|
+
this.quoteResponseCache.set(cacheKey, { quotes: cachedQuotes, fetchedAt: now });
|
|
270
|
+
// Clean expired entries
|
|
271
|
+
for (const [key, val] of this.quoteCache) {
|
|
272
|
+
if (val.expiresAt < now)
|
|
273
|
+
this.quoteCache.delete(key);
|
|
274
|
+
}
|
|
275
|
+
// Clean old response cache entries
|
|
276
|
+
for (const [key, val] of this.quoteResponseCache) {
|
|
277
|
+
if (now - val.fetchedAt > QUOTE_CACHE_TTL_MS)
|
|
278
|
+
this.quoteResponseCache.delete(key);
|
|
279
|
+
}
|
|
280
|
+
return cachedQuotes;
|
|
281
|
+
}
|
|
282
|
+
getCachedQuote(quoteId) {
|
|
283
|
+
const entry = this.quoteCache.get(quoteId);
|
|
284
|
+
if (!entry || entry.expiresAt < Date.now()) {
|
|
285
|
+
this.quoteCache.delete(quoteId);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return entry.quote;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get a cached quote even if it's expired. Returns { quote, expired } so callers
|
|
292
|
+
* can decide to auto-refresh. Does NOT delete expired entries.
|
|
293
|
+
*/
|
|
294
|
+
getCachedQuoteWithExpiry(quoteId) {
|
|
295
|
+
const entry = this.quoteCache.get(quoteId);
|
|
296
|
+
if (!entry)
|
|
297
|
+
return null;
|
|
298
|
+
return { quote: entry.quote, expired: entry.expiresAt < Date.now() };
|
|
299
|
+
}
|
|
300
|
+
getBackend(name) {
|
|
301
|
+
return this.backends.find((b) => b.name === name);
|
|
302
|
+
}
|
|
303
|
+
getAllBackends() {
|
|
304
|
+
return this.backends;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Get the circuit breaker instance (for monitoring/testing).
|
|
308
|
+
*/
|
|
309
|
+
getCircuitBreaker() {
|
|
310
|
+
return this.circuitBreaker;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Get the list of providers that failed or returned no results in the last request.
|
|
314
|
+
*/
|
|
315
|
+
getLastFailedProviders() {
|
|
316
|
+
return this.lastFailedProviders;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Differentiate why no quotes were returned.
|
|
320
|
+
* Call after getQuotes returns empty to understand the cause.
|
|
321
|
+
*/
|
|
322
|
+
getLastRequestDiagnosis() {
|
|
323
|
+
const outcomes = Array.from(this.lastRequestErrors.values());
|
|
324
|
+
const circuitBroken = [];
|
|
325
|
+
for (const [name] of this.lastRequestErrors) {
|
|
326
|
+
if (this.circuitBreaker.getState(name) === "OPEN") {
|
|
327
|
+
circuitBroken.push(name);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
allErrored: outcomes.length > 0 && outcomes.every((o) => o === "error"),
|
|
332
|
+
allEmpty: outcomes.length > 0 && outcomes.every((o) => o === "empty" || o === "success"),
|
|
333
|
+
circuitBroken,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sanitizeError } from "../utils/sanitize-error.js";
|
|
3
|
+
export function registerCheckStatus(server, engine) {
|
|
4
|
+
server.tool("bridge_status", "Check the status of a cross-chain bridge transfer. " +
|
|
5
|
+
"Supports all providers: LI.FI, Squid Router, deBridge, Across, Relay, Persistence Interop. " +
|
|
6
|
+
"Provide the tracking ID from bridge_execute, or a transaction hash with provider name. " +
|
|
7
|
+
"Returns: status (pending/in_progress/completed/failed), source/destination tx hashes, elapsed time, and estimated remaining time.", {
|
|
8
|
+
trackingId: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Tracking ID from bridge_execute"),
|
|
12
|
+
txHash: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Source chain transaction hash"),
|
|
16
|
+
fromChain: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Source chain ID (needed with txHash for LI.FI)"),
|
|
20
|
+
toChain: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Destination chain ID (needed with txHash for LI.FI)"),
|
|
24
|
+
provider: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Bridge provider (e.g. 'lifi', 'persistence')"),
|
|
28
|
+
}, async (params) => {
|
|
29
|
+
// Validate: at least one of trackingId or txHash must be provided
|
|
30
|
+
if (!params.trackingId && !params.txHash) {
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: "Please provide either a trackingId (from bridge_execute) or a txHash to check bridge status.",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
isError: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Determine which backend to query
|
|
42
|
+
let providerName = params.provider;
|
|
43
|
+
if (!providerName && params.trackingId) {
|
|
44
|
+
providerName = params.trackingId.split(":")[0];
|
|
45
|
+
}
|
|
46
|
+
if (!providerName)
|
|
47
|
+
providerName = "lifi"; // default
|
|
48
|
+
const backend = engine.getBackend(providerName);
|
|
49
|
+
if (!backend) {
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: `Unknown provider: ${providerName}. Available: ${engine.getAllBackends().map((b) => b.name).join(", ")}`,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const meta = {};
|
|
61
|
+
if (params.txHash)
|
|
62
|
+
meta.txHash = params.txHash;
|
|
63
|
+
if (params.fromChain)
|
|
64
|
+
meta.fromChain = params.fromChain;
|
|
65
|
+
if (params.toChain)
|
|
66
|
+
meta.toChain = params.toChain;
|
|
67
|
+
const status = await backend.getStatus(params.trackingId ?? params.txHash ?? "", meta);
|
|
68
|
+
const response = {
|
|
69
|
+
status: status.state,
|
|
70
|
+
summary: status.humanReadable,
|
|
71
|
+
provider: status.provider,
|
|
72
|
+
sourceTx: status.sourceTxHash ?? null,
|
|
73
|
+
destinationTx: status.destTxHash ?? null,
|
|
74
|
+
elapsedSeconds: status.elapsed,
|
|
75
|
+
estimatedRemainingSeconds: status.estimatedRemaining ?? null,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: `Status check failed: ${sanitizeError(err)}`,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
isError: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|