@aibtc/mcp-server 1.43.0 → 1.45.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 (53) hide show
  1. package/dist/config/sponsor.d.ts +8 -0
  2. package/dist/config/sponsor.d.ts.map +1 -1
  3. package/dist/config/sponsor.js +10 -0
  4. package/dist/config/sponsor.js.map +1 -1
  5. package/dist/services/hiro-api.d.ts +3 -0
  6. package/dist/services/hiro-api.d.ts.map +1 -1
  7. package/dist/services/hiro-api.js.map +1 -1
  8. package/dist/services/nonce-tracker.d.ts +135 -0
  9. package/dist/services/nonce-tracker.d.ts.map +1 -0
  10. package/dist/services/nonce-tracker.js +315 -0
  11. package/dist/services/nonce-tracker.js.map +1 -0
  12. package/dist/services/wallet-manager.d.ts +1 -1
  13. package/dist/services/wallet-manager.d.ts.map +1 -1
  14. package/dist/services/wallet-manager.js +10 -10
  15. package/dist/services/wallet-manager.js.map +1 -1
  16. package/dist/tools/arxiv-research.tools.d.ts +16 -0
  17. package/dist/tools/arxiv-research.tools.d.ts.map +1 -0
  18. package/dist/tools/arxiv-research.tools.js +433 -0
  19. package/dist/tools/arxiv-research.tools.js.map +1 -0
  20. package/dist/tools/inbox.tools.js +23 -18
  21. package/dist/tools/inbox.tools.js.map +1 -1
  22. package/dist/tools/index.d.ts.map +1 -1
  23. package/dist/tools/index.js +9 -0
  24. package/dist/tools/index.js.map +1 -1
  25. package/dist/tools/nonce.tools.d.ts +11 -0
  26. package/dist/tools/nonce.tools.d.ts.map +1 -0
  27. package/dist/tools/nonce.tools.js +689 -0
  28. package/dist/tools/nonce.tools.js.map +1 -0
  29. package/dist/tools/skill-mappings.d.ts.map +1 -1
  30. package/dist/tools/skill-mappings.js +20 -9
  31. package/dist/tools/skill-mappings.js.map +1 -1
  32. package/dist/tools/wallet-management.tools.js +1 -1
  33. package/dist/tools/wallet-management.tools.js.map +1 -1
  34. package/dist/tools/yield-dashboard.tools.d.ts +17 -0
  35. package/dist/tools/yield-dashboard.tools.d.ts.map +1 -0
  36. package/dist/tools/yield-dashboard.tools.js +559 -0
  37. package/dist/tools/yield-dashboard.tools.js.map +1 -0
  38. package/dist/transactions/builder.d.ts +9 -20
  39. package/dist/transactions/builder.d.ts.map +1 -1
  40. package/dist/transactions/builder.js +34 -92
  41. package/dist/transactions/builder.js.map +1 -1
  42. package/dist/transactions/sponsor-builder.d.ts +6 -12
  43. package/dist/transactions/sponsor-builder.d.ts.map +1 -1
  44. package/dist/transactions/sponsor-builder.js +81 -39
  45. package/dist/transactions/sponsor-builder.js.map +1 -1
  46. package/dist/utils/relay-health.d.ts +14 -5
  47. package/dist/utils/relay-health.d.ts.map +1 -1
  48. package/dist/utils/relay-health.js +60 -76
  49. package/dist/utils/relay-health.js.map +1 -1
  50. package/dist/yield-hunter/index.js +4 -4
  51. package/dist/yield-hunter/index.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skill/SKILL.md +1 -1
@@ -0,0 +1,689 @@
1
+ /**
2
+ * Nonce Diagnostic Tools
3
+ *
4
+ * MCP tools for inspecting and recovering sender nonce state.
5
+ * Complements the relay-diagnostic tools which focus on sponsor nonce state.
6
+ *
7
+ * @see https://github.com/aibtcdev/aibtc-mcp-server/issues/413
8
+ */
9
+ import { z } from "zod";
10
+ import { createJsonResponse, createErrorResponse } from "../utils/index.js";
11
+ import { NETWORK } from "../services/x402.service.js";
12
+ import { getWalletManager } from "../services/wallet-manager.js";
13
+ import { getHiroApi } from "../services/hiro-api.js";
14
+ import { getAddressState, reloadFromDisk, recordNonceUsed, STALE_NONCE_MS, } from "../services/nonce-tracker.js";
15
+ import { makeSTXTokenTransfer, broadcastTransaction, } from "@stacks/transactions";
16
+ import { getStacksNetwork, getExplorerTxUrl } from "../config/networks.js";
17
+ import { resolveDefaultFee } from "../utils/fee.js";
18
+ import { SPONSOR_ADDRESSES } from "../utils/relay-health.js";
19
+ /** PoX burn address used as the recipient for gap-fill/RBF self-transfers. */
20
+ const POX_BURN_ADDRESS = "SP000000000000000000002Q6VF78";
21
+ /**
22
+ * Broadcast a 1 uSTX gap-fill transaction at the given nonce.
23
+ *
24
+ * Shared by both the nonce_fill_gap tool (single gap) and nonce_heal
25
+ * (batch gaps). Records the nonce in the shared tracker on success.
26
+ */
27
+ async function broadcastGapFill(privateKey, senderAddress, nonce, fee) {
28
+ const networkName = getStacksNetwork(NETWORK);
29
+ const transaction = await makeSTXTokenTransfer({
30
+ recipient: POX_BURN_ADDRESS,
31
+ amount: 1n,
32
+ senderKey: privateKey,
33
+ network: networkName,
34
+ memo: `nonce-fill:${nonce}`,
35
+ nonce: BigInt(nonce),
36
+ fee,
37
+ });
38
+ const broadcastResponse = await broadcastTransaction({
39
+ transaction,
40
+ network: networkName,
41
+ });
42
+ if ("error" in broadcastResponse) {
43
+ return {
44
+ nonce,
45
+ txid: null,
46
+ status: "failed",
47
+ error: `${broadcastResponse.error} - ${broadcastResponse.reason}`,
48
+ };
49
+ }
50
+ await recordNonceUsed(senderAddress, nonce, broadcastResponse.txid);
51
+ return {
52
+ nonce,
53
+ txid: broadcastResponse.txid,
54
+ status: "broadcast",
55
+ explorer: getExplorerTxUrl(broadcastResponse.txid, NETWORK),
56
+ };
57
+ }
58
+ export function registerNonceTools(server) {
59
+ // ============================================================================
60
+ // nonce_health — surface local tracker state vs chain
61
+ // ============================================================================
62
+ server.registerTool("nonce_health", {
63
+ description: `Check the sender nonce health for the active wallet.
64
+
65
+ Compares the local nonce tracker state (persisted at ~/.aibtc/nonce-state.json)
66
+ against the chain's view from Hiro API. Use this to diagnose:
67
+ - Nonce conflicts (ConflictingNonceInMempool)
68
+ - Stuck transaction queues
69
+ - Gaps in the nonce sequence
70
+ - Stale local tracker state
71
+
72
+ Returns:
73
+ - local: lastUsedNonce, pending count, staleness
74
+ - chain: possibleNextNonce, lastExecuted, mempool nonces, missing nonces
75
+ - healthy: whether the nonce state looks good
76
+ - issues: list of detected problems with recommendations`,
77
+ inputSchema: {
78
+ address: z
79
+ .string()
80
+ .optional()
81
+ .describe("STX address to check. Defaults to the active wallet address."),
82
+ },
83
+ }, async ({ address: inputAddress }) => {
84
+ try {
85
+ const walletAccount = getWalletManager().getAccount();
86
+ const address = inputAddress || walletAccount?.address;
87
+ if (!address) {
88
+ return createJsonResponse({
89
+ healthy: false,
90
+ issues: [
91
+ "No address provided and no wallet is unlocked. Provide an address or unlock a wallet first.",
92
+ ],
93
+ });
94
+ }
95
+ // Reload from disk to pick up changes from other processes (CLI skills)
96
+ await reloadFromDisk();
97
+ // Gather local and chain state in parallel
98
+ const [localState, nonceInfo] = await Promise.all([
99
+ getAddressState(address),
100
+ getHiroApi(NETWORK)
101
+ .getNonceInfo(address)
102
+ .catch(() => null),
103
+ ]);
104
+ const issues = [];
105
+ // Build local status
106
+ const isStale = localState
107
+ ? Date.now() - new Date(localState.lastUpdated).getTime() > STALE_NONCE_MS
108
+ : true;
109
+ const local = localState
110
+ ? {
111
+ lastUsedNonce: localState.lastUsedNonce,
112
+ lastUpdated: localState.lastUpdated,
113
+ pendingCount: localState.pending.length,
114
+ pendingLog: localState.pending.slice(-10), // last 10 for brevity
115
+ isStale,
116
+ }
117
+ : {
118
+ lastUsedNonce: null,
119
+ lastUpdated: null,
120
+ pendingCount: 0,
121
+ pendingLog: [],
122
+ isStale: true,
123
+ };
124
+ if (!localState) {
125
+ issues.push("No local nonce state for this address. State will be initialized on next transaction.");
126
+ }
127
+ else if (isStale) {
128
+ issues.push(`Local nonce state is stale (last updated ${localState.lastUpdated}). Will re-sync from chain on next transaction.`);
129
+ }
130
+ // Build chain status
131
+ const chain = nonceInfo
132
+ ? {
133
+ possibleNextNonce: nonceInfo.possible_next_nonce,
134
+ lastExecutedNonce: nonceInfo.last_executed_tx_nonce,
135
+ lastMempoolNonce: nonceInfo.last_mempool_tx_nonce,
136
+ missingNonces: nonceInfo.detected_missing_nonces ?? [],
137
+ mempoolNonces: nonceInfo.detected_mempool_nonces ?? [],
138
+ }
139
+ : null;
140
+ if (!chain) {
141
+ issues.push("Could not fetch chain nonce info from Hiro API. The API may be temporarily unavailable.");
142
+ }
143
+ // Cross-check local vs chain
144
+ if (localState && chain) {
145
+ const localNext = localState.lastUsedNonce + 1;
146
+ const chainNext = chain.possibleNextNonce;
147
+ if (localNext > chainNext + 10) {
148
+ issues.push(`Local tracker is far ahead of chain (local next=${localNext}, chain next=${chainNext}). ` +
149
+ `This could indicate many pending transactions or a tracker bug. Check mempool.`);
150
+ }
151
+ if (chainNext > localNext && !isStale) {
152
+ issues.push(`Chain advanced past local tracker (chain next=${chainNext}, local next=${localNext}). ` +
153
+ `Transactions may have been sent outside this MCP server. Tracker will reconcile on next tx.`);
154
+ }
155
+ if (chain.missingNonces.length > 0) {
156
+ issues.push(`Chain reports missing nonces: [${chain.missingNonces.join(", ")}]. ` +
157
+ `These gaps will stall pending transactions. Use nonce_fill_gap to resolve.`);
158
+ }
159
+ }
160
+ const healthy = issues.length === 0;
161
+ const snapshot = {
162
+ address,
163
+ local: {
164
+ lastUsedNonce: local.lastUsedNonce ?? -1,
165
+ lastUpdated: local.lastUpdated ?? "never",
166
+ pendingCount: local.pendingCount,
167
+ isStale: local.isStale,
168
+ },
169
+ chain: chain ?? {
170
+ possibleNextNonce: -1,
171
+ lastExecutedNonce: -1,
172
+ lastMempoolNonce: null,
173
+ missingNonces: [],
174
+ mempoolNonces: [],
175
+ },
176
+ healthy,
177
+ issues,
178
+ };
179
+ return createJsonResponse({
180
+ ...snapshot,
181
+ pendingLog: local.pendingLog,
182
+ });
183
+ }
184
+ catch (error) {
185
+ return createErrorResponse(error);
186
+ }
187
+ });
188
+ // ============================================================================
189
+ // nonce_fill_gap — send minimal self-transfer at a specific nonce
190
+ // ============================================================================
191
+ server.registerTool("nonce_fill_gap", {
192
+ description: `Fill a nonce gap by sending a minimal STX transfer at the specified nonce.
193
+
194
+ LAST-RESORT recovery action. Each gap-fill is a real on-chain transaction with a real
195
+ fee (~0.001-0.01 STX). Most gaps self-resolve within seconds as Stacks blocks are 3-5s.
196
+ Only use this after confirming the gap persists via nonce_health.
197
+
198
+ When transactions are pending but a gap exists in the nonce sequence (e.g., nonces
199
+ 5 and 7 are pending but 6 is missing), the Stacks mempool will not process nonces
200
+ 7+ until 6 is filled. This tool fills the gap with a 1 micro-STX transfer to the
201
+ PoX burn address.
202
+
203
+ Use nonce_health first to identify gaps, then call this tool for each missing nonce.
204
+
205
+ Requires the wallet to be unlocked. The fee is auto-estimated.`,
206
+ inputSchema: {
207
+ nonce: z
208
+ .number()
209
+ .int()
210
+ .nonnegative()
211
+ .describe("The specific nonce to fill"),
212
+ },
213
+ }, async ({ nonce }) => {
214
+ try {
215
+ const walletAccount = getWalletManager().getAccount();
216
+ if (!walletAccount) {
217
+ return createJsonResponse({
218
+ success: false,
219
+ message: "Wallet must be unlocked to fill a nonce gap. Use wallet_unlock first.",
220
+ });
221
+ }
222
+ const fee = await resolveDefaultFee(NETWORK, "token_transfer");
223
+ const result = await broadcastGapFill(walletAccount.privateKey, walletAccount.address, nonce, fee);
224
+ if (result.status === "failed") {
225
+ return createJsonResponse({
226
+ success: false,
227
+ nonce,
228
+ error: `Broadcast failed: ${result.error}`,
229
+ });
230
+ }
231
+ return createJsonResponse({
232
+ success: true,
233
+ nonce,
234
+ txid: result.txid,
235
+ explorer: result.explorer,
236
+ message: `Gap-fill transaction sent at nonce ${nonce}. txid: ${result.txid}`,
237
+ });
238
+ }
239
+ catch (error) {
240
+ return createErrorResponse(error);
241
+ }
242
+ });
243
+ // ============================================================================
244
+ // tx_status_deep — cross-reference sender pending txids with sponsor mempool
245
+ // ============================================================================
246
+ server.registerTool("tx_status_deep", {
247
+ description: `Deep diagnostic view correlating sender nonces with sponsor nonces for sponsored transactions.
248
+
249
+ Reads the sender's local pending txid log and cross-references each entry against
250
+ the sponsor's mempool to show the full lifecycle of sponsored transactions:
251
+ - Which sender nonce maps to which sponsor nonce
252
+ - Whether sponsor nonce gaps are blocking specific transactions
253
+ - Which pending txids are missing from the sponsor mempool entirely
254
+ - Multiple competing txids (RBF candidates) for the same sender nonce slot
255
+
256
+ Output per nonce slot:
257
+ Sender nonce N:
258
+ - 0xabc (sponsored, sponsor nonce 47) -- BLOCKED by missing sponsor nonces [44, 45]
259
+ - 0xdef (direct, fee 0.01 STX) -- competing RBF candidate
260
+ Sender nonce M (0xghi, sponsored) -> sponsor nonce 48 -- pending, no gaps ahead
261
+ Sender nonce P (0xjkl, sponsored) -> NOT IN SPONSOR MEMPOOL
262
+
263
+ Use this when check_relay_health shows issues but you need per-transaction clarity.
264
+ Returns structured JSON with pendingSlots, sponsorMissingNonces, and summary counts.`,
265
+ inputSchema: {
266
+ address: z
267
+ .string()
268
+ .optional()
269
+ .describe("STX address to check. Defaults to the active wallet address."),
270
+ },
271
+ }, async ({ address: inputAddress }) => {
272
+ try {
273
+ const walletAccount = getWalletManager().getAccount();
274
+ const address = inputAddress || walletAccount?.address;
275
+ if (!address) {
276
+ return createJsonResponse({
277
+ error: "No address provided and no wallet is unlocked. Provide an address or unlock a wallet first.",
278
+ pendingSlots: [],
279
+ summary: { total: 0, sponsored: 0, direct: 0, blocked: 0, notInSponsorMempool: 0 },
280
+ });
281
+ }
282
+ // Reload from disk to pick up changes from other processes
283
+ await reloadFromDisk();
284
+ const localState = await getAddressState(address);
285
+ if (!localState || localState.pending.length === 0) {
286
+ return createJsonResponse({
287
+ address,
288
+ pendingSlots: [],
289
+ sponsorAddress: SPONSOR_ADDRESSES[NETWORK] ?? null,
290
+ sponsorMissingNonces: [],
291
+ summary: { total: 0, sponsored: 0, direct: 0, blocked: 0, notInSponsorMempool: 0 },
292
+ message: "No pending transactions in local tracker for this address.",
293
+ });
294
+ }
295
+ // Group pending log entries by nonce (handle RBF — multiple txids per nonce)
296
+ const byNonce = new Map();
297
+ for (const entry of localState.pending) {
298
+ const existing = byNonce.get(entry.nonce) ?? [];
299
+ existing.push(entry);
300
+ byNonce.set(entry.nonce, existing);
301
+ }
302
+ // Determine sponsor address for this network
303
+ const sponsorAddress = SPONSOR_ADDRESSES[NETWORK];
304
+ // Fetch sponsor data (best-effort — failures degrade gracefully)
305
+ let sponsorMempoolIndex = new Map();
306
+ let sponsorMissingNonces = [];
307
+ if (sponsorAddress) {
308
+ const hiroApi = getHiroApi(NETWORK);
309
+ // Fetch sponsor mempool txs and nonce gaps in parallel
310
+ const [sponsorMempoolResult, sponsorNonceInfo] = await Promise.all([
311
+ hiroApi
312
+ .getMempoolTransactions({ sender_address: sponsorAddress, limit: 200 })
313
+ .catch(() => null),
314
+ hiroApi.getNonceInfo(sponsorAddress).catch(() => null),
315
+ ]);
316
+ if (sponsorMempoolResult) {
317
+ for (const tx of sponsorMempoolResult.results) {
318
+ sponsorMempoolIndex.set(tx.tx_id, {
319
+ nonce: tx.nonce,
320
+ sponsor_nonce: tx.sponsor_nonce,
321
+ fee_rate: tx.fee_rate ?? "unknown",
322
+ tx_status: tx.tx_status,
323
+ sponsored: tx.sponsored ?? false,
324
+ });
325
+ }
326
+ }
327
+ if (sponsorNonceInfo) {
328
+ sponsorMissingNonces = sponsorNonceInfo.detected_missing_nonces ?? [];
329
+ }
330
+ }
331
+ const sponsorMissingSet = new Set(sponsorMissingNonces);
332
+ // Build diagnostic per nonce slot
333
+ const pendingSlots = [];
334
+ const sortedNonces = [...byNonce.keys()].sort((a, b) => a - b);
335
+ let totalSponsored = 0;
336
+ let totalDirect = 0;
337
+ let totalBlocked = 0;
338
+ let totalNotInSponsorMempool = 0;
339
+ for (const senderNonce of sortedNonces) {
340
+ const entries = byNonce.get(senderNonce);
341
+ const candidates = [];
342
+ for (const entry of entries) {
343
+ const sponsorTx = sponsorMempoolIndex.get(entry.txid);
344
+ const inSponsorMempool = !!sponsorTx;
345
+ const isSponsored = inSponsorMempool ? (sponsorTx.sponsored || sponsorTx.sponsor_nonce !== undefined) : false;
346
+ let blocked = false;
347
+ let blockingGaps = [];
348
+ if (isSponsored && sponsorTx?.sponsor_nonce !== undefined) {
349
+ // Find sponsor nonce gaps below this tx's sponsor_nonce that would block it
350
+ blockingGaps = sponsorMissingNonces.filter((missing) => missing < sponsorTx.sponsor_nonce);
351
+ blocked = blockingGaps.length > 0;
352
+ }
353
+ candidates.push({
354
+ txid: entry.txid,
355
+ timestamp: entry.timestamp,
356
+ sponsored: isSponsored,
357
+ ...(sponsorTx?.sponsor_nonce !== undefined
358
+ ? { sponsorNonce: sponsorTx.sponsor_nonce }
359
+ : {}),
360
+ ...(sponsorTx ? { feeRate: sponsorTx.fee_rate, txStatus: sponsorTx.tx_status } : {}),
361
+ inSponsorMempool,
362
+ blocked,
363
+ ...(blockingGaps.length > 0 ? { blockingGaps } : {}),
364
+ });
365
+ if (isSponsored)
366
+ totalSponsored++;
367
+ else
368
+ totalDirect++;
369
+ if (blocked)
370
+ totalBlocked++;
371
+ if (!inSponsorMempool)
372
+ totalNotInSponsorMempool++;
373
+ }
374
+ pendingSlots.push({ senderNonce, candidates });
375
+ }
376
+ const total = localState.pending.length;
377
+ return createJsonResponse({
378
+ address,
379
+ pendingSlots,
380
+ sponsorAddress: sponsorAddress ?? null,
381
+ sponsorMissingNonces,
382
+ summary: {
383
+ total,
384
+ sponsored: totalSponsored,
385
+ direct: totalDirect,
386
+ blocked: totalBlocked,
387
+ notInSponsorMempool: totalNotInSponsorMempool,
388
+ },
389
+ });
390
+ }
391
+ catch (error) {
392
+ return createErrorResponse(error);
393
+ }
394
+ });
395
+ // ============================================================================
396
+ // nonce_heal — diagnose, fill gaps, and optionally RBF-bump the chain head
397
+ // ============================================================================
398
+ server.registerTool("nonce_heal", {
399
+ description: `Diagnose and heal the full nonce state for the active wallet in one shot.
400
+
401
+ Handles 90% of stuck-tx cases automatically:
402
+ 1. Fetches current nonce state from Hiro API (gaps, mempool)
403
+ 2. In dryRun mode: shows what would happen without broadcasting
404
+ 3. In execute mode (dryRun=false):
405
+ - Fills every gap with a 1 uSTX self-transfer to the PoX burn address
406
+ - Optionally RBF-bumps the chain head (lowest non-gap pending tx) to kick off processing
407
+
408
+ RBF bump behavior:
409
+ - Token-transfer txs: rebuilt at same nonce with fee * feeMultiplier and rebroadcast
410
+ - Sponsored txs: skipped with explanation (sender cannot RBF without sponsor key)
411
+ - Contract-call txs: skipped with manual RBF instructions
412
+
413
+ Always run nonce_health first to understand the current state.
414
+ Requires wallet to be unlocked for execute mode (dryRun=false).
415
+
416
+ Returns:
417
+ - address, dryRun flag, confirmedNonce
418
+ - gapsFound: list of missing nonces
419
+ - actions: per-action detail (fill_gap or bump_head) with txids, fees, status
420
+ - warnings: informational notes
421
+ - summary: human-readable description of what happened`,
422
+ inputSchema: {
423
+ address: z
424
+ .string()
425
+ .optional()
426
+ .describe("STX address to heal. Defaults to the active wallet address."),
427
+ dryRun: z
428
+ .boolean()
429
+ .default(true)
430
+ .describe("If true (default), preview proposed actions without broadcasting. Set to false to execute."),
431
+ bumpHead: z
432
+ .boolean()
433
+ .default(true)
434
+ .describe("Whether to RBF-bump the first real pending tx (chain head) after filling gaps. Default true."),
435
+ feeMultiplier: z
436
+ .number()
437
+ .min(1.1)
438
+ .default(1.5)
439
+ .describe("Fee multiplier for RBF bump (e.g. 1.5 = 50% higher fee). Minimum 1.1. Default 1.5."),
440
+ },
441
+ }, async ({ address: inputAddress, dryRun, bumpHead, feeMultiplier }) => {
442
+ try {
443
+ const walletAccount = getWalletManager().getAccount();
444
+ const address = inputAddress || walletAccount?.address;
445
+ if (!address) {
446
+ return createJsonResponse({
447
+ error: "No address provided and no wallet is unlocked. Provide an address or unlock a wallet first.",
448
+ });
449
+ }
450
+ if (!dryRun && !walletAccount) {
451
+ return createJsonResponse({
452
+ error: "Wallet must be unlocked to execute heal actions. Use wallet_unlock first, or set dryRun=true to preview.",
453
+ });
454
+ }
455
+ // Reload from disk to pick up changes from other processes
456
+ await reloadFromDisk();
457
+ const nonceInfo = await getHiroApi(NETWORK).getNonceInfo(address);
458
+ const confirmedNonce = nonceInfo.last_executed_tx_nonce;
459
+ const gapsFound = nonceInfo.detected_missing_nonces ?? [];
460
+ const mempoolNonces = nonceInfo.detected_mempool_nonces ?? [];
461
+ const gapSet = new Set(gapsFound);
462
+ const actions = [];
463
+ const warnings = [];
464
+ // Identify chain head: lowest non-gap mempool nonce
465
+ const chainHeadNonce = mempoolNonces.length > 0
466
+ ? mempoolNonces.filter((n) => !gapSet.has(n)).sort((a, b) => a - b)[0]
467
+ : undefined;
468
+ // ----------------------------------------------------------------
469
+ // dryRun: build proposed actions and return early
470
+ // ----------------------------------------------------------------
471
+ if (dryRun) {
472
+ for (const n of gapsFound) {
473
+ actions.push({ type: "fill_gap", nonce: n, txid: null, status: "proposed" });
474
+ }
475
+ if (bumpHead && chainHeadNonce !== undefined) {
476
+ // Find the chain head tx to inspect its type
477
+ const mempoolResult = await getHiroApi(NETWORK)
478
+ .getMempoolTransactions({ sender_address: address, limit: 50 })
479
+ .catch(() => null);
480
+ const headTx = mempoolResult?.results.find((tx) => tx.nonce === chainHeadNonce);
481
+ if (!headTx) {
482
+ warnings.push(`Could not find chain head tx at nonce ${chainHeadNonce} in mempool. It may have confirmed already.`);
483
+ }
484
+ else if (headTx.sponsored) {
485
+ actions.push({
486
+ type: "bump_head",
487
+ nonce: chainHeadNonce,
488
+ originalTxid: headTx.tx_id,
489
+ newTxid: null,
490
+ status: "skipped",
491
+ reason: "Sponsored tx — sender cannot RBF. Relay must recover.",
492
+ });
493
+ }
494
+ else {
495
+ // Fetch the full tx for tx_type
496
+ const fullTx = await getHiroApi(NETWORK)
497
+ .getTransaction(headTx.tx_id)
498
+ .catch(() => null);
499
+ if (fullTx?.tx_type === "token_transfer") {
500
+ const originalFee = parseInt(headTx.fee_rate ?? "0", 10);
501
+ const newFee = Math.ceil(originalFee * (feeMultiplier ?? 1.5));
502
+ actions.push({
503
+ type: "bump_head",
504
+ nonce: chainHeadNonce,
505
+ originalTxid: headTx.tx_id,
506
+ newTxid: null,
507
+ newFee: String(newFee),
508
+ status: "proposed",
509
+ });
510
+ }
511
+ else {
512
+ actions.push({
513
+ type: "bump_head",
514
+ nonce: chainHeadNonce,
515
+ originalTxid: headTx.tx_id,
516
+ newTxid: null,
517
+ status: "skipped",
518
+ reason: `Manual RBF needed for ${fullTx?.tx_type ?? "unknown"} at nonce ${chainHeadNonce} — rebuild and resubmit manually.`,
519
+ });
520
+ }
521
+ }
522
+ }
523
+ const gapCount = gapsFound.length;
524
+ const bumpAction = actions.find((a) => a.type === "bump_head");
525
+ const summary = gapCount === 0 && !bumpAction
526
+ ? "No gaps found and no head bump needed. Nonce state looks healthy."
527
+ : `Would fill ${gapCount} gap(s)${bumpAction ? ` and ${bumpAction.status === "proposed" ? "bump" : "skip"} chain head at nonce ${bumpAction.nonce}` : ""}. Set dryRun=false to execute.`;
528
+ return createJsonResponse({
529
+ address,
530
+ dryRun: true,
531
+ confirmedNonce,
532
+ gapsFound,
533
+ actions,
534
+ warnings,
535
+ summary,
536
+ });
537
+ }
538
+ // ----------------------------------------------------------------
539
+ // Execute mode
540
+ // ----------------------------------------------------------------
541
+ const fee = await resolveDefaultFee(NETWORK, "token_transfer");
542
+ // Fill each gap
543
+ for (const n of gapsFound) {
544
+ try {
545
+ const result = await broadcastGapFill(walletAccount.privateKey, walletAccount.address, n, fee);
546
+ actions.push({ type: "fill_gap", ...result });
547
+ if (result.status === "failed") {
548
+ warnings.push(`Gap fill at nonce ${n} failed: ${result.error}`);
549
+ }
550
+ }
551
+ catch (err) {
552
+ const msg = err instanceof Error ? err.message : String(err);
553
+ actions.push({ type: "fill_gap", nonce: n, txid: null, status: "failed", error: msg });
554
+ warnings.push(`Gap fill at nonce ${n} threw: ${msg}`);
555
+ }
556
+ }
557
+ // RBF bump chain head
558
+ if (bumpHead && chainHeadNonce !== undefined) {
559
+ const mempoolResult = await getHiroApi(NETWORK)
560
+ .getMempoolTransactions({ sender_address: address, limit: 50 })
561
+ .catch(() => null);
562
+ const headTx = mempoolResult?.results.find((tx) => tx.nonce === chainHeadNonce);
563
+ if (!headTx) {
564
+ warnings.push(`Could not find chain head tx at nonce ${chainHeadNonce} in mempool. It may have confirmed already.`);
565
+ }
566
+ else if (headTx.sponsored) {
567
+ actions.push({
568
+ type: "bump_head",
569
+ nonce: chainHeadNonce,
570
+ originalTxid: headTx.tx_id,
571
+ newTxid: null,
572
+ status: "skipped",
573
+ reason: "Sponsored tx — sender cannot RBF. Relay must recover.",
574
+ });
575
+ }
576
+ else {
577
+ const fullTx = await getHiroApi(NETWORK)
578
+ .getTransaction(headTx.tx_id)
579
+ .catch(() => null);
580
+ if (fullTx?.tx_type === "token_transfer") {
581
+ try {
582
+ const originalFee = parseInt(headTx.fee_rate ?? "0", 10);
583
+ const newFee = Math.ceil(originalFee * (feeMultiplier ?? 1.5));
584
+ const rbfTx = await makeSTXTokenTransfer({
585
+ recipient: POX_BURN_ADDRESS,
586
+ amount: 1n,
587
+ senderKey: walletAccount.privateKey,
588
+ network: getStacksNetwork(NETWORK),
589
+ memo: `rbf-bump:${chainHeadNonce}`,
590
+ nonce: BigInt(chainHeadNonce),
591
+ fee: BigInt(newFee),
592
+ });
593
+ const rbfBroadcast = await broadcastTransaction({
594
+ transaction: rbfTx,
595
+ network: getStacksNetwork(NETWORK),
596
+ });
597
+ if ("error" in rbfBroadcast) {
598
+ actions.push({
599
+ type: "bump_head",
600
+ nonce: chainHeadNonce,
601
+ originalTxid: headTx.tx_id,
602
+ newTxid: null,
603
+ newFee: String(newFee),
604
+ status: "failed",
605
+ error: `${rbfBroadcast.error} - ${rbfBroadcast.reason}`,
606
+ });
607
+ warnings.push(`RBF bump at nonce ${chainHeadNonce} failed: ${rbfBroadcast.error}`);
608
+ }
609
+ else {
610
+ await recordNonceUsed(walletAccount.address, chainHeadNonce, rbfBroadcast.txid);
611
+ actions.push({
612
+ type: "bump_head",
613
+ nonce: chainHeadNonce,
614
+ originalTxid: headTx.tx_id,
615
+ newTxid: rbfBroadcast.txid,
616
+ newFee: String(newFee),
617
+ status: "broadcast",
618
+ });
619
+ }
620
+ }
621
+ catch (err) {
622
+ const msg = err instanceof Error ? err.message : String(err);
623
+ actions.push({
624
+ type: "bump_head",
625
+ nonce: chainHeadNonce,
626
+ originalTxid: headTx.tx_id,
627
+ newTxid: null,
628
+ status: "failed",
629
+ error: msg,
630
+ });
631
+ warnings.push(`RBF bump at nonce ${chainHeadNonce} threw: ${msg}`);
632
+ }
633
+ }
634
+ else {
635
+ actions.push({
636
+ type: "bump_head",
637
+ nonce: chainHeadNonce,
638
+ originalTxid: headTx.tx_id,
639
+ newTxid: null,
640
+ status: "skipped",
641
+ reason: `Manual RBF needed for ${fullTx?.tx_type ?? "unknown"} at nonce ${chainHeadNonce} — rebuild and resubmit manually.`,
642
+ });
643
+ }
644
+ }
645
+ }
646
+ // Build summary
647
+ const filledCount = actions.filter((a) => a.type === "fill_gap" && a.status === "broadcast").length;
648
+ const failedGaps = actions.filter((a) => a.type === "fill_gap" && a.status === "failed").length;
649
+ const bumpAction = actions.find((a) => a.type === "bump_head");
650
+ let summary = "";
651
+ if (filledCount > 0) {
652
+ summary += `Filled ${filledCount} gap(s). `;
653
+ }
654
+ if (failedGaps > 0) {
655
+ summary += `${failedGaps} gap fill(s) failed (see warnings). `;
656
+ }
657
+ if (bumpAction) {
658
+ if (bumpAction.status === "broadcast") {
659
+ summary += `Bumped chain head at nonce ${bumpAction.nonce}. Chain should unstick within 1-2 blocks.`;
660
+ }
661
+ else if (bumpAction.status === "skipped") {
662
+ summary += `Chain head at nonce ${bumpAction.nonce} skipped: ${bumpAction.reason}`;
663
+ }
664
+ else if (bumpAction.status === "failed") {
665
+ summary += `Chain head bump at nonce ${bumpAction.nonce} failed (see warnings).`;
666
+ }
667
+ }
668
+ if (!summary) {
669
+ summary =
670
+ gapsFound.length === 0
671
+ ? "No gaps found. Nonce state looks healthy."
672
+ : "Actions attempted — check action statuses for details.";
673
+ }
674
+ return createJsonResponse({
675
+ address,
676
+ dryRun: false,
677
+ confirmedNonce,
678
+ gapsFound,
679
+ actions,
680
+ warnings,
681
+ summary: summary.trim(),
682
+ });
683
+ }
684
+ catch (error) {
685
+ return createErrorResponse(error);
686
+ }
687
+ });
688
+ }
689
+ //# sourceMappingURL=nonce.tools.js.map