@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,744 @@
1
+ import { z } from "zod";
2
+ import { ethers } from "ethers";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ import { sanitizeError } from "../utils/sanitize-error.js";
7
+ import { getProvider } from "../utils/gas-estimator.js";
8
+ const REWARDS_API = "https://rewards.interop.persistence.one";
9
+ const TIMEOUT_MS = 15_000;
10
+ // H-1: In-memory key store — keys are NOT stored in process.env
11
+ const keyStore = {};
12
+ /** Read a key: keyStore first, then process.env fallback (clearing env after read) */
13
+ export function getKey(name) {
14
+ if (keyStore[name])
15
+ return keyStore[name];
16
+ const envMap = { privateKey: "PRIVATE_KEY", mnemonic: "MNEMONIC", solanaKey: "SOLANA_PRIVATE_KEY" };
17
+ const envKey = envMap[name];
18
+ const val = process.env[envKey];
19
+ if (val) {
20
+ keyStore[name] = val;
21
+ delete process.env[envKey]; // H-1: clear from env after reading
22
+ return val;
23
+ }
24
+ return undefined;
25
+ }
26
+ /**
27
+ * Returns the BridgeKitty config directory. Resolution order (highest priority first):
28
+ * 1. BRIDGEKITTY_CONFIG env var → use as explicit path
29
+ * 2. ./.bridgekitty/ (local directory) → preferred for sandboxed agents
30
+ * 3. BRIDGEKITTY_HOME env var (if set)
31
+ * 4. ~/.bridgekitty/ (home directory) → traditional default
32
+ *
33
+ * Creates the directory if it doesn't exist (mode 0o700).
34
+ */
35
+ export function getConfigDir() {
36
+ let dir;
37
+ if (process.env.BRIDGEKITTY_CONFIG) {
38
+ // Explicit config path takes highest priority
39
+ dir = process.env.BRIDGEKITTY_CONFIG;
40
+ }
41
+ else {
42
+ // Check local directory first (helps sandboxed agents)
43
+ const localDir = path.join(process.cwd(), ".bridgekitty");
44
+ const localEnv = path.join(localDir, ".env");
45
+ if (fs.existsSync(localEnv)) {
46
+ dir = localDir;
47
+ }
48
+ else if (process.env.BRIDGEKITTY_HOME) {
49
+ dir = process.env.BRIDGEKITTY_HOME;
50
+ }
51
+ else {
52
+ dir = path.join(os.homedir(), ".bridgekitty");
53
+ }
54
+ }
55
+ if (!fs.existsSync(dir)) {
56
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
57
+ }
58
+ return dir;
59
+ }
60
+ const EVM_CHAINS = {
61
+ ethereum: { chainId: 1, symbol: "ETH" },
62
+ optimism: { chainId: 10, symbol: "ETH" },
63
+ bsc: { chainId: 56, symbol: "BNB" },
64
+ polygon: { chainId: 137, symbol: "POL" },
65
+ arbitrum: { chainId: 42161, symbol: "ETH" },
66
+ avalanche: { chainId: 43114, symbol: "AVAX" },
67
+ base: { chainId: 8453, symbol: "ETH" },
68
+ linea: { chainId: 59144, symbol: "ETH" },
69
+ scroll: { chainId: 534352, symbol: "ETH" },
70
+ zksync: { chainId: 324, symbol: "ETH" },
71
+ mantle: { chainId: 5000, symbol: "MNT" },
72
+ blast: { chainId: 81457, symbol: "ETH" },
73
+ };
74
+ const PERSISTENCE_REST = "https://rest.core.persistence.one";
75
+ const SOLANA_RPC = "https://api.mainnet-beta.solana.com";
76
+ // Key ERC-20 tokens to check by default on each chain
77
+ const DEFAULT_ERC20_TOKENS = {
78
+ 1: [
79
+ { symbol: "USDC", address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 },
80
+ { symbol: "USDT", address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6 },
81
+ { symbol: "WETH", address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", decimals: 18 },
82
+ { symbol: "WBTC", address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", decimals: 8 },
83
+ { symbol: "CBBTC", address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", decimals: 8 },
84
+ ],
85
+ 10: [
86
+ { symbol: "USDC", address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", decimals: 6 },
87
+ { symbol: "USDT", address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", decimals: 6 },
88
+ { symbol: "WETH", address: "0x4200000000000000000000000000000000000006", decimals: 18 },
89
+ ],
90
+ 56: [
91
+ { symbol: "BTCB", address: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", decimals: 18 },
92
+ { symbol: "USDC", address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", decimals: 18 },
93
+ { symbol: "USDT", address: "0x55d398326f99059fF775485246999027B3197955", decimals: 18 },
94
+ { symbol: "WETH", address: "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", decimals: 18 },
95
+ ],
96
+ 137: [
97
+ { symbol: "USDC", address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6 },
98
+ { symbol: "USDT", address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", decimals: 6 },
99
+ { symbol: "WETH", address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", decimals: 18 },
100
+ { symbol: "WBTC", address: "0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6", decimals: 8 },
101
+ ],
102
+ 42161: [
103
+ { symbol: "USDC", address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6 },
104
+ { symbol: "USDT", address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", decimals: 6 },
105
+ { symbol: "WETH", address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", decimals: 18 },
106
+ { symbol: "WBTC", address: "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", decimals: 8 },
107
+ ],
108
+ 43114: [
109
+ { symbol: "USDC", address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6 },
110
+ { symbol: "USDT", address: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", decimals: 6 },
111
+ { symbol: "WETH", address: "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB", decimals: 18 },
112
+ { symbol: "WBTC", address: "0x50b7545627a5162F82A992c33b87aDc75187B218", decimals: 8 },
113
+ ],
114
+ 8453: [
115
+ { symbol: "USDC", address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6 },
116
+ { symbol: "CBBTC", address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", decimals: 8 },
117
+ { symbol: "WETH", address: "0x4200000000000000000000000000000000000006", decimals: 18 },
118
+ ],
119
+ };
120
+ const ERC20_BALANCE_ABI = ["function balanceOf(address) view returns (uint256)"];
121
+ // ─── USD Price Cache ─────────────────────────────────────────────────────
122
+ const COINGECKO_API = "https://api.coingecko.com/api/v3";
123
+ const PRICE_CACHE_TTL_MS = 60_000; // 60 seconds
124
+ let priceCache = null;
125
+ // CoinGecko IDs for native tokens
126
+ const COINGECKO_IDS = {
127
+ ETH: "ethereum",
128
+ WETH: "ethereum",
129
+ BNB: "binancecoin",
130
+ POL: "matic-network",
131
+ MATIC: "matic-network",
132
+ AVAX: "avalanche-2",
133
+ MNT: "mantle",
134
+ XPRT: "persistence",
135
+ SOL: "solana",
136
+ BTC: "bitcoin",
137
+ WBTC: "bitcoin",
138
+ CBBTC: "bitcoin",
139
+ BTCB: "bitcoin",
140
+ USDC: "usd-coin",
141
+ USDT: "tether",
142
+ };
143
+ async function fetchUsdPrices() {
144
+ const now = Date.now();
145
+ if (priceCache && now - priceCache.fetchedAt < PRICE_CACHE_TTL_MS) {
146
+ return priceCache.prices;
147
+ }
148
+ try {
149
+ const ids = Object.values(COINGECKO_IDS).join(",");
150
+ const controller = new AbortController();
151
+ const timer = setTimeout(() => controller.abort(), 10_000);
152
+ try {
153
+ const res = await fetch(`${COINGECKO_API}/simple/price?ids=${ids}&vs_currencies=usd`, { signal: controller.signal });
154
+ if (!res.ok)
155
+ throw new Error(`CoinGecko ${res.status}`);
156
+ const data = await res.json();
157
+ const prices = {};
158
+ for (const [symbol, cgId] of Object.entries(COINGECKO_IDS)) {
159
+ if (data[cgId]?.usd) {
160
+ prices[symbol] = data[cgId].usd;
161
+ }
162
+ }
163
+ priceCache = { prices, fetchedAt: now };
164
+ return prices;
165
+ }
166
+ finally {
167
+ clearTimeout(timer);
168
+ }
169
+ }
170
+ catch {
171
+ // Return cached prices if available, even if stale
172
+ return priceCache?.prices ?? {};
173
+ }
174
+ }
175
+ async function fetchJson(url, init) {
176
+ const controller = new AbortController();
177
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
178
+ try {
179
+ const res = await fetch(url, { ...init, signal: controller.signal });
180
+ if (!res.ok) {
181
+ const text = await res.text().catch(() => "");
182
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
183
+ }
184
+ return res.json();
185
+ }
186
+ finally {
187
+ clearTimeout(timer);
188
+ }
189
+ }
190
+ export function registerWalletTools(server) {
191
+ // ─── wallet_status ────────────────────────────────────────────────────────
192
+ server.tool("wallet_status", "Check if a wallet is configured. Returns wallet address, key status, and config file location. Call this FIRST before wallet_setup or wallet_import.", {}, async () => {
193
+ const configDir = getConfigDir();
194
+ const envPath = path.resolve(configDir, ".env");
195
+ const hasEnvFile = fs.existsSync(envPath);
196
+ const pk = getKey("privateKey");
197
+ const mn = getKey("mnemonic");
198
+ const sol = getKey("solanaKey");
199
+ if (pk) {
200
+ const address = new ethers.Wallet(pk).address;
201
+ return {
202
+ content: [{
203
+ type: "text",
204
+ text: JSON.stringify({
205
+ status: "ready",
206
+ wallet: address,
207
+ hasPrivateKey: true,
208
+ hasMnemonic: !!mn,
209
+ hasSolanaKey: !!sol,
210
+ configFile: envPath,
211
+ chains: {
212
+ evm: Object.keys(EVM_CHAINS),
213
+ persistence: mn ? "available" : "unavailable (no mnemonic)",
214
+ solana: sol ? "available" : "unavailable (no solana key)",
215
+ },
216
+ }, null, 2),
217
+ }],
218
+ };
219
+ }
220
+ // No wallet loaded
221
+ return {
222
+ content: [{
223
+ type: "text",
224
+ text: JSON.stringify({
225
+ status: "not_configured",
226
+ configFile: envPath,
227
+ configFileExists: hasEnvFile,
228
+ hint: hasEnvFile
229
+ ? `Config file exists at ${envPath} but no keys were loaded. Check format: MNEMONIC=word1 word2 ... and/or PRIVATE_KEY=0x...`
230
+ : `No wallet configured. Either: (1) run wallet_setup to generate a new wallet, or (2) add keys to ${envPath} with format: MNEMONIC=word1 word2 ... / PRIVATE_KEY=0x...`,
231
+ }, null, 2),
232
+ }],
233
+ };
234
+ });
235
+ // ─── wallet_setup ─────────────────────────────────────────────────────────
236
+ server.tool("wallet_setup", "Create wallets for all supported chains (EVM, Cosmos, Solana). Keys saved to ~/.bridgekitty/.env. Use wallet_status first to check if already configured.", {}, async () => {
237
+ try {
238
+ // C-1: Check if .env already exists with keys — refuse to overwrite
239
+ const envPath = path.resolve(getConfigDir(), ".env");
240
+ if (fs.existsSync(envPath)) {
241
+ const existing = fs.readFileSync(envPath, "utf-8");
242
+ if (existing.includes("PRIVATE_KEY")) {
243
+ return {
244
+ content: [{
245
+ type: "text",
246
+ text: `ERROR: ${envPath} already contains PRIVATE_KEY. To regenerate wallets, delete the existing file first (back it up!). This safeguard prevents accidental key loss.`,
247
+ }],
248
+ isError: true,
249
+ };
250
+ }
251
+ }
252
+ // 1. Generate a single mnemonic (24 words) — used for ALL chains
253
+ const { Secp256k1HdWallet } = await import("@cosmjs/amino");
254
+ const cosmosWallet = await Secp256k1HdWallet.generate(24, { prefix: "persistence" });
255
+ const [cosmosAccount] = await cosmosWallet.getAccounts();
256
+ const persistenceAddress = cosmosAccount.address;
257
+ const mnemonic = cosmosWallet.mnemonic;
258
+ // 2. Derive EVM wallet from the same mnemonic (BIP-44 m/44'/60'/0'/0/0)
259
+ const evmWallet = ethers.HDNodeWallet.fromMnemonic(ethers.Mnemonic.fromPhrase(mnemonic), "m/44'/60'/0'/0/0");
260
+ const evmAddress = evmWallet.address;
261
+ const privateKey = evmWallet.privateKey;
262
+ // 3. Derive Solana wallet from the same mnemonic (BIP-44 m/44'/501'/0'/0')
263
+ let solanaAddress;
264
+ let solanaPrivateKey;
265
+ try {
266
+ const { Keypair } = await import("@solana/web3.js");
267
+ const { derivePath } = await import("ed25519-hd-key");
268
+ const bip39 = await import("@scure/bip39");
269
+ const seed = await bip39.mnemonicToSeed(mnemonic);
270
+ const derived = derivePath("m/44'/501'/0'/0'", Buffer.from(seed).toString("hex"));
271
+ const solanaKeypair = Keypair.fromSeed(derived.key);
272
+ solanaAddress = solanaKeypair.publicKey.toBase58();
273
+ const bs58 = await import("bs58");
274
+ solanaPrivateKey = bs58.default.encode(solanaKeypair.secretKey);
275
+ }
276
+ catch (depErr) {
277
+ // M-4: No silent fallback — tell user to install deps
278
+ const msg = depErr.message || "";
279
+ if (msg.includes("Cannot find") || msg.includes("MODULE_NOT_FOUND") || msg.includes("ed25519-hd-key") || msg.includes("@scure/bip39")) {
280
+ return {
281
+ content: [{
282
+ type: "text",
283
+ text: "Solana key derivation failed: missing dependency. Install with: npm install ed25519-hd-key @scure/bip39",
284
+ }],
285
+ isError: true,
286
+ };
287
+ }
288
+ throw depErr;
289
+ }
290
+ // 4. Save to .env
291
+ const envContent = `MNEMONIC=${mnemonic}\nPRIVATE_KEY=${privateKey}\nSOLANA_PRIVATE_KEY=${solanaPrivateKey}\n`;
292
+ fs.writeFileSync(envPath, envContent, { mode: 0o600 });
293
+ // H-1: Set in keyStore, NOT process.env
294
+ keyStore.privateKey = privateKey;
295
+ keyStore.mnemonic = mnemonic;
296
+ keyStore.solanaKey = solanaPrivateKey;
297
+ return {
298
+ content: [{
299
+ type: "text",
300
+ text: JSON.stringify({
301
+ status: "success",
302
+ wallets: {
303
+ evm: {
304
+ address: evmAddress,
305
+ chains: Object.keys(EVM_CHAINS),
306
+ },
307
+ persistence: persistenceAddress,
308
+ solana: solanaAddress,
309
+ },
310
+ note: "⚠️ IMPORTANT: Back up your .env file NOW — it contains your private keys. If lost, your funds are unrecoverable. Store a copy in a secure location.",
311
+ envPath,
312
+ nextStep: "Fund your EVM wallet to start bridging. Use xprt_farm_prepare to start XPRT farming.",
313
+ }, null, 2),
314
+ }],
315
+ };
316
+ }
317
+ catch (err) {
318
+ return {
319
+ content: [{ type: "text", text: `Setup failed: ${sanitizeError(err)}` }],
320
+ isError: true,
321
+ };
322
+ }
323
+ });
324
+ // ─── wallet_balance ───────────────────────────────────────────────────────
325
+ server.tool("wallet_balance", "Check wallet balances across EVM, Cosmos, and Solana chains. " +
326
+ "Returns native token balances AND key ERC-20 token balances (USDC, USDT, WETH, WBTC, cbBTC, BTCB) by default. " +
327
+ "Uses multiple RPCs with automatic failover. " +
328
+ "Returns per-token balance, USD value (via CoinGecko), and total portfolio value. " +
329
+ "Call this before any bridging or farming operation to verify sufficient funds and gas.", {
330
+ chains: z.array(z.string()).optional().describe("Chains to check (default: all). Options: ethereum, optimism, bsc, polygon, arbitrum, avalanche, base, linea, scroll, zksync, mantle, blast, persistence, solana"),
331
+ includeUsd: z.boolean().default(true).describe("Include USD valuations for each balance (default: true). Uses CoinGecko prices, cached for 60s."),
332
+ includeTokens: z.boolean().default(true).describe("Include ERC-20 token balances (USDC, USDT, WETH, WBTC, cbBTC, BTCB). Default: true."),
333
+ tokens: z.record(z.string(), z.array(z.object({
334
+ address: z.string().describe("Token contract address (0x...)"),
335
+ symbol: z.string().describe("Token symbol"),
336
+ decimals: z.number().describe("Token decimals"),
337
+ }))).optional().describe("Custom tokens to check per chain, e.g. { \"base\": [{ \"address\": \"0x...\", \"symbol\": \"FOO\", \"decimals\": 18 }] }"),
338
+ }, async (params) => {
339
+ const privateKey = getKey("privateKey");
340
+ const mnemonic = getKey("mnemonic");
341
+ const solanaKey = getKey("solanaKey");
342
+ if (!privateKey) {
343
+ const envPath = path.resolve(getConfigDir(), ".env");
344
+ return {
345
+ content: [{ type: "text", text: `No wallet configured. Add keys to ${envPath} (MNEMONIC=... / PRIVATE_KEY=0x...) or run wallet_setup to generate new keys. Use wallet_status to check.` }],
346
+ isError: true,
347
+ };
348
+ }
349
+ const evmAddress = new ethers.Wallet(privateKey).address;
350
+ const chainsToCheck = params.chains ?? [...Object.keys(EVM_CHAINS), "persistence", "solana"];
351
+ // Fetch USD prices in parallel with balance checks
352
+ const pricesPromise = params.includeUsd ? fetchUsdPrices() : Promise.resolve({});
353
+ const balances = {};
354
+ // EVM chains — same address on all chains, fetch balances in parallel
355
+ const evmChains = chainsToCheck
356
+ .filter((c) => EVM_CHAINS[c])
357
+ .map((c) => ({ name: c, ...EVM_CHAINS[c] }));
358
+ const evmResults = await Promise.allSettled(evmChains.map(async (chain) => {
359
+ const provider = await getProvider(chain.chainId);
360
+ const bal = await provider.getBalance(evmAddress);
361
+ return { name: chain.name, symbol: chain.symbol, balance: ethers.formatEther(bal) };
362
+ }));
363
+ const prices = await pricesPromise;
364
+ for (const result of evmResults) {
365
+ if (result.status === "fulfilled") {
366
+ const { name, symbol, balance } = result.value;
367
+ const entry = { balance, symbol };
368
+ if (params.includeUsd) {
369
+ const price = prices[symbol];
370
+ entry.usdValue = price ? Math.round(parseFloat(balance) * price * 100) / 100 : null;
371
+ }
372
+ balances[`${name} (${symbol})`] = entry;
373
+ }
374
+ else {
375
+ const idx = evmResults.indexOf(result);
376
+ const chain = evmChains[idx];
377
+ balances[`${chain.name} (${chain.symbol})`] = {
378
+ balance: "0",
379
+ symbol: chain.symbol,
380
+ usdValue: null,
381
+ error: `RPC unavailable: ${sanitizeError(result.reason)}`,
382
+ };
383
+ }
384
+ }
385
+ // ERC-20 token balances
386
+ if (params.includeTokens !== false) {
387
+ const erc20Promises = [];
388
+ for (const chain of evmChains) {
389
+ // Get default tokens for this chain
390
+ let tokensToCheck = DEFAULT_ERC20_TOKENS[chain.chainId] ?? [];
391
+ // Add custom tokens if specified
392
+ if (params.tokens?.[chain.name]) {
393
+ tokensToCheck = [...tokensToCheck, ...params.tokens[chain.name]];
394
+ }
395
+ for (const token of tokensToCheck) {
396
+ erc20Promises.push((async () => {
397
+ try {
398
+ const provider = await getProvider(chain.chainId);
399
+ const contract = new ethers.Contract(token.address, ERC20_BALANCE_ABI, provider);
400
+ const bal = await contract.balanceOf(evmAddress);
401
+ const balance = ethers.formatUnits(bal, token.decimals);
402
+ return { key: `${chain.name} (${token.symbol})`, symbol: token.symbol, balance };
403
+ }
404
+ catch (err) {
405
+ // Log the error for debugging, return zero so default tokens always appear
406
+ console.error(`[wallet] ERC20 balance check failed for ${token.symbol} on ${chain.name} (${token.address}): ${err.message?.slice(0, 100)}`);
407
+ return { key: `${chain.name} (${token.symbol})`, symbol: token.symbol, balance: "0" };
408
+ }
409
+ })());
410
+ }
411
+ }
412
+ const erc20Results = await Promise.allSettled(erc20Promises);
413
+ for (const result of erc20Results) {
414
+ if (result.status === "fulfilled" && result.value !== null) {
415
+ const { key, symbol, balance } = result.value;
416
+ const entry = { balance, symbol };
417
+ if (params.includeUsd) {
418
+ // Map ERC-20 symbols to price keys
419
+ const priceKey = {
420
+ USDC: "USDC", USDT: "USDT", WETH: "WETH", WBTC: "WBTC",
421
+ CBBTC: "CBBTC", BTCB: "BTCB", DAI: "USDC",
422
+ };
423
+ const pk = priceKey[symbol];
424
+ if (pk && prices[pk]) {
425
+ entry.usdValue = Math.round(parseFloat(balance) * prices[pk] * 100) / 100;
426
+ }
427
+ else if (["USDC", "USDT", "DAI"].includes(symbol)) {
428
+ entry.usdValue = Math.round(parseFloat(balance) * 100) / 100; // ~$1 fallback
429
+ }
430
+ else {
431
+ entry.usdValue = null;
432
+ }
433
+ }
434
+ balances[key] = entry;
435
+ }
436
+ }
437
+ }
438
+ // Persistence XPRT (liquid + staked + unbonding + rewards)
439
+ if (chainsToCheck.includes("persistence") && mnemonic) {
440
+ try {
441
+ const { Secp256k1HdWallet } = await import("@cosmjs/amino");
442
+ const wallet = await Secp256k1HdWallet.fromMnemonic(mnemonic, { prefix: "persistence" });
443
+ const [account] = await wallet.getAccounts();
444
+ const address = account.address;
445
+ // Fetch all data in parallel
446
+ const [bankData, delegationsData, unbondingData, rewardsData] = await Promise.allSettled([
447
+ fetchJson(`https://rest.cosmos.directory/persistence/cosmos/bank/v1beta1/balances/${address}`),
448
+ fetchJson(`https://rest.cosmos.directory/persistence/cosmos/staking/v1beta1/delegations/${address}`),
449
+ fetchJson(`https://rest.cosmos.directory/persistence/cosmos/staking/v1beta1/delegators/${address}/unbonding_delegations`),
450
+ fetchJson(`https://rest.cosmos.directory/persistence/cosmos/distribution/v1beta1/delegators/${address}/rewards`),
451
+ ]);
452
+ // Parse liquid balance
453
+ const xprt = bankData.status === "fulfilled" ?
454
+ bankData.value.balances?.find((b) => b.denom === "uxprt") : null;
455
+ const liquidAmount = xprt ? (parseInt(xprt.amount) / 1e6) : 0;
456
+ // Parse staked balance
457
+ let stakedAmount = 0;
458
+ const delegations = [];
459
+ if (delegationsData.status === "fulfilled" && delegationsData.value.delegation_responses) {
460
+ for (const del of delegationsData.value.delegation_responses) {
461
+ const amount = parseInt(del.balance?.amount || "0") / 1e6;
462
+ stakedAmount += amount;
463
+ delegations.push({
464
+ validator: del.delegation?.validator_address || "unknown",
465
+ amount: amount.toFixed(2),
466
+ });
467
+ }
468
+ }
469
+ // Parse unbonding balance
470
+ let unbondingAmount = 0;
471
+ if (unbondingData.status === "fulfilled" && unbondingData.value.unbonding_responses) {
472
+ for (const unbond of unbondingData.value.unbonding_responses) {
473
+ for (const entry of unbond.entries || []) {
474
+ unbondingAmount += parseInt(entry.balance || "0") / 1e6;
475
+ }
476
+ }
477
+ }
478
+ // Parse pending rewards
479
+ let pendingRewards = 0;
480
+ if (rewardsData.status === "fulfilled" && rewardsData.value.rewards) {
481
+ for (const reward of rewardsData.value.rewards) {
482
+ for (const coin of reward.reward || []) {
483
+ if (coin.denom === "uxprt") {
484
+ pendingRewards += parseFloat(coin.amount || "0") / 1e6;
485
+ }
486
+ }
487
+ }
488
+ }
489
+ const totalPosition = liquidAmount + stakedAmount + unbondingAmount + pendingRewards;
490
+ // Determine multiplier tier from rewards API (canonical source)
491
+ let currentMultiplier = "1x";
492
+ let nextMultiplierTier = null;
493
+ let xprtNeededForNextTier = null;
494
+ let tierName = "Explorer";
495
+ try {
496
+ const today = new Date().toISOString().slice(0, 10);
497
+ const tierData = await fetchJson(`https://rewards.interop.persistence.one/tiers/${address}?blockDate=${today}`);
498
+ if (tierData.multiplier) {
499
+ currentMultiplier = `${tierData.multiplier}x`;
500
+ }
501
+ if (tierData.tier) {
502
+ tierName = tierData.tier;
503
+ }
504
+ if (tierData.nextMultiplierMilestone) {
505
+ const nextStake = tierData.nextMultiplierMilestone.stake;
506
+ const nextMult = tierData.nextMultiplierMilestone.multiplier;
507
+ nextMultiplierTier = `${nextMult}x`;
508
+ xprtNeededForNextTier = Math.max(0, nextStake - stakedAmount);
509
+ }
510
+ }
511
+ catch {
512
+ // Fallback to hardcoded tiers if API is unavailable
513
+ if (stakedAmount >= 1000000) {
514
+ currentMultiplier = "5x";
515
+ tierName = "Pioneer";
516
+ }
517
+ else if (stakedAmount >= 10000) {
518
+ currentMultiplier = "3x";
519
+ tierName = "Voyager";
520
+ nextMultiplierTier = "5x";
521
+ xprtNeededForNextTier = 1000000 - stakedAmount;
522
+ }
523
+ else {
524
+ currentMultiplier = "1x";
525
+ tierName = "Explorer";
526
+ nextMultiplierTier = "3x";
527
+ xprtNeededForNextTier = 10000 - stakedAmount;
528
+ }
529
+ }
530
+ const persistenceBalance = {
531
+ liquid: liquidAmount.toFixed(6),
532
+ staked: stakedAmount.toFixed(2),
533
+ unbonding: unbondingAmount.toFixed(2),
534
+ pendingRewards: pendingRewards.toFixed(2),
535
+ totalPosition: totalPosition.toFixed(2),
536
+ currentMultiplier,
537
+ tier: tierName,
538
+ delegations,
539
+ };
540
+ if (nextMultiplierTier) {
541
+ persistenceBalance.nextMultiplierTier = nextMultiplierTier;
542
+ persistenceBalance.xprtNeededForNextTier = xprtNeededForNextTier?.toFixed(0);
543
+ }
544
+ // Create balance entry with total position
545
+ const entry = { balance: totalPosition.toFixed(6), symbol: "XPRT" };
546
+ if (params.includeUsd) {
547
+ const price = prices["XPRT"];
548
+ entry.usdValue = price ? Math.round(totalPosition * price * 100) / 100 : null;
549
+ }
550
+ // Add the detailed structure to the entry
551
+ entry.details = persistenceBalance;
552
+ balances["persistence (XPRT)"] = entry;
553
+ }
554
+ catch (err) {
555
+ balances["persistence (XPRT)"] = { balance: `error: ${sanitizeError(err)}`, symbol: "XPRT", usdValue: null };
556
+ }
557
+ }
558
+ // Solana
559
+ if (chainsToCheck.includes("solana") && solanaKey) {
560
+ try {
561
+ const bs58 = await import("bs58");
562
+ const { Keypair, Connection } = await import("@solana/web3.js");
563
+ const secretKey = bs58.default.decode(solanaKey);
564
+ const keypair = Keypair.fromSecretKey(secretKey);
565
+ const connection = new Connection(SOLANA_RPC);
566
+ const lamports = await connection.getBalance(keypair.publicKey);
567
+ const solBalance = (lamports / 1e9).toFixed(9);
568
+ const entry = { balance: solBalance, symbol: "SOL" };
569
+ if (params.includeUsd) {
570
+ const price = prices["SOL"];
571
+ entry.usdValue = price ? Math.round(parseFloat(solBalance) * price * 100) / 100 : null;
572
+ }
573
+ balances["solana (SOL)"] = entry;
574
+ }
575
+ catch (err) {
576
+ balances["solana (SOL)"] = { balance: `error: ${sanitizeError(err)}`, symbol: "SOL", usdValue: null };
577
+ }
578
+ }
579
+ // Calculate total portfolio USD value
580
+ let totalPortfolioUsd = null;
581
+ if (params.includeUsd) {
582
+ let total = 0;
583
+ let hasAnyPrice = false;
584
+ for (const entry of Object.values(balances)) {
585
+ if (entry.usdValue !== null && entry.usdValue !== undefined && entry.usdValue > 0) {
586
+ total += entry.usdValue;
587
+ hasAnyPrice = true;
588
+ }
589
+ }
590
+ totalPortfolioUsd = hasAnyPrice ? Math.round(total * 100) / 100 : null;
591
+ }
592
+ return {
593
+ content: [{
594
+ type: "text",
595
+ text: JSON.stringify({
596
+ wallet: evmAddress,
597
+ balances,
598
+ ...(params.includeUsd ? { totalPortfolioUsd } : {}),
599
+ }, null, 2),
600
+ }],
601
+ };
602
+ });
603
+ // ─── wallet_import ──────────────────────────────────────────────────────
604
+ server.tool("wallet_import", "Import existing keys. Alternative: edit ~/.bridgekitty/.env directly (MNEMONIC=<words>, PRIVATE_KEY=0x<hex>). Mnemonic gives EVM + Persistence + Solana; privateKey alone gives EVM only. Use wallet_status to check current state.", {
605
+ mnemonic: z.string().optional().describe("12 or 24 word BIP-39 mnemonic phrase"),
606
+ privateKey: z.string().optional().describe("0x-prefixed hex EVM private key"),
607
+ overwrite: z.boolean().default(false).describe("Set true to overwrite existing keys (back up first!)"),
608
+ }, async (params) => {
609
+ try {
610
+ // Validate: at least one must be provided
611
+ if (!params.mnemonic && !params.privateKey) {
612
+ return {
613
+ content: [{ type: "text", text: "ERROR: Provide at least one of 'mnemonic' or 'privateKey'." }],
614
+ isError: true,
615
+ };
616
+ }
617
+ // Validate privateKey format
618
+ let evmAddress;
619
+ if (params.privateKey) {
620
+ if (!/^0x[a-fA-F0-9]{64}$/.test(params.privateKey)) {
621
+ return {
622
+ content: [{ type: "text", text: "ERROR: privateKey must be 0x-prefixed followed by 64 hex characters." }],
623
+ isError: true,
624
+ };
625
+ }
626
+ try {
627
+ evmAddress = new ethers.Wallet(params.privateKey).address;
628
+ }
629
+ catch (e) {
630
+ return {
631
+ content: [{ type: "text", text: `ERROR: Invalid private key: ${sanitizeError(e)}` }],
632
+ isError: true,
633
+ };
634
+ }
635
+ }
636
+ // Validate mnemonic
637
+ if (params.mnemonic) {
638
+ const words = params.mnemonic.trim().split(/\s+/);
639
+ if (words.length !== 12 && words.length !== 24) {
640
+ return {
641
+ content: [{ type: "text", text: `ERROR: Mnemonic must be 12 or 24 words. Got ${words.length}.` }],
642
+ isError: true,
643
+ };
644
+ }
645
+ try {
646
+ ethers.Mnemonic.fromPhrase(params.mnemonic.trim());
647
+ }
648
+ catch (e) {
649
+ return {
650
+ content: [{ type: "text", text: `ERROR: Invalid BIP-39 mnemonic: ${sanitizeError(e)}` }],
651
+ isError: true,
652
+ };
653
+ }
654
+ }
655
+ // C-1: Overwrite protection
656
+ const envPath = path.resolve(getConfigDir(), ".env");
657
+ if (!params.overwrite && fs.existsSync(envPath)) {
658
+ const existing = fs.readFileSync(envPath, "utf-8");
659
+ if (existing.includes("PRIVATE_KEY") || existing.includes("MNEMONIC")) {
660
+ return {
661
+ content: [{
662
+ type: "text",
663
+ text: `ERROR: ${envPath} already contains keys. Pass overwrite=true to replace (back up first!).`,
664
+ }],
665
+ isError: true,
666
+ };
667
+ }
668
+ }
669
+ // Derive addresses
670
+ const wallets = {};
671
+ let finalPrivateKey = params.privateKey;
672
+ const finalMnemonic = params.mnemonic?.trim();
673
+ let solanaPrivateKey;
674
+ if (finalMnemonic) {
675
+ // Derive EVM from mnemonic (only if no explicit privateKey)
676
+ if (!finalPrivateKey) {
677
+ const hdWallet = ethers.HDNodeWallet.fromMnemonic(ethers.Mnemonic.fromPhrase(finalMnemonic), "m/44'/60'/0'/0/0");
678
+ finalPrivateKey = hdWallet.privateKey;
679
+ evmAddress = hdWallet.address;
680
+ }
681
+ // Derive Persistence address
682
+ const { Secp256k1HdWallet } = await import("@cosmjs/amino");
683
+ const cosmosWallet = await Secp256k1HdWallet.fromMnemonic(finalMnemonic, { prefix: "persistence" });
684
+ const [cosmosAccount] = await cosmosWallet.getAccounts();
685
+ wallets.persistence = cosmosAccount.address;
686
+ // Derive Solana address
687
+ try {
688
+ const { Keypair } = await import("@solana/web3.js");
689
+ const { derivePath } = await import("ed25519-hd-key");
690
+ const bip39 = await import("@scure/bip39");
691
+ const seed = await bip39.mnemonicToSeed(finalMnemonic);
692
+ const derived = derivePath("m/44'/501'/0'/0'", Buffer.from(seed).toString("hex"));
693
+ const solanaKeypair = Keypair.fromSeed(derived.key);
694
+ const bs58 = await import("bs58");
695
+ solanaPrivateKey = bs58.default.encode(solanaKeypair.secretKey);
696
+ wallets.solana = solanaKeypair.publicKey.toBase58();
697
+ }
698
+ catch (depErr) {
699
+ const msg = depErr.message || "";
700
+ if (msg.includes("Cannot find") || msg.includes("MODULE_NOT_FOUND") || msg.includes("ed25519-hd-key") || msg.includes("@scure/bip39")) {
701
+ wallets.solana = "skipped (install ed25519-hd-key @scure/bip39 for Solana support)";
702
+ }
703
+ else {
704
+ throw depErr;
705
+ }
706
+ }
707
+ }
708
+ wallets.evm = { address: evmAddress, chains: Object.keys(EVM_CHAINS) };
709
+ // Write .env
710
+ let envContent = "";
711
+ if (finalMnemonic)
712
+ envContent += `MNEMONIC=${finalMnemonic}\n`;
713
+ if (finalPrivateKey)
714
+ envContent += `PRIVATE_KEY=${finalPrivateKey}\n`;
715
+ if (solanaPrivateKey)
716
+ envContent += `SOLANA_PRIVATE_KEY=${solanaPrivateKey}\n`;
717
+ fs.writeFileSync(envPath, envContent, { mode: 0o600 });
718
+ // H-1: Update in-memory keyStore
719
+ if (finalPrivateKey)
720
+ keyStore.privateKey = finalPrivateKey;
721
+ if (finalMnemonic)
722
+ keyStore.mnemonic = finalMnemonic;
723
+ if (solanaPrivateKey)
724
+ keyStore.solanaKey = solanaPrivateKey;
725
+ return {
726
+ content: [{
727
+ type: "text",
728
+ text: JSON.stringify({
729
+ status: "imported",
730
+ wallets,
731
+ envPath,
732
+ note: "⚠️ Keys saved. Back up your .env file securely.",
733
+ }, null, 2),
734
+ }],
735
+ };
736
+ }
737
+ catch (err) {
738
+ return {
739
+ content: [{ type: "text", text: `Import failed: ${sanitizeError(err)}` }],
740
+ isError: true,
741
+ };
742
+ }
743
+ });
744
+ }