@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,534 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import { resolveChainId, getChainName, isCosmosChain, isSolanaChain } from "../utils/chains.js";
|
|
4
|
+
import { resolveToken } from "../utils/token-registry.js";
|
|
5
|
+
import { parseTokenAmount } from "../utils/tokens.js";
|
|
6
|
+
import { BackendValidationError } from "../backends/types.js";
|
|
7
|
+
import { getProvider } from "../utils/gas-estimator.js";
|
|
8
|
+
const ERC20_BALANCE_ABI = ["function balanceOf(address) view returns (uint256)"];
|
|
9
|
+
const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
|
|
10
|
+
// ─── Rate Limiting ─────────────────────────────────────────────────────
|
|
11
|
+
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
|
12
|
+
const RATE_LIMIT_MAX_REQUESTS = 10; // max requests per route per window
|
|
13
|
+
// Map of route key → array of request timestamps
|
|
14
|
+
const rateLimitMap = new Map();
|
|
15
|
+
let rateLimitCheckCount = 0;
|
|
16
|
+
/**
|
|
17
|
+
* Evict stale entries from the rate limit map to prevent unbounded growth.
|
|
18
|
+
* (NEW-MEDIUM-002: periodic cleanup every 100 calls)
|
|
19
|
+
*/
|
|
20
|
+
function evictStaleRateLimitEntries() {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
for (const [key, timestamps] of rateLimitMap) {
|
|
23
|
+
const hasRecent = timestamps.some(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
24
|
+
if (!hasRecent) {
|
|
25
|
+
rateLimitMap.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function checkRateLimit(routeKey) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
// Periodic cleanup to bound memory (NEW-MEDIUM-002)
|
|
32
|
+
rateLimitCheckCount++;
|
|
33
|
+
if (rateLimitCheckCount % 100 === 0) {
|
|
34
|
+
evictStaleRateLimitEntries();
|
|
35
|
+
}
|
|
36
|
+
const timestamps = rateLimitMap.get(routeKey) ?? [];
|
|
37
|
+
// Prune expired entries
|
|
38
|
+
const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
39
|
+
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
|
|
40
|
+
rateLimitMap.set(routeKey, recent);
|
|
41
|
+
return false; // rate limited
|
|
42
|
+
}
|
|
43
|
+
recent.push(now);
|
|
44
|
+
rateLimitMap.set(routeKey, recent);
|
|
45
|
+
return true; // allowed
|
|
46
|
+
}
|
|
47
|
+
export function registerGetQuote(server, engine) {
|
|
48
|
+
server.tool("bridge_get_quote", "Get the best cross-chain bridge quote across multiple providers (LI.FI, Squid Router, deBridge, Across, Relay, Persistence Interop). " +
|
|
49
|
+
"Supports EVM chains, Cosmos chains (Persistence, Cosmos Hub), and Solana. " +
|
|
50
|
+
"Accepts token symbols (e.g. 'USDC', 'ETH', 'WBTC', 'XPRT', 'ATOM') or contract addresses (0x...). " +
|
|
51
|
+
"Symbols are resolved to verified canonical addresses only — no unverified tokens. " +
|
|
52
|
+
"Returns ranked options by output amount, speed, and fees. Includes failedProviders array showing which providers didn't return quotes and why. " +
|
|
53
|
+
"Preconditions: None for quoting. Use bridge_execute to act on a quote. " +
|
|
54
|
+
"Error codes: 'Token resolution failed' (unknown symbol), 'Rate limited' (too many requests), 'Validation error' (invalid params).", {
|
|
55
|
+
fromChain: z
|
|
56
|
+
.string()
|
|
57
|
+
.describe("Source chain (e.g. 'ethereum', 'base', 'arbitrum', or chain ID like '1', '8453')"),
|
|
58
|
+
toChain: z.string().describe("Destination chain"),
|
|
59
|
+
fromToken: z
|
|
60
|
+
.string()
|
|
61
|
+
.describe("Token to send — symbol (e.g. 'USDC', 'ETH', 'WBTC') or contract address (0x...). " +
|
|
62
|
+
"Symbols resolve to verified canonical addresses only."),
|
|
63
|
+
toToken: z
|
|
64
|
+
.string()
|
|
65
|
+
.describe("Token to receive — symbol (e.g. 'USDC', 'ETH') or contract address (0x...). " +
|
|
66
|
+
"Symbols resolve to verified canonical addresses only."),
|
|
67
|
+
amount: z
|
|
68
|
+
.string()
|
|
69
|
+
.describe("Amount in human-readable units (e.g. '100' for 100 USDC)"),
|
|
70
|
+
fromAddress: z.string().describe("Sender wallet address (0x... for EVM, base58 for Solana)"),
|
|
71
|
+
toAddress: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Recipient address (defaults to fromAddress)"),
|
|
75
|
+
preference: z
|
|
76
|
+
.enum(["cheapest", "fastest"])
|
|
77
|
+
.default("fastest")
|
|
78
|
+
.describe("Optimize for lowest cost or fastest delivery"),
|
|
79
|
+
providers: z
|
|
80
|
+
.array(z.string())
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Optional: only query specific providers (e.g. ['squid', 'lifi']). Default: query all."),
|
|
83
|
+
}, async (params) => {
|
|
84
|
+
// Helper: resolve toAddress for non-EVM destinations (Solana, Cosmos)
|
|
85
|
+
async function resolveToAddress(toAddress, destChainId) {
|
|
86
|
+
if (toAddress)
|
|
87
|
+
return toAddress;
|
|
88
|
+
if (!isSolanaChain(destChainId))
|
|
89
|
+
return undefined;
|
|
90
|
+
// Auto-derive Solana address from wallet's Solana key
|
|
91
|
+
try {
|
|
92
|
+
const { getKey } = await import("./wallet.js");
|
|
93
|
+
const solanaKey = getKey("solanaKey");
|
|
94
|
+
if (solanaKey) {
|
|
95
|
+
const bs58 = await import("bs58");
|
|
96
|
+
const { Keypair } = await import("@solana/web3.js");
|
|
97
|
+
const secretKey = bs58.default.decode(solanaKey);
|
|
98
|
+
const keypair = Keypair.fromSecretKey(secretKey);
|
|
99
|
+
return keypair.publicKey.toBase58();
|
|
100
|
+
}
|
|
101
|
+
// Fall back to mnemonic-derived address
|
|
102
|
+
const mnemonic = getKey("mnemonic");
|
|
103
|
+
if (mnemonic) {
|
|
104
|
+
const { derivePath } = await import("ed25519-hd-key");
|
|
105
|
+
const bip39 = await import("@scure/bip39");
|
|
106
|
+
const { Keypair } = await import("@solana/web3.js");
|
|
107
|
+
const seed = await bip39.mnemonicToSeed(mnemonic);
|
|
108
|
+
const derivedSeed = derivePath("m/44'/501'/0'/0'", seed.toString("hex")).key;
|
|
109
|
+
const keypair = Keypair.fromSeed(derivedSeed);
|
|
110
|
+
return keypair.publicKey.toBase58();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Non-fatal — Solana key derivation failed, quote will still work without toAddress
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
// Resolve chains
|
|
119
|
+
const fromChainId = resolveChainId(params.fromChain);
|
|
120
|
+
const toChainId = resolveChainId(params.toChain);
|
|
121
|
+
if (!fromChainId)
|
|
122
|
+
return {
|
|
123
|
+
content: [
|
|
124
|
+
{
|
|
125
|
+
type: "text",
|
|
126
|
+
text: `Unknown source chain: ${params.fromChain}. Use chain name (e.g. 'base') or ID (e.g. '8453').`,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
if (!toChainId)
|
|
131
|
+
return {
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: "text",
|
|
135
|
+
text: `Unknown destination chain: ${params.toChain}. Use chain name (e.g. 'arbitrum') or ID.`,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
// Rate limit check per route
|
|
140
|
+
const routeKey = `${fromChainId}:${toChainId}:${params.fromToken.toLowerCase()}:${params.toToken.toLowerCase()}:${params.fromAddress.toLowerCase()}`;
|
|
141
|
+
if (!checkRateLimit(routeKey)) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: JSON.stringify({
|
|
146
|
+
error: "Rate limited",
|
|
147
|
+
message: `Too many requests for this route. Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please wait and try again.`,
|
|
148
|
+
}),
|
|
149
|
+
}],
|
|
150
|
+
isError: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Validate amount is positive before parsing
|
|
154
|
+
const amountTrimmed = params.amount.trim();
|
|
155
|
+
if (!amountTrimmed || !/^\d+\.?\d*$/.test(amountTrimmed)) {
|
|
156
|
+
return {
|
|
157
|
+
content: [{
|
|
158
|
+
type: "text",
|
|
159
|
+
text: JSON.stringify({
|
|
160
|
+
error: "Invalid amount",
|
|
161
|
+
message: `Amount must be a positive number. Got: "${params.amount}"`,
|
|
162
|
+
}),
|
|
163
|
+
}],
|
|
164
|
+
isError: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const amountNum = Number(amountTrimmed);
|
|
168
|
+
if (isNaN(amountNum) || amountNum <= 0) {
|
|
169
|
+
return {
|
|
170
|
+
content: [{
|
|
171
|
+
type: "text",
|
|
172
|
+
text: JSON.stringify({
|
|
173
|
+
error: "Invalid amount",
|
|
174
|
+
message: `Amount must be a positive number. Got: "${params.amount}"`,
|
|
175
|
+
}),
|
|
176
|
+
}],
|
|
177
|
+
isError: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// Pre-flight: warn if fromAddress is a zero address or burn address
|
|
181
|
+
const ZERO_ADDRESSES = [
|
|
182
|
+
"0x0000000000000000000000000000000000000000",
|
|
183
|
+
"0x000000000000000000000000000000000000dead",
|
|
184
|
+
];
|
|
185
|
+
if (ZERO_ADDRESSES.includes(params.fromAddress.toLowerCase())) {
|
|
186
|
+
return {
|
|
187
|
+
content: [{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: JSON.stringify({
|
|
190
|
+
error: "Invalid sender address",
|
|
191
|
+
message: `The sender address ${params.fromAddress} appears to be a zero/burn address. Provide a real wallet address.`,
|
|
192
|
+
}),
|
|
193
|
+
}],
|
|
194
|
+
isError: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// Resolve tokens via verified registry
|
|
198
|
+
const fromTokenResult = resolveToken(params.fromToken, fromChainId);
|
|
199
|
+
if (!fromTokenResult.ok) {
|
|
200
|
+
return {
|
|
201
|
+
content: [{
|
|
202
|
+
type: "text",
|
|
203
|
+
text: JSON.stringify({
|
|
204
|
+
error: "Token resolution failed",
|
|
205
|
+
token: params.fromToken,
|
|
206
|
+
chain: getChainName(fromChainId),
|
|
207
|
+
chainId: fromChainId,
|
|
208
|
+
message: fromTokenResult.error,
|
|
209
|
+
}, null, 2),
|
|
210
|
+
}],
|
|
211
|
+
isError: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const toTokenResult = resolveToken(params.toToken, toChainId);
|
|
215
|
+
if (!toTokenResult.ok) {
|
|
216
|
+
return {
|
|
217
|
+
content: [{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: JSON.stringify({
|
|
220
|
+
error: "Token resolution failed",
|
|
221
|
+
token: params.toToken,
|
|
222
|
+
chain: getChainName(toChainId),
|
|
223
|
+
chainId: toChainId,
|
|
224
|
+
message: toTokenResult.error,
|
|
225
|
+
}, null, 2),
|
|
226
|
+
}],
|
|
227
|
+
isError: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const fromTokenAddress = fromTokenResult.address;
|
|
231
|
+
const toTokenAddress = toTokenResult.address;
|
|
232
|
+
const decimals = fromTokenResult.decimals;
|
|
233
|
+
const toDecimals = toTokenResult.decimals;
|
|
234
|
+
const fromSymbol = fromTokenResult.symbol;
|
|
235
|
+
const toSymbol = toTokenResult.symbol;
|
|
236
|
+
// Parse amount to raw units
|
|
237
|
+
const amountRaw = parseTokenAmount(amountTrimmed, decimals);
|
|
238
|
+
let quotes;
|
|
239
|
+
try {
|
|
240
|
+
quotes = await engine.getQuotes({
|
|
241
|
+
fromChainId,
|
|
242
|
+
toChainId,
|
|
243
|
+
fromTokenAddress,
|
|
244
|
+
toTokenAddress,
|
|
245
|
+
amountRaw,
|
|
246
|
+
fromAddress: params.fromAddress,
|
|
247
|
+
toAddress: await resolveToAddress(params.toAddress, toChainId),
|
|
248
|
+
preference: params.preference,
|
|
249
|
+
fromTokenDecimals: decimals,
|
|
250
|
+
toTokenDecimals: toDecimals,
|
|
251
|
+
providers: params.providers,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (err instanceof BackendValidationError) {
|
|
256
|
+
return {
|
|
257
|
+
content: [{
|
|
258
|
+
type: "text",
|
|
259
|
+
text: JSON.stringify({ error: "Validation error", message: err.message }),
|
|
260
|
+
}],
|
|
261
|
+
isError: true,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
if (quotes.length === 0) {
|
|
267
|
+
// Differentiate "route doesn't exist" from "backends are down"
|
|
268
|
+
const diagnosis = engine.getLastRequestDiagnosis();
|
|
269
|
+
let message;
|
|
270
|
+
if (diagnosis.allErrored) {
|
|
271
|
+
message = `All bridge providers are currently unavailable. Please try again in a few minutes.`;
|
|
272
|
+
if (diagnosis.circuitBroken.length > 0) {
|
|
273
|
+
message += ` (${diagnosis.circuitBroken.join(", ")} temporarily disabled due to repeated failures)`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
message = `No bridge routes found for ${params.amount} ${fromSymbol} from ${getChainName(fromChainId)} to ${getChainName(toChainId)}. This route may not be supported by any provider.`;
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
content: [
|
|
281
|
+
{
|
|
282
|
+
type: "text",
|
|
283
|
+
text: message,
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const best = quotes[0];
|
|
289
|
+
function formatTime(seconds) {
|
|
290
|
+
if (seconds < 60)
|
|
291
|
+
return `${seconds}s`;
|
|
292
|
+
const mins = Math.floor(seconds / 60);
|
|
293
|
+
const secs = seconds % 60;
|
|
294
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
295
|
+
}
|
|
296
|
+
// Backends where gas is estimated by us (not provided by the backend API)
|
|
297
|
+
const GAS_ESTIMATED_BACKENDS = new Set(["debridge", "across", "persistence"]);
|
|
298
|
+
// Compute stats for tags
|
|
299
|
+
const fastestTime = Math.min(...quotes.map(q => q.estimatedTimeSeconds));
|
|
300
|
+
let bestOutputRaw = "0";
|
|
301
|
+
for (const q of quotes) {
|
|
302
|
+
try {
|
|
303
|
+
if (BigInt(q.minOutputAmountRaw) > BigInt(bestOutputRaw)) {
|
|
304
|
+
bestOutputRaw = q.minOutputAmountRaw;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch { /* skip */ }
|
|
308
|
+
}
|
|
309
|
+
function formatGasFee(q) {
|
|
310
|
+
// If gas cost is null/unknown, display "unknown" — never show misleading $0.00
|
|
311
|
+
if (q.estimatedGasCostUsd === null || q.estimatedGasCostUsd === undefined) {
|
|
312
|
+
return "unknown";
|
|
313
|
+
}
|
|
314
|
+
if (q.estimatedGasCostUsd > 0) {
|
|
315
|
+
// For very small but non-zero amounts, show "<$0.01" instead of "$0.00"
|
|
316
|
+
if (q.estimatedGasCostUsd < 0.01) {
|
|
317
|
+
const marker = GAS_ESTIMATED_BACKENDS.has(q.backendName) ? "~" : "";
|
|
318
|
+
return `${marker}<$0.01`;
|
|
319
|
+
}
|
|
320
|
+
// Backends where we estimate gas ourselves get the "~" and "(est)" markers
|
|
321
|
+
if (GAS_ESTIMATED_BACKENDS.has(q.backendName)) {
|
|
322
|
+
return `~$${q.estimatedGasCostUsd.toFixed(2)} (est)`;
|
|
323
|
+
}
|
|
324
|
+
return `$${q.estimatedGasCostUsd.toFixed(2)}`;
|
|
325
|
+
}
|
|
326
|
+
return "$0.00";
|
|
327
|
+
}
|
|
328
|
+
function getFeeModel(q) {
|
|
329
|
+
switch (q.backendName) {
|
|
330
|
+
case "relay":
|
|
331
|
+
return "gas_included_in_spread";
|
|
332
|
+
case "persistence":
|
|
333
|
+
return "gasless_relay";
|
|
334
|
+
case "lifi":
|
|
335
|
+
case "squid":
|
|
336
|
+
case "debridge":
|
|
337
|
+
case "across":
|
|
338
|
+
default:
|
|
339
|
+
return "user_pays_gas";
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// L2 chains with typically very low gas costs
|
|
343
|
+
const L2_CHAINS = new Set([
|
|
344
|
+
10, 137, 324, 8453, 42161, 42170, 59144, 534352, 1101, 81457, 7777777,
|
|
345
|
+
34443, 204, 1088, 5000, 288, 252, 690, 1135, 1329, 1868, 1923, 2741,
|
|
346
|
+
7560, 13371, 33139, 167000, 60808, 1750, 2522, 232, 999, 360, 1514,
|
|
347
|
+
810180, 4326, 9745
|
|
348
|
+
]);
|
|
349
|
+
function getGasEstimateNote(chainId) {
|
|
350
|
+
if (L2_CHAINS.has(chainId)) {
|
|
351
|
+
const chainName = getChainName(chainId);
|
|
352
|
+
return `Gas on ${chainName} L2 is typically <$0.01`;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
function buildTags(q) {
|
|
357
|
+
const tags = [];
|
|
358
|
+
if (quotes.length > 1) {
|
|
359
|
+
if (q.estimatedTimeSeconds === fastestTime) {
|
|
360
|
+
tags.push("⚡ fastest");
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
if (BigInt(q.minOutputAmountRaw) === BigInt(bestOutputRaw)) {
|
|
364
|
+
tags.push("💰 best rate");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch { /* skip */ }
|
|
368
|
+
}
|
|
369
|
+
return tags;
|
|
370
|
+
}
|
|
371
|
+
function formatQuote(q) {
|
|
372
|
+
const expiresInSeconds = q.expiresAt
|
|
373
|
+
? Math.max(0, Math.round((q.expiresAt - Date.now()) / 1000))
|
|
374
|
+
: null;
|
|
375
|
+
const gasNote = fromChainId ? getGasEstimateNote(fromChainId) : null;
|
|
376
|
+
const quote = {
|
|
377
|
+
provider: q.provider,
|
|
378
|
+
youReceiveMin: `${q.minOutputAmount} ${toSymbol}`,
|
|
379
|
+
estimatedGasFee: formatGasFee(q),
|
|
380
|
+
feeModel: getFeeModel(q),
|
|
381
|
+
estimatedTime: formatTime(q.estimatedTimeSeconds),
|
|
382
|
+
route: q.route,
|
|
383
|
+
tags: buildTags(q),
|
|
384
|
+
quoteId: q.quoteId,
|
|
385
|
+
expiresAt: q.expiresAt ? new Date(q.expiresAt).toISOString() : null,
|
|
386
|
+
expiresInSeconds,
|
|
387
|
+
};
|
|
388
|
+
if (gasNote) {
|
|
389
|
+
quote.gasEstimateNote = gasNote;
|
|
390
|
+
}
|
|
391
|
+
// Surface protocol fees (deBridge fixFee, operating expenses) so agents know the REAL cost
|
|
392
|
+
const fb = q.feeBreakdown;
|
|
393
|
+
if (fb?.fixFeeNativeRaw && fb.fixFeeNativeRaw !== "0") {
|
|
394
|
+
const fixFeeEth = Number(BigInt(fb.fixFeeNativeRaw)) / 1e18;
|
|
395
|
+
const cid = fromChainId;
|
|
396
|
+
const nativeSymbol = [56].includes(cid) ? "BNB"
|
|
397
|
+
: [137].includes(cid) ? "MATIC"
|
|
398
|
+
: [43114].includes(cid) ? "AVAX"
|
|
399
|
+
: "ETH";
|
|
400
|
+
quote.protocolFee = `${fixFeeEth.toFixed(6)} ${nativeSymbol}`;
|
|
401
|
+
// Total the user actually pays (input + fees), formatted
|
|
402
|
+
if (fb.totalSourceAmountRaw) {
|
|
403
|
+
const totalSrc = Number(BigInt(fb.totalSourceAmountRaw)) / (10 ** decimals);
|
|
404
|
+
quote.totalSourceCost = `${totalSrc} ${fromSymbol} + ${fixFeeEth.toFixed(6)} ${nativeSymbol} protocol fee`;
|
|
405
|
+
}
|
|
406
|
+
// Warn when protocol fee exceeds bridge amount
|
|
407
|
+
try {
|
|
408
|
+
const fixFeeBig = BigInt(fb.fixFeeNativeRaw);
|
|
409
|
+
const amountBig = BigInt(amountRaw);
|
|
410
|
+
// Only compare when both are in the same denomination (native token)
|
|
411
|
+
if (fromTokenAddress === "0x0000000000000000000000000000000000000000" ||
|
|
412
|
+
fromTokenAddress.toLowerCase() === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") {
|
|
413
|
+
if (fixFeeBig > amountBig) {
|
|
414
|
+
quote.feeWarning = `⚠️ Protocol fee (${fixFeeEth.toFixed(6)} ${nativeSymbol}) exceeds bridge amount (${params.amount} ${fromSymbol}). Consider bridging a larger amount.`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch { /* ignore */ }
|
|
419
|
+
}
|
|
420
|
+
return quote;
|
|
421
|
+
}
|
|
422
|
+
const bestGasDisplay = formatGasFee(best);
|
|
423
|
+
// Include failed providers for transparency
|
|
424
|
+
const failedProviders = engine.getLastFailedProviders();
|
|
425
|
+
const response = {
|
|
426
|
+
bestQuote: formatQuote(best),
|
|
427
|
+
alternatives: quotes.slice(1, 5).map(formatQuote),
|
|
428
|
+
totalRoutesFound: quotes.length,
|
|
429
|
+
summary: `Best: receive min ${best.minOutputAmount} ${toSymbol} via ${best.provider} (gas: ${bestGasDisplay}, ETA: ${formatTime(best.estimatedTimeSeconds)}). ${quotes.length > 1 ? `${quotes.length - 1} alternative(s) available.` : ""}`,
|
|
430
|
+
};
|
|
431
|
+
if (failedProviders.length > 0) {
|
|
432
|
+
response.failedProviders = failedProviders;
|
|
433
|
+
}
|
|
434
|
+
// Pre-flight balance warning: check if wallet has enough funds for the quote
|
|
435
|
+
// Solana balance check
|
|
436
|
+
if (params.fromAddress && isSolanaChain(fromChainId)) {
|
|
437
|
+
try {
|
|
438
|
+
const { Connection, PublicKey } = await import("@solana/web3.js");
|
|
439
|
+
const connection = new Connection("https://api.mainnet-beta.solana.com", "confirmed");
|
|
440
|
+
const pubkey = new PublicKey(params.fromAddress);
|
|
441
|
+
const isNativeSOL = fromTokenAddress === "So11111111111111111111111111111111111111112";
|
|
442
|
+
let walletBalance;
|
|
443
|
+
let balanceFormatted;
|
|
444
|
+
if (isNativeSOL) {
|
|
445
|
+
const lamports = await connection.getBalance(pubkey);
|
|
446
|
+
walletBalance = BigInt(lamports);
|
|
447
|
+
balanceFormatted = (lamports / 1e9).toFixed(6);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
// SPL token balance
|
|
451
|
+
const { TOKEN_PROGRAM_ID } = await import("@solana/spl-token");
|
|
452
|
+
const tokenMint = new PublicKey(fromTokenAddress);
|
|
453
|
+
const accounts = await connection.getParsedTokenAccountsByOwner(pubkey, { mint: tokenMint });
|
|
454
|
+
const totalAmount = accounts.value.reduce((sum, acc) => {
|
|
455
|
+
return sum + BigInt(acc.account.data.parsed.info.tokenAmount.amount);
|
|
456
|
+
}, 0n);
|
|
457
|
+
walletBalance = totalAmount;
|
|
458
|
+
balanceFormatted = (Number(totalAmount) / 10 ** decimals).toFixed(decimals);
|
|
459
|
+
}
|
|
460
|
+
const amountRequired = BigInt(amountRaw);
|
|
461
|
+
if (walletBalance < amountRequired) {
|
|
462
|
+
response.balanceWarning = `Warning: wallet balance (${balanceFormatted} ${fromSymbol}) may be insufficient for ${params.amount} ${fromSymbol} quote`;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// Balance check failure should never block the quote
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// EVM balance check — validates BOTH token balance AND native balance for protocol fees
|
|
470
|
+
if (params.fromAddress && !isCosmosChain(fromChainId) && !isSolanaChain(fromChainId)) {
|
|
471
|
+
try {
|
|
472
|
+
const isNative = fromTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() ||
|
|
473
|
+
fromTokenAddress === "0x0000000000000000000000000000000000000000";
|
|
474
|
+
const provider = await getProvider(fromChainId);
|
|
475
|
+
// Always check native balance (needed for gas + protocol fees even for ERC-20 bridges)
|
|
476
|
+
const nativeBalance = await provider.getBalance(params.fromAddress);
|
|
477
|
+
if (isNative) {
|
|
478
|
+
// For native token bridges: total cost = bridge amount + protocol fee + operating expenses
|
|
479
|
+
const bestFb = best.feeBreakdown;
|
|
480
|
+
const fixFee = bestFb?.fixFeeNativeRaw ? BigInt(bestFb.fixFeeNativeRaw) : 0n;
|
|
481
|
+
const totalSourceRaw = bestFb?.totalSourceAmountRaw ? BigInt(bestFb.totalSourceAmountRaw) : BigInt(amountRaw);
|
|
482
|
+
// Total needed = totalSourceAmount (amount + operating expenses) + fixFee
|
|
483
|
+
const totalNeeded = totalSourceRaw + fixFee;
|
|
484
|
+
const balanceFormatted = ethers.formatEther(nativeBalance);
|
|
485
|
+
const nativeSymbol = [56].includes(fromChainId) ? "BNB"
|
|
486
|
+
: [137].includes(fromChainId) ? "MATIC"
|
|
487
|
+
: [43114].includes(fromChainId) ? "AVAX"
|
|
488
|
+
: "ETH";
|
|
489
|
+
if (nativeBalance < totalNeeded) {
|
|
490
|
+
const totalNeededFormatted = ethers.formatEther(totalNeeded);
|
|
491
|
+
response.balanceWarning = `⚠️ Insufficient balance: wallet has ${balanceFormatted} ${nativeSymbol}, but this bridge requires ~${totalNeededFormatted} ${nativeSymbol} total (${params.amount} ${fromSymbol} bridge amount + protocol fees + operating expenses)`;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// For ERC-20 bridges: check token balance AND native balance for protocol fees + gas
|
|
496
|
+
const contract = new ethers.Contract(fromTokenAddress, ERC20_BALANCE_ABI, provider);
|
|
497
|
+
const tokenBalance = await contract.balanceOf(params.fromAddress);
|
|
498
|
+
const tokenFormatted = ethers.formatUnits(tokenBalance, decimals);
|
|
499
|
+
const amountRequired = BigInt(amountRaw);
|
|
500
|
+
const warnings = [];
|
|
501
|
+
if (tokenBalance < amountRequired) {
|
|
502
|
+
warnings.push(`Token balance (${tokenFormatted} ${fromSymbol}) is insufficient for ${params.amount} ${fromSymbol}`);
|
|
503
|
+
}
|
|
504
|
+
// Check native balance for protocol fee
|
|
505
|
+
const bestFb = best.feeBreakdown;
|
|
506
|
+
const fixFee = bestFb?.fixFeeNativeRaw ? BigInt(bestFb.fixFeeNativeRaw) : 0n;
|
|
507
|
+
if (fixFee > 0n) {
|
|
508
|
+
const nativeSymbol = [56].includes(fromChainId) ? "BNB"
|
|
509
|
+
: [137].includes(fromChainId) ? "MATIC"
|
|
510
|
+
: [43114].includes(fromChainId) ? "AVAX"
|
|
511
|
+
: "ETH";
|
|
512
|
+
// Need fixFee + some gas (estimate ~0.0002 ETH for L2s)
|
|
513
|
+
const gasBuffer = fromChainId === 1 ? 2000000000000000n : 200000000000000n; // 0.002 ETH L1 / 0.0002 ETH L2
|
|
514
|
+
const totalNativeNeeded = fixFee + gasBuffer;
|
|
515
|
+
if (nativeBalance < totalNativeNeeded) {
|
|
516
|
+
const nativeFormatted = ethers.formatEther(nativeBalance);
|
|
517
|
+
const feeFormatted = ethers.formatEther(fixFee);
|
|
518
|
+
warnings.push(`Native balance (${nativeFormatted} ${nativeSymbol}) may be insufficient for protocol fee (${feeFormatted} ${nativeSymbol}) + gas`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (warnings.length > 0) {
|
|
522
|
+
response.balanceWarning = `⚠️ ${warnings.join(". ")}`;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
// Balance check failure should never block the quote
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolveChainId, getChainName, PERSISTENCE_CHAIN_ID, COSMOSHUB_CHAIN_ID } from "../utils/chains.js";
|
|
3
|
+
export function registerGetTokens(server, engine) {
|
|
4
|
+
server.tool("bridge_tokens", "List popular tokens available for bridging on a given chain. " +
|
|
5
|
+
"Returns verified token symbols, contract addresses, and decimals. " +
|
|
6
|
+
"Supports EVM chains and Cosmos chains (Persistence, Cosmos Hub). " +
|
|
7
|
+
"Use token symbols or addresses from this list in bridge_get_quote.", {
|
|
8
|
+
chain: z.string().describe("Chain name or ID (e.g. 'base', '8453')"),
|
|
9
|
+
search: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Filter by token name or symbol"),
|
|
13
|
+
}, async (params) => {
|
|
14
|
+
const chainId = resolveChainId(params.chain);
|
|
15
|
+
if (!chainId) {
|
|
16
|
+
return {
|
|
17
|
+
content: [
|
|
18
|
+
{
|
|
19
|
+
type: "text",
|
|
20
|
+
text: `Unknown chain: ${params.chain}. Use a chain name or ID.`,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Try backends in order until one returns tokens.
|
|
26
|
+
// LI.FI has the widest EVM coverage; Squid covers Cosmos chains.
|
|
27
|
+
const backends = engine.getAllBackends();
|
|
28
|
+
for (const backend of backends) {
|
|
29
|
+
if (!backend.getSupportedTokens)
|
|
30
|
+
continue;
|
|
31
|
+
try {
|
|
32
|
+
let tokens = await backend.getSupportedTokens(chainId);
|
|
33
|
+
if (!tokens || tokens.length === 0)
|
|
34
|
+
continue;
|
|
35
|
+
if (params.search) {
|
|
36
|
+
const q = params.search.toLowerCase();
|
|
37
|
+
tokens = tokens.filter((t) => t.symbol.toLowerCase().includes(q) ||
|
|
38
|
+
t.name.toLowerCase().includes(q));
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
text: JSON.stringify({
|
|
45
|
+
chain: getChainName(chainId),
|
|
46
|
+
chainId,
|
|
47
|
+
tokenCount: tokens.length,
|
|
48
|
+
tokens: tokens.slice(0, 30).map((t) => ({
|
|
49
|
+
symbol: t.symbol,
|
|
50
|
+
name: t.name,
|
|
51
|
+
address: t.address,
|
|
52
|
+
decimals: t.decimals,
|
|
53
|
+
})),
|
|
54
|
+
}, null, 2),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// This backend failed — try the next one
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Hardcoded fallback for Persistence — Squid API may require auth
|
|
65
|
+
if (chainId === PERSISTENCE_CHAIN_ID) {
|
|
66
|
+
const persistenceTokens = [
|
|
67
|
+
{ symbol: "XPRT", name: "Persistence", address: "uxprt", decimals: 6 },
|
|
68
|
+
{ symbol: "stATOM", name: "Stride Staked ATOM", address: "ibc/C8A74ABBE2AF892E15680D916A7C22130585CE5704F9B17A10F184A90D53BECA", decimals: 6 },
|
|
69
|
+
{ symbol: "stkATOM", name: "pSTAKE Staked ATOM", address: "stk/uatom", decimals: 6 },
|
|
70
|
+
{ symbol: "ATOM", name: "Cosmos Hub", address: "ibc/C8A74ABBE2AF892E15680D916A7C22130585CE5704F9B17A10F184A90D53BECA", decimals: 6 },
|
|
71
|
+
{ symbol: "USDT", name: "Tether USD (Axelar)", address: "ibc/B56D6A6284B153D6F054485A9B78AD1FCE5699D751DA511262268105983B147C", decimals: 6 },
|
|
72
|
+
{ symbol: "USDC", name: "USD Coin (Axelar)", address: "ibc/68F4C3E20AF7CAAFBE5E9CBE6C5F2A186DB3766E7B19D9929B260BE58B76E164", decimals: 6 },
|
|
73
|
+
{ symbol: "WETH", name: "Wrapped ETH (Axelar)", address: "ibc/5F9BE030FC1EC5BF20E90B4A2F930D43DB54B860E3B2D4F0FB27E380E9B9359D", decimals: 18 },
|
|
74
|
+
{ symbol: "WBTC", name: "Wrapped BTC (Axelar)", address: "ibc/680BE60BFE5A303E5AB3B4E5B5F3CFAE5F9DE6927081B11D86DE8E2D364E9E6A", decimals: 8 },
|
|
75
|
+
];
|
|
76
|
+
let filtered = persistenceTokens;
|
|
77
|
+
if (params.search) {
|
|
78
|
+
const q = params.search.toLowerCase();
|
|
79
|
+
filtered = persistenceTokens.filter(t => t.symbol.toLowerCase().includes(q) || t.name.toLowerCase().includes(q));
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
content: [{
|
|
83
|
+
type: "text",
|
|
84
|
+
text: JSON.stringify({
|
|
85
|
+
chain: getChainName(chainId),
|
|
86
|
+
chainId,
|
|
87
|
+
tokenCount: filtered.length,
|
|
88
|
+
tokens: filtered,
|
|
89
|
+
note: "Curated token list (live API unavailable)",
|
|
90
|
+
}, null, 2),
|
|
91
|
+
}],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Hardcoded fallback for Cosmos Hub
|
|
95
|
+
if (chainId === COSMOSHUB_CHAIN_ID) {
|
|
96
|
+
const cosmosTokens = [
|
|
97
|
+
{ symbol: "ATOM", name: "Cosmos Hub", address: "uatom", decimals: 6 },
|
|
98
|
+
{ symbol: "USDC", name: "USD Coin (Noble)", address: "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA9E", decimals: 6 },
|
|
99
|
+
{ symbol: "stATOM", name: "Stride Staked ATOM", address: "ibc/C140AFD542AE77BD7DCC83F13FDD8C5E5BB8C4929785E6EC2F4C636F98F17C5", decimals: 6 },
|
|
100
|
+
];
|
|
101
|
+
let filtered = cosmosTokens;
|
|
102
|
+
if (params.search) {
|
|
103
|
+
const q = params.search.toLowerCase();
|
|
104
|
+
filtered = cosmosTokens.filter(t => t.symbol.toLowerCase().includes(q) || t.name.toLowerCase().includes(q));
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
content: [{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: JSON.stringify({
|
|
110
|
+
chain: getChainName(chainId),
|
|
111
|
+
chainId,
|
|
112
|
+
tokenCount: filtered.length,
|
|
113
|
+
tokens: filtered,
|
|
114
|
+
note: "Curated token list (live API unavailable)",
|
|
115
|
+
}, null, 2),
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `Could not fetch tokens for ${getChainName(chainId)}. Try using a token symbol (USDC, ETH) or contract address directly in bridge_get_quote.`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|