@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,1308 @@
1
+ import { z } from "zod";
2
+ import { ethers } from "ethers";
3
+ import { PersistenceBackend } from "../backends/persistence.js";
4
+ import { getKey, getConfigDir } from "./wallet.js";
5
+ import { sanitizeError } from "../utils/sanitize-error.js";
6
+ import { simulateTransaction } from "../utils/tx-simulator.js";
7
+ import { getProvider } from "../utils/gas-estimator.js";
8
+ import { createFillWatcher, checkTransferEvents, checkBalanceChange, getCurrentBlockNumber } from "../utils/fill-detector.js";
9
+ import * as path from "path";
10
+ const REWARDS_API = "https://rewards.interop.persistence.one";
11
+ const PERSISTENCE_REST = "https://rest.core.persistence.one";
12
+ const TIMEOUT_MS = 15_000;
13
+ const POLL_INTERVAL_MS = 5_000; // 5s between polls (standard)
14
+ const FAST_POLL_INTERVAL_MS = 2_000; // 2s for first 30s (aggressive phase)
15
+ const FAST_POLL_DURATION_MS = 30_000; // How long to use fast polling
16
+ const STATUS_API_INTERVAL = 3; // Check status API every Nth poll (secondary, often broken)
17
+ const POST_TIMEOUT_COOLDOWN_MS = 30_000; // Extra cooldown after timeouts for RPC propagation
18
+ /** Write timestamped progress to stderr (visible in MCP clients as notifications) */
19
+ function progress(msg) {
20
+ const ts = new Date().toISOString().slice(11, 19); // HH:MM:SS
21
+ console.error(`[xprt-farm ${ts}] ${msg}`);
22
+ }
23
+ // Token addresses
24
+ const CBTCB_BASE = "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf"; // cbBTC on Base (8 decimals)
25
+ const BTCB_BSC = "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c"; // BTCB on BSC (18 decimals)
26
+ const ERC20_BALANCE_ABI = [
27
+ "function balanceOf(address) view returns (uint256)",
28
+ ];
29
+ async function fetchJson(url, init) {
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
32
+ try {
33
+ const res = await fetch(url, { ...init, signal: controller.signal });
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => "");
36
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
37
+ }
38
+ return res.json();
39
+ }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ async function getBalance(chainId, tokenAddress, wallet) {
45
+ const provider = await getProvider(chainId);
46
+ const contract = new ethers.Contract(tokenAddress, ERC20_BALANCE_ABI, provider);
47
+ const bal = await contract.balanceOf(wallet);
48
+ return bal.toString();
49
+ }
50
+ function getMultiplierTier(xprtStaked) {
51
+ if (xprtStaked >= 1_000_000)
52
+ return { tier: "Pioneer", multiplier: "5x" };
53
+ if (xprtStaked >= 10_000)
54
+ return { tier: "Voyager", multiplier: "3x" };
55
+ return { tier: "Explorer", multiplier: "1x" };
56
+ }
57
+ // ─── Dynamic amount clamping ─────────────────────────────────────────────────
58
+ // Reads actual token balance, normalizes to 8-dec BTC, clamps to [MIN, MAX].
59
+ // Same caps as PersistenceBackend.validateAmount (MIN_AMOUNT_RAW / MAX_AMOUNT_RAW).
60
+ const MIN_BTC_8DEC = 5000n; // 0.00005 BTC (matches persistence.ts MIN_AMOUNT_RAW)
61
+ const MAX_BTC_8DEC = 100000n; // 0.001 BTC (matches persistence.ts MAX_AMOUNT_RAW)
62
+ // Reduced minimum for return legs (leg 2) — accounts for ~1% solver fee on the outbound leg.
63
+ // When leg 1 sends 0.00005 BTC, the solver returns ~0.0000495-0.0000499, which is below
64
+ // MIN_BTC_8DEC (5000) but above this threshold (4500 = 0.000045 BTC).
65
+ const MIN_BTC_8DEC_LEG2 = 4500n;
66
+ async function getClampedAmount(chainId, tokenAddress, walletAddress, decimals, userCapBtc,
67
+ /** If true, use reduced minimum and ignore cap — for return legs after solver fees */
68
+ isReturnLeg) {
69
+ const balanceRaw = BigInt(await getBalance(chainId, tokenAddress, walletAddress));
70
+ // Normalize to 8-decimal BTC (cbBTC is 8-dec, BTCB is 18-dec)
71
+ const balance8Dec = decimals > 8
72
+ ? balanceRaw / (10n ** BigInt(decimals - 8))
73
+ : balanceRaw;
74
+ const effectiveMin = isReturnLeg ? MIN_BTC_8DEC_LEG2 : MIN_BTC_8DEC;
75
+ if (balance8Dec < effectiveMin)
76
+ return null; // Below minimum
77
+ let clamped = balance8Dec > MAX_BTC_8DEC ? MAX_BTC_8DEC : balance8Dec;
78
+ // For return legs, don't apply user cap — send back whatever we received
79
+ if (!isReturnLeg && userCapBtc !== undefined) {
80
+ const cap8Dec = BigInt(Math.round(userCapBtc * 1e8));
81
+ if (cap8Dec < clamped)
82
+ clamped = cap8Dec;
83
+ }
84
+ if (clamped < effectiveMin)
85
+ clamped = effectiveMin;
86
+ // Convert back to native decimals for the quote
87
+ const amountRaw = decimals > 8
88
+ ? (clamped * (10n ** BigInt(decimals - 8))).toString()
89
+ : clamped.toString();
90
+ return { amountRaw, btc8Dec: clamped };
91
+ }
92
+ export function registerXprtFarmTools(server, engine) {
93
+ // ─── xprt_farm_prepare ─────────────────────────────────────────────────────
94
+ server.tool("xprt_farm_prepare", "Convert ETH or other tokens to cbBTC and bridge gas to BSC, preparing your wallet for XPRT farming via Persistence Interop.", {
95
+ amount: z.string().optional().describe("ETH amount to use (auto-detects balance if omitted)"),
96
+ }, async (params) => {
97
+ const privateKey = getKey("privateKey");
98
+ if (!privateKey) {
99
+ const envPath = path.resolve(getConfigDir(), ".env");
100
+ return {
101
+ 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.` }],
102
+ isError: true,
103
+ };
104
+ }
105
+ const signer = new ethers.Wallet(privateKey);
106
+ const walletAddress = signer.address;
107
+ // Check ETH balance on Base
108
+ const baseProvider = await getProvider(8453);
109
+ const ethBalance = await baseProvider.getBalance(walletAddress);
110
+ const ethBalanceEth = parseFloat(ethers.formatEther(ethBalance));
111
+ if (ethBalanceEth < 0.003) {
112
+ return {
113
+ content: [{
114
+ type: "text",
115
+ text: JSON.stringify({
116
+ error: "Insufficient ETH on Base",
117
+ balance: `${ethBalanceEth.toFixed(6)} ETH`,
118
+ needed: "At least 0.003 ETH (0.002 for gas reserve + 0.001 for swaps)",
119
+ action: `Send ETH to ${walletAddress} on Base (chain ID 8453)`,
120
+ }, null, 2),
121
+ }],
122
+ isError: true,
123
+ };
124
+ }
125
+ const totalEth = params.amount ? parseFloat(params.amount) : ethBalanceEth;
126
+ const gasReserve = 0.002;
127
+ const bnbSwapEth = 0.0002;
128
+ const cbBTCSwapEth = totalEth - gasReserve - bnbSwapEth;
129
+ if (cbBTCSwapEth <= 0) {
130
+ return {
131
+ content: [{
132
+ type: "text",
133
+ text: `Insufficient ETH. After gas reserve (${gasReserve}) and BNB swap (${bnbSwapEth}), nothing left for cbBTC. Balance: ${ethBalanceEth.toFixed(6)} ETH`,
134
+ }],
135
+ isError: true,
136
+ };
137
+ }
138
+ const steps = [];
139
+ // Step 1: Swap ETH → BNB on BSC via cross-chain bridge
140
+ try {
141
+ const bnbAmountRaw = ethers.parseEther(bnbSwapEth.toFixed(6)).toString();
142
+ const bnbQuoteParams = {
143
+ fromChainId: 8453,
144
+ toChainId: 56,
145
+ fromTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
146
+ toTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
147
+ amountRaw: bnbAmountRaw, fromAddress: walletAddress, preference: "cheapest",
148
+ };
149
+ const backends = engine.getAllBackends().filter(b => b.name !== "persistence");
150
+ let bestQuote = null;
151
+ for (const backend of backends) {
152
+ try {
153
+ const q = await backend.getQuote(bnbQuoteParams);
154
+ if (q && (!bestQuote || parseFloat(q.outputAmount) > parseFloat(bestQuote.outputAmount))) {
155
+ bestQuote = q;
156
+ }
157
+ }
158
+ catch { /* skip */ }
159
+ }
160
+ if (bestQuote) {
161
+ const backend = engine.getBackend(bestQuote.backendName);
162
+ if (backend) {
163
+ const tx = await backend.buildTransaction(bestQuote);
164
+ // Simulate before sending
165
+ const sim = await simulateTransaction(tx.chainId, { to: tx.to, data: tx.data, value: tx.value, from: walletAddress });
166
+ if (!sim.success)
167
+ throw new Error(`Simulation failed: ${sim.error}`);
168
+ const connectedSigner = signer.connect(await getProvider(tx.chainId));
169
+ const txResponse = await connectedSigner.sendTransaction({
170
+ to: tx.to,
171
+ data: tx.data,
172
+ value: tx.value,
173
+ ...(tx.gasLimit ? { gasLimit: tx.gasLimit } : {}),
174
+ });
175
+ await txResponse.wait();
176
+ steps.push({ step: "ETH→BNB (BSC gas)", status: "success", details: { txHash: txResponse.hash, amount: `${bnbSwapEth} ETH` } });
177
+ }
178
+ }
179
+ else {
180
+ steps.push({ step: "ETH→BNB (BSC gas)", status: "skipped", details: "No route found" });
181
+ }
182
+ }
183
+ catch (err) {
184
+ steps.push({ step: "ETH→BNB (BSC gas)", status: `failed: ${sanitizeError(err)}` });
185
+ }
186
+ // Step 2: Swap ETH → cbBTC on Base (same-chain)
187
+ try {
188
+ const cbBTCAmountRaw = ethers.parseEther(cbBTCSwapEth.toFixed(6)).toString();
189
+ const cbBTCQuoteParams = {
190
+ fromChainId: 8453,
191
+ toChainId: 8453,
192
+ fromTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
193
+ toTokenAddress: CBTCB_BASE,
194
+ amountRaw: cbBTCAmountRaw, fromAddress: walletAddress, preference: "cheapest",
195
+ };
196
+ const backends = engine.getAllBackends().filter(b => b.name !== "persistence");
197
+ let bestQuote = null;
198
+ for (const backend of backends) {
199
+ try {
200
+ const q = await backend.getQuote(cbBTCQuoteParams);
201
+ if (q && (!bestQuote || parseFloat(q.outputAmount) > parseFloat(bestQuote.outputAmount))) {
202
+ bestQuote = q;
203
+ }
204
+ }
205
+ catch { /* skip */ }
206
+ }
207
+ if (bestQuote) {
208
+ const backend = engine.getBackend(bestQuote.backendName);
209
+ if (backend) {
210
+ let tx = await backend.buildTransaction(bestQuote);
211
+ const connectedSigner = signer.connect(baseProvider);
212
+ if (tx.approvalTx) {
213
+ const approvalResponse = await connectedSigner.sendTransaction({
214
+ to: tx.approvalTx.to,
215
+ data: tx.approvalTx.data,
216
+ value: tx.approvalTx.value,
217
+ });
218
+ await approvalResponse.wait();
219
+ }
220
+ // Simulate before sending
221
+ const sim2 = await simulateTransaction(tx.chainId, { to: tx.to, data: tx.data, value: tx.value, from: walletAddress });
222
+ if (!sim2.success)
223
+ throw new Error(`Simulation failed: ${sim2.error}`);
224
+ const txResponse = await connectedSigner.sendTransaction({
225
+ to: tx.to,
226
+ data: tx.data,
227
+ value: tx.value,
228
+ ...(tx.gasLimit ? { gasLimit: tx.gasLimit } : {}),
229
+ });
230
+ await txResponse.wait();
231
+ steps.push({ step: "ETH→cbBTC (Base)", status: "success", details: { txHash: txResponse.hash, amount: `${cbBTCSwapEth.toFixed(6)} ETH` } });
232
+ }
233
+ }
234
+ else {
235
+ steps.push({ step: "ETH→cbBTC (Base)", status: "skipped", details: "No route found" });
236
+ }
237
+ }
238
+ catch (err) {
239
+ steps.push({ step: "ETH→cbBTC (Base)", status: `failed: ${sanitizeError(err)}` });
240
+ }
241
+ return {
242
+ content: [{
243
+ type: "text",
244
+ text: JSON.stringify({
245
+ status: "completed",
246
+ wallet: walletAddress,
247
+ gasReserve: `${gasReserve} ETH kept on Base`,
248
+ steps,
249
+ nextStep: "Run xprt_farm_start to begin XPRT farming",
250
+ }, null, 2),
251
+ }],
252
+ };
253
+ });
254
+ // ─── xprt_farm_start ────────────────────────────────────────────────────────
255
+ server.tool("xprt_farm_start", "Start XPRT farming by running automated BTC round-trip swaps between BSC and Base via Persistence Interop. " +
256
+ "Earn XPRT rewards distributed daily as airdrops — not guaranteed income. " +
257
+ "Preconditions: Requires wallet_setup to have been run. Requires cbBTC on Base and/or BTCB on BSC (min 0.00005 BTC). Requires gas on both Base and BSC. " +
258
+ "Call wallet_balance before starting to verify sufficient balances. " +
259
+ "Response includes per-leg detail: amountSent, amountReceived, feeBps, provider, status, and durationSeconds.", {
260
+ amount: z.string().optional().describe("Max BTC to send on the outbound leg of each round (e.g. '0.0003'). " +
261
+ "The return leg will bridge the full received balance back; actual return amount depends on bridge fees. " +
262
+ "Omit to use full available balance, clamped to protocol limits 0.00005–0.001 BTC."),
263
+ startFrom: z.enum(["base", "bsc", "auto"]).default("auto").describe("'auto' (detect best direction), 'base' (cbBTC→BTCB), or 'bsc' (BTCB→cbBTC)"),
264
+ rounds: z.number().default(10).describe("Number of round trips (default 10)"),
265
+ delay: z.number().default(30).describe("Delay between rounds in seconds (default 30)"),
266
+ fillTimeout: z.number().default(180).describe("Max seconds to wait for each leg fill (default 180, minimum 90)"),
267
+ maxFailures: z.number().default(3).describe("Stop after N consecutive failures (default 3)"),
268
+ maxLossBps: z.number().default(200).describe("Stop if cumulative loss exceeds N basis points (default 200 = 2%)"),
269
+ dryRun: z.boolean().optional().describe("Preview the farming operation without executing transactions (default: false)"),
270
+ }, async (params) => {
271
+ const privateKey = getKey("privateKey");
272
+ if (!privateKey) {
273
+ return {
274
+ content: [{ type: "text", text: `No wallet configured. Add keys to ${path.resolve(getConfigDir(), ".env")} (MNEMONIC=... / PRIVATE_KEY=0x...) or run wallet_setup to generate new keys. Use wallet_status to check.` }],
275
+ isError: true,
276
+ };
277
+ }
278
+ const signer = new ethers.Wallet(privateKey);
279
+ const walletAddress = signer.address;
280
+ const persistence = new PersistenceBackend();
281
+ // Parse optional user cap (in BTC)
282
+ const userCapBtc = params.amount ? parseFloat(params.amount) : undefined;
283
+ const effectiveTimeout = Math.max(params.fillTimeout, 90); // minimum 90s
284
+ const maxPolls = Math.ceil((effectiveTimeout * 1000) / POLL_INTERVAL_MS);
285
+ // Auto-detect best startFrom direction
286
+ let resolvedStartFrom = params.startFrom === "auto" ? "base" : params.startFrom;
287
+ if (params.startFrom === "auto") {
288
+ progress("Auto-detecting best direction...");
289
+ let baseBal = null;
290
+ let bscBal = null;
291
+ try {
292
+ baseBal = await getClampedAmount(8453, CBTCB_BASE, walletAddress, 8, userCapBtc);
293
+ }
294
+ catch { /* non-fatal */ }
295
+ try {
296
+ bscBal = await getClampedAmount(56, BTCB_BSC, walletAddress, 18, userCapBtc);
297
+ }
298
+ catch { /* non-fatal */ }
299
+ if (!baseBal && !bscBal) {
300
+ // Neither chain has enough — return detailed error with both balances
301
+ let baseRaw = "0", bscRaw = "0";
302
+ try {
303
+ baseRaw = await getBalance(8453, CBTCB_BASE, walletAddress);
304
+ }
305
+ catch { }
306
+ try {
307
+ bscRaw = await getBalance(56, BTCB_BSC, walletAddress);
308
+ }
309
+ catch { }
310
+ const baseHuman = (Number(baseRaw) / 1e8).toFixed(8);
311
+ const bscHuman = ethers.formatUnits(bscRaw, 18);
312
+ return {
313
+ content: [{
314
+ type: "text",
315
+ text: JSON.stringify({
316
+ error: "Insufficient BTC balance on both chains",
317
+ minimum: "0.00005 BTC per leg",
318
+ balances: {
319
+ "Base cbBTC": `${baseHuman} BTC`,
320
+ "BSC BTCB": `${bscHuman} BTC`,
321
+ },
322
+ action: "Run xprt_farm_prepare to convert ETH to cbBTC, or send BTC to your wallet.",
323
+ }, null, 2),
324
+ }],
325
+ isError: true,
326
+ };
327
+ }
328
+ if (baseBal && !bscBal) {
329
+ resolvedStartFrom = "base";
330
+ }
331
+ else if (bscBal && !baseBal) {
332
+ resolvedStartFrom = "bsc";
333
+ }
334
+ else {
335
+ // Both have balance — pick the one with more BTC
336
+ resolvedStartFrom = baseBal.btc8Dec >= bscBal.btc8Dec ? "base" : "bsc";
337
+ }
338
+ progress(`Auto-detected: starting from ${resolvedStartFrom} (${resolvedStartFrom === "base" ? "cbBTC" : "BTCB"})`);
339
+ }
340
+ // Compute leg configs based on resolved direction
341
+ const leg1 = resolvedStartFrom === "bsc"
342
+ ? { chainId: 56, destChainId: 8453, token: BTCB_BSC, destToken: CBTCB_BASE, decimals: 18, label: "BSC→Base" }
343
+ : { chainId: 8453, destChainId: 56, token: CBTCB_BASE, destToken: BTCB_BSC, decimals: 8, label: "Base→BSC" };
344
+ const leg2 = resolvedStartFrom === "bsc"
345
+ ? { chainId: 8453, destChainId: 56, token: CBTCB_BASE, destToken: BTCB_BSC, decimals: 8, label: "Base→BSC" }
346
+ : { chainId: 56, destChainId: 8453, token: BTCB_BSC, destToken: CBTCB_BASE, decimals: 18, label: "BSC→Base" };
347
+ const results = [];
348
+ let consecutiveFailures = 0;
349
+ let completedRounds = 0;
350
+ let totalLossBps = 0;
351
+ // Track loss using "home" chain balance (the chain we start from)
352
+ let initialHomeBalance8Dec = null;
353
+ try {
354
+ const homeBal = BigInt(await getBalance(leg1.chainId, leg1.token, walletAddress));
355
+ initialHomeBalance8Dec = leg1.decimals > 8
356
+ ? homeBal / (10n ** BigInt(leg1.decimals - 8))
357
+ : homeBal;
358
+ }
359
+ catch { /* non-fatal */ }
360
+ const directionLabel = resolvedStartFrom === "bsc" ? "BSC→Base→BSC" : "Base→BSC→Base";
361
+ // ── Dry Run Preview ──────────────────────────────────────────────
362
+ if (params.dryRun) {
363
+ progress("DRY RUN: Generating preview without executing...");
364
+ // Get current balances
365
+ let leg1BalanceRaw = "0";
366
+ let leg2BalanceRaw = "0";
367
+ try {
368
+ leg1BalanceRaw = await getBalance(leg1.chainId, leg1.token, walletAddress);
369
+ leg2BalanceRaw = await getBalance(leg2.destChainId, leg2.destToken, walletAddress);
370
+ }
371
+ catch (err) {
372
+ return {
373
+ content: [{
374
+ type: "text",
375
+ text: `DRY RUN FAILED: Could not fetch balances - ${err.message}`,
376
+ }],
377
+ isError: true,
378
+ };
379
+ }
380
+ const leg1Balance = ethers.formatUnits(leg1BalanceRaw, leg1.decimals);
381
+ const leg2Balance = ethers.formatUnits(leg2BalanceRaw, leg2.decimals);
382
+ // Get a quote for leg 1 to estimate output
383
+ let estimatedOutput = "unknown";
384
+ let estimatedLoss = "unknown";
385
+ try {
386
+ const clamped = await getClampedAmount(leg1.chainId, leg1.token, walletAddress, leg1.decimals, userCapBtc);
387
+ if (clamped) {
388
+ const quoteParams = {
389
+ fromChainId: leg1.chainId,
390
+ toChainId: leg1.destChainId,
391
+ fromTokenAddress: leg1.token,
392
+ toTokenAddress: leg1.destToken,
393
+ amountRaw: clamped.amountRaw,
394
+ fromAddress: walletAddress,
395
+ preference: "fastest",
396
+ };
397
+ const quote = await persistence.getQuote(quoteParams);
398
+ if (quote) {
399
+ const outputBtc = Number(quote.minOutputAmountRaw) / (leg1.destToken === CBTCB_BASE ? 1e8 : 1e18);
400
+ const inputBtc = Number(clamped.amountRaw) / (leg1.token === CBTCB_BASE ? 1e8 : 1e18);
401
+ const lossBps = Math.round(((inputBtc - outputBtc) / inputBtc) * 10000);
402
+ estimatedOutput = outputBtc.toFixed(8);
403
+ estimatedLoss = `~${lossBps} bps (${(lossBps / 100).toFixed(2)}%)`;
404
+ }
405
+ }
406
+ }
407
+ catch {
408
+ // Quote failed, keep unknown
409
+ }
410
+ const preview = {
411
+ dryRun: true,
412
+ preview: {
413
+ direction: directionLabel,
414
+ inputAmount: `${leg1Balance} ${leg1.label.includes("Base") ? "cbBTC" : "BTCB"}`,
415
+ estimatedOutput: estimatedOutput === "unknown" ? "unknown" : `${estimatedOutput} ${leg1.label.includes("BSC") ? "BTCB" : "cbBTC"}`,
416
+ estimatedSingleLegLoss: estimatedLoss,
417
+ estimatedRoundTripLoss: estimatedLoss === "unknown" ? "unknown" : `~${parseInt(estimatedLoss) * 2} bps`,
418
+ estimatedGas: "<$0.01 per leg on L2",
419
+ rounds: params.rounds,
420
+ totalEstimatedTime: `~${Math.ceil(params.rounds * (effectiveTimeout * 2 + params.delay) / 60)} minutes`,
421
+ balances: {
422
+ [leg1.label.split("→")[0]]: `${leg1Balance} ${leg1.label.includes("Base") ? "cbBTC" : "BTCB"}`,
423
+ [leg2.label.split("→")[0]]: `${leg2Balance} ${leg2.label.includes("Base") ? "cbBTC" : "BTCB"}`,
424
+ },
425
+ note: "Set dryRun=false to execute the farming operation.",
426
+ },
427
+ };
428
+ return {
429
+ content: [{
430
+ type: "text",
431
+ text: JSON.stringify(preview, null, 2),
432
+ }],
433
+ };
434
+ }
435
+ progress(`Starting ${params.rounds} rounds, direction: ${directionLabel}, fillTimeout: ${effectiveTimeout}s`);
436
+ for (let i = 0; i < params.rounds; i++) {
437
+ if (consecutiveFailures >= params.maxFailures) {
438
+ progress(`STOPPED: ${consecutiveFailures} consecutive failures (max: ${params.maxFailures})`);
439
+ break;
440
+ }
441
+ if (totalLossBps >= params.maxLossBps) {
442
+ progress(`STOPPED: cumulative loss ${totalLossBps} bps exceeds max ${params.maxLossBps}`);
443
+ break;
444
+ }
445
+ progress(`── Round ${i + 1}/${params.rounds} ──`);
446
+ const roundResult = { round: i + 1 };
447
+ let roundFailed = false;
448
+ let hadTimeout = false;
449
+ // ── Leg 1 ──────────────────────────────────────────────────────
450
+ try {
451
+ // Capture pre-leg destination balance for post-timeout verification
452
+ let preDestBalance1 = 0n;
453
+ try {
454
+ preDestBalance1 = BigInt(await getBalance(leg1.destChainId, leg1.destToken, walletAddress));
455
+ }
456
+ catch { /* non-fatal */ }
457
+ const clamped1 = await getClampedAmount(leg1.chainId, leg1.token, walletAddress, leg1.decimals, userCapBtc);
458
+ if (!clamped1)
459
+ throw new Error(`Balance below minimum 0.00005 BTC on ${leg1.label.split("→")[0]}`);
460
+ progress(`Leg 1 (${leg1.label}): ${Number(clamped1.btc8Dec) / 1e8} BTC`);
461
+ const quote1 = await persistence.getQuote({
462
+ fromChainId: leg1.chainId, toChainId: leg1.destChainId,
463
+ fromTokenAddress: leg1.token, toTokenAddress: leg1.destToken,
464
+ amountRaw: clamped1.amountRaw, fromAddress: walletAddress, preference: "cheapest",
465
+ });
466
+ if (!quote1)
467
+ throw new Error(`No quote available for ${leg1.label}`);
468
+ // Create push-based fill watcher BEFORE signing — gives the WS
469
+ // connections 5-15s to establish + subscribe while tx is being signed.
470
+ // Uses eth_subscribe("logs") which pushes events in real-time (3-15s),
471
+ // unlike eth_getLogs which is cached server-side for 30-120s.
472
+ const watcher1 = createFillWatcher(leg1.destChainId, leg1.destToken, walletAddress, () => progress(`Leg 1: fill PUSHED via eth_subscribe`));
473
+ // Capture destination chain block number for HTTP getLogs fallback.
474
+ let startBlock1;
475
+ try {
476
+ startBlock1 = Math.max(0, await getCurrentBlockNumber(leg1.destChainId) - 2);
477
+ }
478
+ catch {
479
+ startBlock1 = 0;
480
+ }
481
+ // signAndExecute waits for source tx confirmation — tokens leave wallet here.
482
+ // While this runs (5-15s), the WS subscription is establishing.
483
+ const leg1Start = Date.now();
484
+ const result1 = await persistence.signAndExecute(quote1, signer);
485
+ roundResult.leg1 = {
486
+ txHash: result1.txHash, orderId: result1.orderId, status: "submitted",
487
+ amountSent: `${Number(clamped1.btc8Dec) / 1e8}`,
488
+ provider: "persistence",
489
+ txHashSource: result1.txHash,
490
+ };
491
+ progress(`Leg 1 tx confirmed: ${result1.txHash.slice(0, 18)}... — polling for destination fill (${watcher1.connectedCount()} WS connections)...`);
492
+ // Multi-signal fill detection (4 prongs, fastest-first):
493
+ // 0. eth_subscribe push (primary — real-time, no caching), 3-15s
494
+ // 1. HTTP getLogs — rotated RPCs, 30-120s (server-side cached)
495
+ // 2. HTTP balance check — rotated RPCs, 30-120s (server-side cached)
496
+ // 3. Status API every Nth poll (often broken)
497
+ let fulfilled = false;
498
+ let legFailed = false;
499
+ for (let w = 0; w < maxPolls; w++) {
500
+ const pollMs = (w * POLL_INTERVAL_MS < FAST_POLL_DURATION_MS) ? FAST_POLL_INTERVAL_MS : POLL_INTERVAL_MS;
501
+ await new Promise(r => setTimeout(r, pollMs));
502
+ // Prong 0: eth_subscribe push detection (primary — real-time)
503
+ if (watcher1.isDetected()) {
504
+ fulfilled = true;
505
+ progress(`Leg 1: fill confirmed via WS subscription [${(w + 1) * 5}s]`);
506
+ break;
507
+ }
508
+ // Prong 1: HTTP getLogs with RPC rotation (fallback)
509
+ try {
510
+ const eventResult = await checkTransferEvents(leg1.destChainId, leg1.destToken, walletAddress, startBlock1, w);
511
+ if (eventResult.found) {
512
+ fulfilled = true;
513
+ progress(`Leg 1: fill confirmed via Transfer event [${(w + 1) * 5}s]`);
514
+ break;
515
+ }
516
+ if (eventResult.latestBlock && eventResult.latestBlock > startBlock1) {
517
+ startBlock1 = eventResult.latestBlock - 1;
518
+ }
519
+ }
520
+ catch { /* non-fatal */ }
521
+ // Prong 2: Balance check with RPC rotation (fallback)
522
+ try {
523
+ const balResult = await checkBalanceChange(leg1.destChainId, leg1.destToken, walletAddress, preDestBalance1, w);
524
+ if (balResult.changed) {
525
+ fulfilled = true;
526
+ const delta = balResult.newBalance - preDestBalance1;
527
+ progress(`Leg 1: fill confirmed via balance (+${((Number(delta) / 1e8) * (leg1.destChainId === 56 ? 1e-10 : 1)).toFixed(8)} BTC) [${(w + 1) * 5}s]`);
528
+ break;
529
+ }
530
+ }
531
+ catch { /* non-fatal */ }
532
+ // Prong 3: Status API every Nth poll (often broken)
533
+ if ((w + 1) % STATUS_API_INTERVAL === 0) {
534
+ const status = await persistence.getStatus(result1.trackingId, { orderId: result1.orderId });
535
+ if (status.state === "completed") {
536
+ fulfilled = true;
537
+ progress(`Leg 1: fill confirmed via status API`);
538
+ break;
539
+ }
540
+ if (status.state === "failed") {
541
+ legFailed = true;
542
+ break;
543
+ }
544
+ progress(`Leg 1 polling... ${(w + 1) * 5}s/${effectiveTimeout}s (status: ${status.humanReadable ?? status.state})`);
545
+ }
546
+ }
547
+ watcher1.cleanup();
548
+ if (fulfilled) {
549
+ progress(`Leg 1 COMPLETED`);
550
+ roundResult.leg1.status = "completed";
551
+ roundResult.leg1.durationSeconds = Math.round((Date.now() - leg1Start) / 1000);
552
+ // Calculate amount received on destination
553
+ try {
554
+ const postDestBalance1 = BigInt(await getBalance(leg1.destChainId, leg1.destToken, walletAddress));
555
+ const received = postDestBalance1 - preDestBalance1;
556
+ const receivedDecimals = leg1.destChainId === 56 ? 18 : 8;
557
+ const received8Dec = receivedDecimals > 8 ? received / (10n ** BigInt(receivedDecimals - 8)) : received;
558
+ roundResult.leg1.amountReceived = `${Number(received8Dec) / 1e8}`;
559
+ // Calculate fee in basis points
560
+ if (clamped1.btc8Dec > 0n && received8Dec > 0n) {
561
+ const feeBps = Number(((clamped1.btc8Dec - received8Dec) * 10000n) / clamped1.btc8Dec);
562
+ roundResult.leg1.feeBps = feeBps;
563
+ }
564
+ }
565
+ catch { /* non-fatal — balance check for amountReceived */ }
566
+ }
567
+ else if (legFailed) {
568
+ progress(`Leg 1 FAILED — order rejected by solver`);
569
+ roundResult.leg1.status = "failed: order rejected";
570
+ roundFailed = true;
571
+ }
572
+ else {
573
+ // Final fallback: one more balance check with 20s delay using fresh provider
574
+ progress(`Leg 1 polling exhausted — final balance check with 20s delay...`);
575
+ let destVerified = false;
576
+ await new Promise(r => setTimeout(r, 20_000));
577
+ try {
578
+ const fallback = await checkBalanceChange(leg1.destChainId, leg1.destToken, walletAddress, preDestBalance1, maxPolls + 1);
579
+ if (fallback.changed) {
580
+ destVerified = true;
581
+ progress(`Leg 1: destination balance increased after final retry`);
582
+ }
583
+ }
584
+ catch { /* non-fatal */ }
585
+ if (destVerified) {
586
+ roundResult.leg1.status = "completed_late";
587
+ hadTimeout = true;
588
+ }
589
+ else {
590
+ progress(`Leg 1 TIMEOUT — destination balance still unchanged`);
591
+ roundResult.leg1.status = "timeout";
592
+ roundFailed = true;
593
+ hadTimeout = true;
594
+ }
595
+ }
596
+ }
597
+ catch (err) {
598
+ progress(`Leg 1 ERROR: ${sanitizeError(err)}`);
599
+ roundResult.leg1 = { txHash: "", orderId: "", status: `failed: ${sanitizeError(err)}`, amountSent: "0", provider: "persistence" };
600
+ roundFailed = true;
601
+ }
602
+ if (roundFailed) {
603
+ progress(`Round ${i + 1} failed at leg 1: ${roundResult.leg1?.status}`);
604
+ results.push(roundResult);
605
+ consecutiveFailures++;
606
+ // Extra cooldown after timeouts to let pending txs settle
607
+ if (hadTimeout) {
608
+ progress(`Post-timeout cooldown: waiting 30s for pending tx to settle...`);
609
+ await new Promise(r => setTimeout(r, POST_TIMEOUT_COOLDOWN_MS));
610
+ }
611
+ continue;
612
+ }
613
+ // Brief pause between legs
614
+ await new Promise(r => setTimeout(r, 5_000));
615
+ // Extra cooldown if leg1 was a late completion
616
+ if (hadTimeout) {
617
+ progress(`Post-timeout cooldown: waiting 30s for balance propagation...`);
618
+ await new Promise(r => setTimeout(r, POST_TIMEOUT_COOLDOWN_MS));
619
+ }
620
+ // ── Leg 2 ──────────────────────────────────────────────────────
621
+ try {
622
+ // Capture pre-leg destination balance for post-timeout verification
623
+ let preDestBalance2 = 0n;
624
+ try {
625
+ preDestBalance2 = BigInt(await getBalance(leg2.destChainId, leg2.destToken, walletAddress));
626
+ }
627
+ catch { /* non-fatal */ }
628
+ const clamped2 = await getClampedAmount(leg2.chainId, leg2.token, walletAddress, leg2.decimals, userCapBtc, true /* isReturnLeg */);
629
+ if (!clamped2)
630
+ throw new Error(`Balance below minimum 0.000045 BTC on ${leg2.label.split("→")[0]} (solver fees may have reduced the amount too much)`);
631
+ progress(`Leg 2 (${leg2.label}): ${Number(clamped2.btc8Dec) / 1e8} BTC`);
632
+ const quote2 = await persistence.getQuote({
633
+ fromChainId: leg2.chainId, toChainId: leg2.destChainId,
634
+ fromTokenAddress: leg2.token, toTokenAddress: leg2.destToken,
635
+ amountRaw: clamped2.amountRaw, fromAddress: walletAddress, preference: "cheapest",
636
+ });
637
+ if (!quote2)
638
+ throw new Error(`No quote available for ${leg2.label}`);
639
+ // Create push-based fill watcher BEFORE signing (same strategy as leg 1)
640
+ const watcher2 = createFillWatcher(leg2.destChainId, leg2.destToken, walletAddress, () => progress(`Leg 2: fill PUSHED via eth_subscribe`));
641
+ // Capture destination chain block number for HTTP getLogs fallback
642
+ let startBlock2;
643
+ try {
644
+ startBlock2 = Math.max(0, await getCurrentBlockNumber(leg2.destChainId) - 2);
645
+ }
646
+ catch {
647
+ startBlock2 = 0;
648
+ }
649
+ const leg2Start = Date.now();
650
+ const result2 = await persistence.signAndExecute(quote2, signer);
651
+ roundResult.leg2 = {
652
+ txHash: result2.txHash, orderId: result2.orderId, status: "submitted",
653
+ amountSent: `${Number(clamped2.btc8Dec) / 1e8}`,
654
+ provider: "persistence",
655
+ txHashSource: result2.txHash,
656
+ };
657
+ progress(`Leg 2 tx confirmed: ${result2.txHash.slice(0, 18)}... — polling for destination fill (${watcher2.connectedCount()} WS connections)...`);
658
+ // Multi-signal fill detection (same 4-prong strategy as leg 1)
659
+ let fulfilled = false;
660
+ let legFailed = false;
661
+ for (let w = 0; w < maxPolls; w++) {
662
+ const pollMs = (w * POLL_INTERVAL_MS < FAST_POLL_DURATION_MS) ? FAST_POLL_INTERVAL_MS : POLL_INTERVAL_MS;
663
+ await new Promise(r => setTimeout(r, pollMs));
664
+ // Prong 0: eth_subscribe push detection (primary — real-time)
665
+ if (watcher2.isDetected()) {
666
+ fulfilled = true;
667
+ progress(`Leg 2: fill confirmed via WS subscription [${(w + 1) * 5}s]`);
668
+ break;
669
+ }
670
+ // Prong 1: HTTP getLogs with RPC rotation (fallback)
671
+ try {
672
+ const eventResult = await checkTransferEvents(leg2.destChainId, leg2.destToken, walletAddress, startBlock2, w);
673
+ if (eventResult.found) {
674
+ fulfilled = true;
675
+ progress(`Leg 2: fill confirmed via Transfer event [${(w + 1) * 5}s]`);
676
+ break;
677
+ }
678
+ if (eventResult.latestBlock && eventResult.latestBlock > startBlock2) {
679
+ startBlock2 = eventResult.latestBlock - 1;
680
+ }
681
+ }
682
+ catch { /* non-fatal */ }
683
+ // Prong 2: Balance check with RPC rotation (fallback)
684
+ try {
685
+ const balResult = await checkBalanceChange(leg2.destChainId, leg2.destToken, walletAddress, preDestBalance2, w);
686
+ if (balResult.changed) {
687
+ fulfilled = true;
688
+ const delta = balResult.newBalance - preDestBalance2;
689
+ progress(`Leg 2: fill confirmed via balance (+${((Number(delta) / 1e8) * (leg2.destChainId === 56 ? 1e-10 : 1)).toFixed(8)} BTC) [${(w + 1) * 5}s]`);
690
+ break;
691
+ }
692
+ }
693
+ catch { /* non-fatal */ }
694
+ // Prong 3: Status API every Nth poll (often broken)
695
+ if ((w + 1) % STATUS_API_INTERVAL === 0) {
696
+ const status = await persistence.getStatus(result2.trackingId, { orderId: result2.orderId });
697
+ if (status.state === "completed") {
698
+ fulfilled = true;
699
+ progress(`Leg 2: fill confirmed via status API`);
700
+ break;
701
+ }
702
+ if (status.state === "failed") {
703
+ legFailed = true;
704
+ break;
705
+ }
706
+ progress(`Leg 2 polling... ${(w + 1) * 5}s/${effectiveTimeout}s (status: ${status.humanReadable ?? status.state})`);
707
+ }
708
+ }
709
+ watcher2.cleanup();
710
+ // Helper: track loss and count completion
711
+ const countRoundCompleted = async () => {
712
+ completedRounds++;
713
+ consecutiveFailures = 0;
714
+ try {
715
+ if (initialHomeBalance8Dec !== null && initialHomeBalance8Dec > 0n) {
716
+ const bal = await getBalance(leg1.chainId, leg1.token, walletAddress);
717
+ const current8Dec = leg1.decimals > 8
718
+ ? BigInt(bal) / (10n ** BigInt(leg1.decimals - 8))
719
+ : BigInt(bal);
720
+ if (current8Dec < initialHomeBalance8Dec) {
721
+ totalLossBps = Number(((initialHomeBalance8Dec - current8Dec) * 10000n) / initialHomeBalance8Dec);
722
+ progress(`Cumulative loss: ${totalLossBps} bps`);
723
+ }
724
+ }
725
+ }
726
+ catch { /* balance check failed — non-fatal, skip loss tracking */ }
727
+ };
728
+ if (fulfilled) {
729
+ progress(`Leg 2 COMPLETED`);
730
+ roundResult.leg2.status = "completed";
731
+ roundResult.leg2.durationSeconds = Math.round((Date.now() - leg2Start) / 1000);
732
+ // Calculate amount received on destination
733
+ try {
734
+ const postDestBalance2 = BigInt(await getBalance(leg2.destChainId, leg2.destToken, walletAddress));
735
+ const received2 = postDestBalance2 - preDestBalance2;
736
+ const receivedDecimals2 = leg2.destChainId === 56 ? 18 : 8;
737
+ const received8Dec2 = receivedDecimals2 > 8 ? received2 / (10n ** BigInt(receivedDecimals2 - 8)) : received2;
738
+ roundResult.leg2.amountReceived = `${Number(received8Dec2) / 1e8}`;
739
+ if (clamped2.btc8Dec > 0n && received8Dec2 > 0n) {
740
+ const feeBps2 = Number(((clamped2.btc8Dec - received8Dec2) * 10000n) / clamped2.btc8Dec);
741
+ roundResult.leg2.feeBps = feeBps2;
742
+ }
743
+ }
744
+ catch { /* non-fatal */ }
745
+ await countRoundCompleted();
746
+ }
747
+ else if (legFailed) {
748
+ progress(`Leg 2 FAILED — order rejected by solver`);
749
+ roundResult.leg2.status = "failed: order rejected";
750
+ consecutiveFailures++;
751
+ }
752
+ else {
753
+ // Final fallback: one more balance check with 20s delay using fresh provider
754
+ progress(`Leg 2 polling exhausted — final balance check with 20s delay...`);
755
+ let destVerified = false;
756
+ await new Promise(r => setTimeout(r, 20_000));
757
+ try {
758
+ const fallback = await checkBalanceChange(leg2.destChainId, leg2.destToken, walletAddress, preDestBalance2, maxPolls + 1);
759
+ if (fallback.changed) {
760
+ destVerified = true;
761
+ progress(`Leg 2: destination balance increased after final retry`);
762
+ }
763
+ }
764
+ catch { /* non-fatal */ }
765
+ if (destVerified) {
766
+ roundResult.leg2.status = "completed_late";
767
+ await countRoundCompleted();
768
+ hadTimeout = true;
769
+ }
770
+ else {
771
+ progress(`Leg 2 TIMEOUT — destination balance still unchanged`);
772
+ roundResult.leg2.status = "timeout";
773
+ consecutiveFailures++;
774
+ hadTimeout = true;
775
+ }
776
+ }
777
+ }
778
+ catch (err) {
779
+ progress(`Leg 2 ERROR: ${sanitizeError(err)}`);
780
+ roundResult.leg2 = { txHash: "", orderId: "", status: `failed: ${sanitizeError(err)}`, amountSent: "0", provider: "persistence" };
781
+ consecutiveFailures++;
782
+ }
783
+ progress(`Round ${i + 1} result: leg1=${roundResult.leg1?.status ?? "n/a"}, leg2=${roundResult.leg2?.status ?? "n/a"}`);
784
+ results.push(roundResult);
785
+ if (i < params.rounds - 1 && consecutiveFailures < params.maxFailures) {
786
+ // Extra cooldown after timeouts
787
+ if (hadTimeout) {
788
+ progress(`Post-timeout cooldown: waiting 30s...`);
789
+ await new Promise(r => setTimeout(r, POST_TIMEOUT_COOLDOWN_MS));
790
+ }
791
+ await new Promise(r => setTimeout(r, params.delay * 1000));
792
+ }
793
+ }
794
+ progress(`Finished. Completed: ${completedRounds}/${results.length} rounds, loss: ${totalLossBps} bps`);
795
+ // Calculate total volume for reward estimation
796
+ let totalVolumeBtc = 0;
797
+ for (const r of results) {
798
+ if (r.leg1?.amountSent)
799
+ totalVolumeBtc += parseFloat(r.leg1.amountSent);
800
+ if (r.leg2?.amountSent)
801
+ totalVolumeBtc += parseFloat(r.leg2.amountSent);
802
+ }
803
+ // Fetch current multiplier for reward summary
804
+ let currentMultiplier = "1x";
805
+ let nextMultiplierThreshold;
806
+ try {
807
+ const linkData = await fetchJson(`${REWARDS_API}/address-verification/check/${walletAddress}`);
808
+ if (linkData.isRegistered && linkData.persistenceAddress) {
809
+ // Check multiplier from rewards API first (canonical), then fall back to delegation query
810
+ const today = new Date().toISOString().slice(0, 10);
811
+ let resolved = false;
812
+ try {
813
+ const tierData = await fetchJson(`${REWARDS_API}/tiers/${linkData.persistenceAddress}?blockDate=${today}`);
814
+ if (tierData.multiplier) {
815
+ currentMultiplier = `${tierData.multiplier}x`;
816
+ resolved = true;
817
+ }
818
+ }
819
+ catch { /* fall through to delegation-based lookup */ }
820
+ if (!resolved) {
821
+ // Fallback: check staked (delegated) balance — not liquid balance!
822
+ const delData = await fetchJson(`${PERSISTENCE_REST}/cosmos/staking/v1beta1/delegations/${linkData.persistenceAddress}`);
823
+ const totalDelegated = (delData.delegation_responses ?? []).reduce((sum, d) => {
824
+ return sum + parseInt(d.balance?.amount ?? "0") / 1e6;
825
+ }, 0);
826
+ const tier = getMultiplierTier(totalDelegated);
827
+ currentMultiplier = tier.multiplier;
828
+ if (tier.tier === "Explorer")
829
+ nextMultiplierThreshold = "Stake 10,000 XPRT to reach 2x multiplier";
830
+ else if (tier.tier === "Voyager")
831
+ nextMultiplierThreshold = "Stake 1,000,000 XPRT to reach 5x multiplier";
832
+ }
833
+ }
834
+ }
835
+ catch { /* non-fatal */ }
836
+ const rewardSummary = {
837
+ totalVolumeBtc: totalVolumeBtc.toFixed(8),
838
+ estimatedXprtPerRound: "Varies by epoch participation — check xprt_rewards_check for current estimates",
839
+ currentMultiplier,
840
+ nextMultiplierThreshold,
841
+ suggestion: completedRounds > 0
842
+ ? "Run xprt_rewards_check to see your accumulated rewards. Consider staking XPRT for a higher multiplier."
843
+ : "No rounds completed. Check balances and gas, then try again.",
844
+ };
845
+ return {
846
+ content: [{
847
+ type: "text",
848
+ text: JSON.stringify({
849
+ status: "completed",
850
+ wallet: walletAddress,
851
+ direction: directionLabel,
852
+ amountMode: userCapBtc ? `capped at ${userCapBtc} BTC` : "max available (clamped to 0.00005–0.001 BTC)",
853
+ completedRounds,
854
+ totalAttempted: results.length,
855
+ totalLossBps,
856
+ stoppedEarly: consecutiveFailures >= params.maxFailures ? "max consecutive failures" :
857
+ totalLossBps >= params.maxLossBps ? "max loss threshold" : null,
858
+ rounds: results,
859
+ rewardSummary,
860
+ disclaimer: "Rewards are estimated and not guaranteed. Actual XPRT distribution depends on total epoch participation.",
861
+ }, null, 2),
862
+ }],
863
+ };
864
+ });
865
+ // ─── xprt_farm_status ───────────────────────────────────────────────────────
866
+ server.tool("xprt_farm_status", "Check your XPRT farming status: wallet link, BTC balances, current epoch reward pool. Rewards are estimated and change based on total participation.", {}, async () => {
867
+ const privateKey = getKey("privateKey");
868
+ if (!privateKey) {
869
+ return {
870
+ content: [{ type: "text", text: `No wallet configured. Add keys to ${path.resolve(getConfigDir(), ".env")} (MNEMONIC=... / PRIVATE_KEY=0x...) or run wallet_setup to generate new keys. Use wallet_status to check.` }],
871
+ isError: true,
872
+ };
873
+ }
874
+ const wallet = new ethers.Wallet(privateKey);
875
+ const evmAddress = wallet.address;
876
+ const result = { wallet: evmAddress };
877
+ try {
878
+ const linkData = await fetchJson(`${REWARDS_API}/address-verification/check/${evmAddress}`);
879
+ result.linked = linkData.isRegistered ?? false;
880
+ if (linkData.persistenceAddress)
881
+ result.persistenceAddress = linkData.persistenceAddress;
882
+ }
883
+ catch {
884
+ result.linked = "unknown (check failed)";
885
+ }
886
+ try {
887
+ const [cbBTCBal, btcbBal] = await Promise.all([
888
+ getBalance(8453, CBTCB_BASE, evmAddress),
889
+ getBalance(56, BTCB_BSC, evmAddress),
890
+ ]);
891
+ result.balances = {
892
+ "Base (cbBTC)": ethers.formatUnits(cbBTCBal, 8),
893
+ "BSC (BTCB)": ethers.formatUnits(btcbBal, 18),
894
+ };
895
+ }
896
+ catch (err) {
897
+ result.balances = `Error: ${sanitizeError(err)}`;
898
+ }
899
+ try {
900
+ const epoch = await fetchJson(`${REWARDS_API}/epochs/current`);
901
+ result.currentEpoch = {
902
+ epochNumber: epoch.epochNumber,
903
+ startDate: epoch.startDate,
904
+ endDate: epoch.endDate,
905
+ status: epoch.status,
906
+ rewardPoolXprt: epoch.rewardPoolXprt ? `~${Number(epoch.rewardPoolXprt).toFixed(2)} XPRT` : undefined,
907
+ };
908
+ }
909
+ catch {
910
+ result.currentEpoch = "Could not fetch";
911
+ }
912
+ result.disclaimer = "Estimated rewards are not guaranteed and change based on total participation.";
913
+ return {
914
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
915
+ };
916
+ });
917
+ // ─── xprt_farm_boost ────────────────────────────────────────────────────────
918
+ server.tool("xprt_farm_boost", "Buy XPRT with any token and auto-stake for XPRT farming multiplier boost. " +
919
+ "One command to go from 1x to 2x or 5x multiplier. " +
920
+ "Set dryRun=true (default) to preview the estimated XPRT output, exchange rate, and fees before committing. " +
921
+ "Set dryRun=false to execute the swap. " +
922
+ "Preconditions: Requires wallet_setup with mnemonic (for Persistence address derivation).", {
923
+ amount: z.string().describe("Amount of source token to swap (e.g. '0.1')"),
924
+ token: z.string().default("ETH").describe("Source token symbol (default: ETH)"),
925
+ chain: z.string().default("base").describe("Source chain (default: base)"),
926
+ dryRun: z.boolean().default(true).describe("When true (default), returns quote/preview without executing. Set to false to execute the swap."),
927
+ validatorAddress: z.string().optional().describe("Validator address to delegate to (auto-picks best if omitted)"),
928
+ }, async (params) => {
929
+ const mnemonic = getKey("mnemonic");
930
+ if (!mnemonic) {
931
+ return {
932
+ content: [{ type: "text", text: `No mnemonic configured. Add MNEMONIC to ${path.resolve(getConfigDir(), ".env")} or run wallet_setup to generate new keys. Use wallet_status to check.` }],
933
+ isError: true,
934
+ };
935
+ }
936
+ // Derive persistence address to show the user where to send XPRT
937
+ let persistenceAddress;
938
+ try {
939
+ const { Secp256k1HdWallet } = await import("@cosmjs/amino");
940
+ const cosmosWallet = await Secp256k1HdWallet.fromMnemonic(mnemonic, { prefix: "persistence" });
941
+ const [account] = await cosmosWallet.getAccounts();
942
+ persistenceAddress = account.address;
943
+ }
944
+ catch (err) {
945
+ return {
946
+ content: [{ type: "text", text: `Failed to derive Persistence address: ${sanitizeError(err)}` }],
947
+ isError: true,
948
+ };
949
+ }
950
+ // Check current staking status for context
951
+ let currentXprtStaked = 0;
952
+ let currentTier = getMultiplierTier(0);
953
+ try {
954
+ const balData = await fetchJson(`${PERSISTENCE_REST}/cosmos/bank/v1beta1/balances/${persistenceAddress}`);
955
+ const xprt = balData.balances?.find((b) => b.denom === "uxprt");
956
+ currentXprtStaked = xprt ? parseInt(xprt.amount) / 1e6 : 0;
957
+ currentTier = getMultiplierTier(currentXprtStaked);
958
+ }
959
+ catch { /* non-fatal */ }
960
+ if (params.dryRun) {
961
+ // Dry-run: estimate XPRT output based on CoinGecko prices
962
+ let estimatedXprtOutput = null;
963
+ let exchangeRate = null;
964
+ let priceImpact = null;
965
+ let inputValueUsd = null;
966
+ try {
967
+ // Fetch prices for source token and XPRT
968
+ const tokenPriceIds = {
969
+ ETH: "ethereum", BTC: "bitcoin", WBTC: "bitcoin", CBBTC: "bitcoin",
970
+ USDC: "usd-coin", USDT: "tether", BNB: "binancecoin", AVAX: "avalanche-2",
971
+ SOL: "solana", MATIC: "matic-network", POL: "matic-network",
972
+ };
973
+ const srcCgId = tokenPriceIds[params.token.toUpperCase()] ?? params.token.toLowerCase();
974
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${srcCgId},persistence&vs_currencies=usd`;
975
+ const controller = new AbortController();
976
+ const timer = setTimeout(() => controller.abort(), 10_000);
977
+ try {
978
+ const res = await fetch(url, { signal: controller.signal });
979
+ if (res.ok) {
980
+ const data = await res.json();
981
+ const srcPriceUsd = data[srcCgId]?.usd;
982
+ const xprtPriceUsd = data["persistence"]?.usd;
983
+ if (srcPriceUsd && xprtPriceUsd && xprtPriceUsd > 0) {
984
+ inputValueUsd = parseFloat(params.amount) * srcPriceUsd;
985
+ // Estimate: subtract ~1% for bridge+swap fees
986
+ const netUsd = inputValueUsd * 0.99;
987
+ const xprtAmount = netUsd / xprtPriceUsd;
988
+ estimatedXprtOutput = xprtAmount.toFixed(2);
989
+ exchangeRate = `1 ${params.token} ≈ ${(srcPriceUsd / xprtPriceUsd).toFixed(2)} XPRT`;
990
+ priceImpact = "~1% (bridge + DEX swap fees)";
991
+ }
992
+ }
993
+ }
994
+ finally {
995
+ clearTimeout(timer);
996
+ }
997
+ }
998
+ catch { /* price fetch failed — non-fatal */ }
999
+ const newStakedTotal = estimatedXprtOutput
1000
+ ? currentXprtStaked + parseFloat(estimatedXprtOutput)
1001
+ : currentXprtStaked;
1002
+ const newTier = getMultiplierTier(newStakedTotal);
1003
+ const quoteInfo = {
1004
+ status: "dry_run",
1005
+ message: "Preview of XPRT boost — no funds committed. Set dryRun=false to execute.",
1006
+ input: {
1007
+ amount: params.amount,
1008
+ token: params.token,
1009
+ chain: params.chain,
1010
+ estimatedValueUsd: inputValueUsd ? `$${inputValueUsd.toFixed(2)}` : null,
1011
+ },
1012
+ estimatedOutput: {
1013
+ estimatedXprtOutput: estimatedXprtOutput ? `~${estimatedXprtOutput} XPRT` : "Unable to estimate (price data unavailable)",
1014
+ exchangeRate: exchangeRate ?? "Unable to estimate",
1015
+ priceImpact: priceImpact ?? "Unable to estimate",
1016
+ },
1017
+ currentState: {
1018
+ persistenceAddress,
1019
+ currentXprtStaked: currentXprtStaked.toFixed(2),
1020
+ currentTier: currentTier.tier,
1021
+ currentMultiplier: currentTier.multiplier,
1022
+ },
1023
+ projectedState: {
1024
+ projectedXprtStaked: newStakedTotal.toFixed(2),
1025
+ projectedTier: newTier.tier,
1026
+ projectedMultiplier: newTier.multiplier,
1027
+ tierChange: newTier.multiplier !== currentTier.multiplier
1028
+ ? `${currentTier.multiplier} → ${newTier.multiplier}`
1029
+ : "no change",
1030
+ },
1031
+ estimatedRoute: `${params.token} on ${params.chain} → bridge to Persistence → swap to XPRT → auto-stake`,
1032
+ feeBreakdown: {
1033
+ bridgeFee: "~0.1-0.5% (varies by route)",
1034
+ swapFee: "~0.3% (DEX swap)",
1035
+ gasFee: "~$0.50-2.00 (varies by chain)",
1036
+ },
1037
+ tiers: {
1038
+ Explorer: "0 XPRT staked → 1x multiplier",
1039
+ Voyager: "10,000 XPRT staked → 2x multiplier",
1040
+ Pioneer: "1,000,000 XPRT staked → 5x multiplier",
1041
+ },
1042
+ quoteExpiresAt: new Date(Date.now() + 60_000).toISOString(),
1043
+ };
1044
+ return {
1045
+ content: [{
1046
+ type: "text",
1047
+ text: JSON.stringify(quoteInfo, null, 2),
1048
+ }],
1049
+ };
1050
+ }
1051
+ // ── Execute mode: Bridge EVM token → XPRT → auto-stake ──────────────
1052
+ progress(`Boost execute: ${params.amount} ${params.token} on ${params.chain} → XPRT → stake`);
1053
+ // Step 1: Resolve chain and token
1054
+ const { resolveChainId } = await import("../utils/chains.js");
1055
+ const { resolveToken } = await import("../utils/token-registry.js");
1056
+ const { parseTokenAmount } = await import("../utils/tokens.js");
1057
+ const { PERSISTENCE_CHAIN_ID } = await import("../utils/chains.js");
1058
+ const fromChainId = resolveChainId(params.chain);
1059
+ if (!fromChainId) {
1060
+ return {
1061
+ content: [{ type: "text", text: `Unknown chain: ${params.chain}` }],
1062
+ isError: true,
1063
+ };
1064
+ }
1065
+ const fromTokenResult = resolveToken(params.token, fromChainId);
1066
+ if (!fromTokenResult.ok) {
1067
+ return {
1068
+ content: [{ type: "text", text: `Token resolution failed: ${fromTokenResult.error}` }],
1069
+ isError: true,
1070
+ };
1071
+ }
1072
+ const toTokenResult = resolveToken("XPRT", PERSISTENCE_CHAIN_ID);
1073
+ if (!toTokenResult.ok) {
1074
+ return {
1075
+ content: [{ type: "text", text: `XPRT token resolution failed: ${toTokenResult.error}` }],
1076
+ isError: true,
1077
+ };
1078
+ }
1079
+ const amountRaw = parseTokenAmount(params.amount, fromTokenResult.decimals);
1080
+ const privateKey = getKey("privateKey");
1081
+ if (!privateKey) {
1082
+ return {
1083
+ content: [{ type: "text", text: `No private key configured. Run wallet_setup first.` }],
1084
+ isError: true,
1085
+ };
1086
+ }
1087
+ const evmWallet = new ethers.Wallet(privateKey);
1088
+ // Step 2: Get Squid quote (only backend supporting EVM → Cosmos)
1089
+ progress("Getting Squid quote for EVM → XPRT...");
1090
+ const squidBackend = engine.getBackend("squid");
1091
+ if (!squidBackend) {
1092
+ return {
1093
+ content: [{ type: "text", text: `Squid backend not available. Cannot bridge to Cosmos.` }],
1094
+ isError: true,
1095
+ };
1096
+ }
1097
+ const quote = await squidBackend.getQuote({
1098
+ fromChainId,
1099
+ toChainId: PERSISTENCE_CHAIN_ID,
1100
+ fromTokenAddress: fromTokenResult.address,
1101
+ toTokenAddress: toTokenResult.address,
1102
+ amountRaw,
1103
+ fromAddress: evmWallet.address,
1104
+ preference: "fastest",
1105
+ fromTokenDecimals: fromTokenResult.decimals,
1106
+ toTokenDecimals: toTokenResult.decimals,
1107
+ });
1108
+ if (!quote) {
1109
+ return {
1110
+ content: [{ type: "text", text: `No Squid route found for ${params.amount} ${params.token} (${params.chain}) → XPRT. Try a different token or chain.` }],
1111
+ isError: true,
1112
+ };
1113
+ }
1114
+ progress(`Quote: ~${quote.minOutputAmount} XPRT, ETA: ${quote.estimatedTimeSeconds}s`);
1115
+ // Step 3: Build unsigned transaction
1116
+ const txRequest = await squidBackend.buildTransaction(quote);
1117
+ // Step 4: Sign and broadcast on EVM chain
1118
+ progress("Signing and broadcasting bridge tx...");
1119
+ const provider = await getProvider(fromChainId);
1120
+ const signer = evmWallet.connect(provider);
1121
+ // Check balance
1122
+ const balance = await provider.getBalance(evmWallet.address);
1123
+ if (balance < BigInt(amountRaw)) {
1124
+ return {
1125
+ content: [{
1126
+ type: "text",
1127
+ text: JSON.stringify({
1128
+ error: "Insufficient balance",
1129
+ balance: ethers.formatEther(balance),
1130
+ required: params.amount,
1131
+ token: params.token,
1132
+ chain: params.chain,
1133
+ }, null, 2),
1134
+ }],
1135
+ isError: true,
1136
+ };
1137
+ }
1138
+ // Send approval tx if needed
1139
+ if (txRequest.approvalTx) {
1140
+ progress("Sending token approval...");
1141
+ const approvalTx = await signer.sendTransaction({
1142
+ to: txRequest.approvalTx.to,
1143
+ data: txRequest.approvalTx.data,
1144
+ value: txRequest.approvalTx.value,
1145
+ chainId: txRequest.approvalTx.chainId,
1146
+ });
1147
+ await approvalTx.wait();
1148
+ progress(`Approval confirmed: ${approvalTx.hash}`);
1149
+ }
1150
+ // Send bridge tx
1151
+ const tx = await signer.sendTransaction({
1152
+ to: txRequest.to,
1153
+ data: txRequest.data,
1154
+ value: txRequest.value,
1155
+ chainId: txRequest.chainId,
1156
+ gasLimit: txRequest.gasLimit ? BigInt(txRequest.gasLimit) : undefined,
1157
+ });
1158
+ progress(`Bridge tx sent: ${tx.hash}`);
1159
+ const receipt = await tx.wait();
1160
+ if (receipt?.status !== 1) {
1161
+ return {
1162
+ content: [{
1163
+ type: "text",
1164
+ text: JSON.stringify({
1165
+ error: "Bridge transaction failed on-chain",
1166
+ txHash: tx.hash,
1167
+ }, null, 2),
1168
+ }],
1169
+ isError: true,
1170
+ };
1171
+ }
1172
+ progress("Bridge tx confirmed on source chain. Waiting for XPRT to arrive on Persistence...");
1173
+ // Step 5: Poll for XPRT arrival on Persistence (up to 30 minutes)
1174
+ const preLiquidBalance = await (async () => {
1175
+ try {
1176
+ const balData = await fetchJson(`https://rest.cosmos.directory/persistence/cosmos/bank/v1beta1/balances/${persistenceAddress}`);
1177
+ const xprt = balData.balances?.find((b) => b.denom === "uxprt");
1178
+ return parseInt(xprt?.amount || "0");
1179
+ }
1180
+ catch {
1181
+ return 0;
1182
+ }
1183
+ })();
1184
+ const MAX_WAIT_MS = 30 * 60 * 1000; // 30 minutes
1185
+ const POLL_MS = 30_000; // check every 30s
1186
+ const startTime = Date.now();
1187
+ let xprtReceived = 0;
1188
+ while (Date.now() - startTime < MAX_WAIT_MS) {
1189
+ await new Promise(r => setTimeout(r, POLL_MS));
1190
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
1191
+ progress(`Waiting for XPRT... (${elapsed}s elapsed)`);
1192
+ try {
1193
+ const balData = await fetchJson(`https://rest.cosmos.directory/persistence/cosmos/bank/v1beta1/balances/${persistenceAddress}`);
1194
+ const xprt = balData.balances?.find((b) => b.denom === "uxprt");
1195
+ const currentBalance = parseInt(xprt?.amount || "0");
1196
+ if (currentBalance > preLiquidBalance) {
1197
+ xprtReceived = (currentBalance - preLiquidBalance) / 1e6;
1198
+ progress(`XPRT arrived! Received: ${xprtReceived.toFixed(2)} XPRT`);
1199
+ break;
1200
+ }
1201
+ }
1202
+ catch {
1203
+ // LCD query failed, keep polling
1204
+ }
1205
+ }
1206
+ if (xprtReceived === 0) {
1207
+ return {
1208
+ content: [{
1209
+ type: "text",
1210
+ text: JSON.stringify({
1211
+ status: "bridge_pending",
1212
+ message: "Bridge tx confirmed but XPRT hasn't arrived yet after 30 minutes. It may still be in transit via Axelar/IBC.",
1213
+ txHash: tx.hash,
1214
+ persistenceAddress,
1215
+ action: "Check wallet_balance later, then run xprt_stake manually once XPRT arrives.",
1216
+ }, null, 2),
1217
+ }],
1218
+ };
1219
+ }
1220
+ // Step 6: Auto-stake the received XPRT
1221
+ progress(`Staking ${xprtReceived.toFixed(2)} XPRT...`);
1222
+ let stakeTxHash = "not_executed";
1223
+ let stakeError = null;
1224
+ try {
1225
+ const { SigningStargateClient } = await import("@cosmjs/stargate");
1226
+ const { Secp256k1HdWallet: StakeWallet } = await import("@cosmjs/amino");
1227
+ const stakeWallet = await StakeWallet.fromMnemonic(mnemonic, { prefix: "persistence" });
1228
+ const rpc = "https://persistence-rpc.polkachu.com";
1229
+ const client = await SigningStargateClient.connectWithSigner(rpc, stakeWallet);
1230
+ // Pick validator (use provided or auto-select)
1231
+ let validatorAddr = params.validatorAddress;
1232
+ if (!validatorAddr) {
1233
+ try {
1234
+ const validatorsData = await fetchJson(`https://rest.cosmos.directory/persistence/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED&pagination.limit=100`);
1235
+ const validators = (validatorsData.validators || [])
1236
+ .filter((v) => !v.jailed && v.status === "BOND_STATUS_BONDED")
1237
+ .sort((a, b) => parseInt(b.tokens || "0") - parseInt(a.tokens || "0"));
1238
+ if (validators.length > 0) {
1239
+ validatorAddr = validators[0].operator_address;
1240
+ progress(`Auto-selected validator: ${validators[0].description?.moniker || validatorAddr}`);
1241
+ }
1242
+ }
1243
+ catch { /* fallback below */ }
1244
+ }
1245
+ if (!validatorAddr) {
1246
+ stakeError = "No validator available. XPRT received but not staked. Use xprt_stake to delegate manually.";
1247
+ }
1248
+ else {
1249
+ // Leave a small amount for gas (~0.1 XPRT)
1250
+ const stakeAmountUxprt = Math.floor((xprtReceived - 0.1) * 1e6);
1251
+ if (stakeAmountUxprt <= 0) {
1252
+ stakeError = "Received amount too small to stake after reserving gas. Use xprt_stake manually.";
1253
+ }
1254
+ else {
1255
+ const msg = {
1256
+ typeUrl: "/cosmos.staking.v1beta1.MsgDelegate",
1257
+ value: {
1258
+ delegatorAddress: persistenceAddress,
1259
+ validatorAddress: validatorAddr,
1260
+ amount: { denom: "uxprt", amount: String(stakeAmountUxprt) },
1261
+ },
1262
+ };
1263
+ const fee = { amount: [{ denom: "uxprt", amount: "5000" }], gas: "250000" };
1264
+ const result = await client.signAndBroadcast(persistenceAddress, [msg], fee, "BridgeKitty auto-stake");
1265
+ if (result.code === 0) {
1266
+ stakeTxHash = result.transactionHash;
1267
+ progress(`Staked! Tx: ${stakeTxHash}`);
1268
+ }
1269
+ else {
1270
+ stakeError = `Staking tx failed with code ${result.code}: ${result.rawLog}`;
1271
+ }
1272
+ }
1273
+ }
1274
+ }
1275
+ catch (err) {
1276
+ stakeError = `Staking failed: ${sanitizeError(err)}. XPRT received but not staked — use xprt_stake manually.`;
1277
+ }
1278
+ // Calculate new tier
1279
+ const newStakedTotal = currentXprtStaked + (stakeError ? 0 : xprtReceived);
1280
+ const newTier = getMultiplierTier(newStakedTotal);
1281
+ return {
1282
+ content: [{
1283
+ type: "text",
1284
+ text: JSON.stringify({
1285
+ status: stakeError ? "bridge_success_stake_failed" : "completed",
1286
+ message: stakeError
1287
+ ? `Successfully bridged ${params.amount} ${params.token} → ${xprtReceived.toFixed(2)} XPRT, but staking failed.`
1288
+ : `Successfully bridged ${params.amount} ${params.token} → ${xprtReceived.toFixed(2)} XPRT and auto-staked!`,
1289
+ bridgeTxHash: tx.hash,
1290
+ stakeTxHash: stakeError ? null : stakeTxHash,
1291
+ stakeError: stakeError || undefined,
1292
+ xprtReceived: xprtReceived.toFixed(2),
1293
+ persistenceAddress,
1294
+ previousMultiplier: currentTier.multiplier,
1295
+ newMultiplier: newTier.multiplier,
1296
+ newTier: newTier.tier,
1297
+ totalStaked: newStakedTotal.toFixed(2),
1298
+ warning: "⚠️ Staked XPRT is locked for 21 days if you unstake. Use xprt_unstake to initiate unbonding.",
1299
+ tiers: {
1300
+ Explorer: "0 XPRT staked → 1x multiplier",
1301
+ Voyager: "10,000 XPRT staked → 2x multiplier",
1302
+ Pioneer: "1,000,000 XPRT staked → 5x multiplier",
1303
+ },
1304
+ }, null, 2),
1305
+ }],
1306
+ };
1307
+ });
1308
+ }