@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.
- package/dist/config/sponsor.d.ts +8 -0
- package/dist/config/sponsor.d.ts.map +1 -1
- package/dist/config/sponsor.js +10 -0
- package/dist/config/sponsor.js.map +1 -1
- package/dist/services/hiro-api.d.ts +3 -0
- package/dist/services/hiro-api.d.ts.map +1 -1
- package/dist/services/hiro-api.js.map +1 -1
- package/dist/services/nonce-tracker.d.ts +135 -0
- package/dist/services/nonce-tracker.d.ts.map +1 -0
- package/dist/services/nonce-tracker.js +315 -0
- package/dist/services/nonce-tracker.js.map +1 -0
- package/dist/services/wallet-manager.d.ts +1 -1
- package/dist/services/wallet-manager.d.ts.map +1 -1
- package/dist/services/wallet-manager.js +10 -10
- package/dist/services/wallet-manager.js.map +1 -1
- package/dist/tools/arxiv-research.tools.d.ts +16 -0
- package/dist/tools/arxiv-research.tools.d.ts.map +1 -0
- package/dist/tools/arxiv-research.tools.js +433 -0
- package/dist/tools/arxiv-research.tools.js.map +1 -0
- package/dist/tools/inbox.tools.js +23 -18
- package/dist/tools/inbox.tools.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +9 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/nonce.tools.d.ts +11 -0
- package/dist/tools/nonce.tools.d.ts.map +1 -0
- package/dist/tools/nonce.tools.js +689 -0
- package/dist/tools/nonce.tools.js.map +1 -0
- package/dist/tools/skill-mappings.d.ts.map +1 -1
- package/dist/tools/skill-mappings.js +20 -9
- package/dist/tools/skill-mappings.js.map +1 -1
- package/dist/tools/wallet-management.tools.js +1 -1
- package/dist/tools/wallet-management.tools.js.map +1 -1
- package/dist/tools/yield-dashboard.tools.d.ts +17 -0
- package/dist/tools/yield-dashboard.tools.d.ts.map +1 -0
- package/dist/tools/yield-dashboard.tools.js +559 -0
- package/dist/tools/yield-dashboard.tools.js.map +1 -0
- package/dist/transactions/builder.d.ts +9 -20
- package/dist/transactions/builder.d.ts.map +1 -1
- package/dist/transactions/builder.js +34 -92
- package/dist/transactions/builder.js.map +1 -1
- package/dist/transactions/sponsor-builder.d.ts +6 -12
- package/dist/transactions/sponsor-builder.d.ts.map +1 -1
- package/dist/transactions/sponsor-builder.js +81 -39
- package/dist/transactions/sponsor-builder.js.map +1 -1
- package/dist/utils/relay-health.d.ts +14 -5
- package/dist/utils/relay-health.d.ts.map +1 -1
- package/dist/utils/relay-health.js +60 -76
- package/dist/utils/relay-health.js.map +1 -1
- package/dist/yield-hunter/index.js +4 -4
- package/dist/yield-hunter/index.js.map +1 -1
- package/package.json +1 -1
- 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
|