@aibtc/mcp-server 1.64.2 → 1.65.1

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/index.js CHANGED
@@ -7,6 +7,7 @@ import { createRequire } from "module";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { registerAllTools } from "./tools/index.js";
10
+ import { installBountyHint } from "./tools/bounty-hint.js";
10
11
  import { NETWORK, API_URL } from "./config/index.js";
11
12
  import { redactSensitive } from "./utils/redact.js";
12
13
  import { initializeStorage } from "./utils/storage.js";
@@ -253,9 +254,26 @@ else {
253
254
  const server = new McpServer({
254
255
  name: "aibtc-mcp-server",
255
256
  version: packageJson.version,
257
+ }, {
258
+ instructions: [
259
+ "This server lets an agent earn and move real Bitcoin/sBTC on Stacks.",
260
+ "",
261
+ "Hunt bounties. The most direct way to earn here is the sBTC bounty board:",
262
+ "call bounty_list to see open bounties, bounty_get for the task detail, do",
263
+ "the work, then bounty_submit your deliverable. Accepted submissions are paid",
264
+ "in sBTC. Check periodically for new open bounties.",
265
+ "",
266
+ "For the full menu of ways to put assets to work (stacking, DeFi yield,",
267
+ "trading, paid endpoints), call earning_opportunities. Submitting to a bounty",
268
+ "requires a Registered (L1+) on-chain identity — see identity_register.",
269
+ ].join("\n"),
256
270
  });
271
+ // Append a bounty-board hint to spend/onboarding-tool output.
272
+ // Must wrap registerTool before registration; restore it after.
273
+ const restoreBountyHint = installBountyHint(server);
257
274
  // Register all tools from the modular registry
258
275
  registerAllTools(server);
276
+ restoreBountyHint();
259
277
  async function main() {
260
278
  await initializeStorage();
261
279
  const transport = new StdioServerTransport();
@@ -9,6 +9,28 @@ export declare class X402RateLimitError extends Error {
9
9
  readonly retryAfterSeconds: number;
10
10
  constructor(message: string, retryAfterSeconds: number);
11
11
  }
12
+ /**
13
+ * How long a completed x402 request suppresses an identical repeat.
14
+ *
15
+ * This was 60s, which is shorter than a realistic agent retry: an agent that
16
+ * reports a failure to its operator and retries takes minutes, not seconds.
17
+ * In the incident behind #630 the two payments landed 362s apart — 6x the old
18
+ * window — so the guard never engaged and the endpoint was paid twice.
19
+ *
20
+ * The asymmetry favours a long window: a false dedup hit costs a wait (and
21
+ * still returns the prior txid so the caller can verify), while a miss costs a
22
+ * duplicate on-chain payment that cannot be reversed. Deliberate repeat
23
+ * purchases of the same endpoint with the same params must wait it out or vary
24
+ * a param; raise or lower via X402_DEDUP_TTL_SECONDS.
25
+ */
26
+ export declare const X402_DEDUP_TTL_MS: number;
27
+ /**
28
+ * Point the dedup cache at a different state file and drop what is in memory.
29
+ *
30
+ * Also the seam tests use to simulate a restart: re-pointing at the same file
31
+ * clears memory and forces the next read to come off disk.
32
+ */
33
+ export declare function setDedupStateFile(file: string): void;
12
34
  /**
13
35
  * Convert mnemonic to account
14
36
  */
@@ -168,14 +190,21 @@ export declare function probeEndpoint(options: {
168
190
  */
169
191
  export declare function generateDedupKey(method: string, url: string, params?: Record<string, string>, data?: Record<string, unknown>): string;
170
192
  /**
171
- * Check if a request was recently processed (within 60s)
193
+ * Check if a request was recently processed (within X402_DEDUP_TTL_MS).
194
+ *
195
+ * Loads persisted entries first so a payment broadcast before a server restart
196
+ * still suppresses the retry that follows it.
197
+ *
172
198
  * @returns txid if duplicate found, null otherwise
173
199
  */
174
- export declare function checkDedupCache(key: string): string | null;
200
+ export declare function checkDedupCache(key: string): Promise<string | null>;
175
201
  /**
176
- * Record a transaction in the dedup cache
202
+ * Record a transaction in the dedup cache and persist it.
203
+ *
204
+ * Awaits the write so the entry survives a crash immediately after a payment —
205
+ * the window where losing it costs a duplicate payment.
177
206
  */
178
- export declare function recordTransaction(key: string, txid: string): void;
207
+ export declare function recordTransaction(key: string, txid: string): Promise<void>;
179
208
  /**
180
209
  * Check if account has sufficient balance to pay for x402 endpoint.
181
210
  * @throws InsufficientBalanceError if balance is too low
@@ -10,6 +10,9 @@ import { formatStx, formatSbtc } from "../utils/formatting.js";
10
10
  import { getSbtcService } from "./sbtc.service.js";
11
11
  import { getHiroApi } from "./hiro-api.js";
12
12
  import { createHash } from "crypto";
13
+ import { promises as fs } from "fs";
14
+ import os from "os";
15
+ import path from "path";
13
16
  import { InsufficientBalanceError } from "../utils/errors.js";
14
17
  import { getContracts, parseContractId } from "../config/contracts.js";
15
18
  import { resolveDefaultFee } from "../utils/fee.js";
@@ -104,13 +107,167 @@ export class X402RateLimitError extends Error {
104
107
  this.name = "X402RateLimitError";
105
108
  }
106
109
  }
110
+ /**
111
+ * How long a completed x402 request suppresses an identical repeat.
112
+ *
113
+ * This was 60s, which is shorter than a realistic agent retry: an agent that
114
+ * reports a failure to its operator and retries takes minutes, not seconds.
115
+ * In the incident behind #630 the two payments landed 362s apart — 6x the old
116
+ * window — so the guard never engaged and the endpoint was paid twice.
117
+ *
118
+ * The asymmetry favours a long window: a false dedup hit costs a wait (and
119
+ * still returns the prior txid so the caller can verify), while a miss costs a
120
+ * duplicate on-chain payment that cannot be reversed. Deliberate repeat
121
+ * purchases of the same endpoint with the same params must wait it out or vary
122
+ * a param; raise or lower via X402_DEDUP_TTL_SECONDS.
123
+ */
124
+ export const X402_DEDUP_TTL_MS = parseSatsCap("X402_DEDUP_TTL_SECONDS", 900) * 1000; // 15 minutes
107
125
  // Transaction deduplication cache: {dedupKey -> {txid, timestamp}}
108
126
  const dedupCache = new Map();
127
+ /**
128
+ * Where the dedup cache is persisted.
129
+ *
130
+ * Memory alone left the guard open to the same double-pay it exists to stop: an
131
+ * MCP server restarts routinely (client reconnect, config change, crash), and a
132
+ * restart inside the TTL window dropped every entry. An agent retrying after one
133
+ * sailed past the guard and paid a second time — #630's outcome reached by a
134
+ * different route.
135
+ *
136
+ * Keys are SHA-256 digests (see generateDedupKey) and values are chain txids, so
137
+ * no request content reaches disk. The file is still written 0600 under a 0700
138
+ * directory to match the wallet store.
139
+ *
140
+ * Caveat: entries are keyed by request, not by payer. Switching wallets and
141
+ * repeating a request inside the window reports a hit for the other wallet's
142
+ * payment. That is the asymmetry the TTL already takes — a false hit costs a
143
+ * wait and still surfaces the prior txid to verify, while a miss costs an
144
+ * irreversible duplicate payment.
145
+ *
146
+ * Override the location with X402_DEDUP_STATE_FILE.
147
+ */
148
+ let dedupStateFile = process.env.X402_DEDUP_STATE_FILE ||
149
+ path.join(os.homedir(), ".aibtc", "x402-dedup.json");
150
+ let dedupLoad = null;
151
+ let dedupWriteLock = Promise.resolve();
152
+ /**
153
+ * Point the dedup cache at a different state file and drop what is in memory.
154
+ *
155
+ * Also the seam tests use to simulate a restart: re-pointing at the same file
156
+ * clears memory and forces the next read to come off disk.
157
+ */
158
+ export function setDedupStateFile(file) {
159
+ dedupStateFile = file;
160
+ dedupLoad = null;
161
+ dedupCache.clear();
162
+ }
163
+ function isFresh(entry, now) {
164
+ return now - entry.timestamp <= X402_DEDUP_TTL_MS;
165
+ }
166
+ /** Read persisted entries. A missing file is the normal first-run case. */
167
+ async function readDedupState() {
168
+ let raw;
169
+ try {
170
+ raw = await fs.readFile(dedupStateFile, "utf8");
171
+ }
172
+ catch (err) {
173
+ if (err.code !== "ENOENT") {
174
+ console.error(`[x402-dedup] Could not read ${dedupStateFile}: ${err instanceof Error ? err.message : String(err)}`);
175
+ }
176
+ return {};
177
+ }
178
+ try {
179
+ const parsed = JSON.parse(raw);
180
+ if (!parsed || typeof parsed !== "object")
181
+ return {};
182
+ return parsed;
183
+ }
184
+ catch {
185
+ // A corrupt file must not brick every x402 call. Report it and continue
186
+ // with the in-memory guard; the next write replaces the file.
187
+ console.error(`[x402-dedup] Ignoring corrupt state at ${dedupStateFile}`);
188
+ return {};
189
+ }
190
+ }
191
+ /**
192
+ * Load persisted entries into memory once per state file.
193
+ *
194
+ * Must be awaited before any dedup read: answering from a not-yet-loaded cache
195
+ * would report "no prior payment" for one that is sitting on disk, which is the
196
+ * exact miss this persistence closes.
197
+ */
198
+ async function ensureDedupLoaded() {
199
+ if (!dedupLoad) {
200
+ dedupLoad = (async () => {
201
+ const persisted = await readDedupState();
202
+ const now = Date.now();
203
+ for (const [key, entry] of Object.entries(persisted)) {
204
+ if (!entry ||
205
+ typeof entry.txid !== "string" ||
206
+ typeof entry.timestamp !== "number") {
207
+ continue;
208
+ }
209
+ if (!isFresh(entry, now))
210
+ continue;
211
+ // Anything recorded in this process is at least as fresh as disk.
212
+ const existing = dedupCache.get(key);
213
+ if (!existing || existing.timestamp < entry.timestamp) {
214
+ dedupCache.set(key, entry);
215
+ }
216
+ }
217
+ })();
218
+ }
219
+ return dedupLoad;
220
+ }
221
+ /**
222
+ * Merge memory over disk and write atomically, dropping expired entries.
223
+ *
224
+ * Re-reads inside the lock so a second server instance sharing the file does not
225
+ * lose the entries this one never saw. A failure here is logged rather than
226
+ * thrown: the caller records *after* funds have already moved, so rejecting
227
+ * would turn a recorded payment into a reported error while leaving the
228
+ * in-memory guard intact.
229
+ */
230
+ async function persistDedupCache() {
231
+ dedupWriteLock = dedupWriteLock
232
+ .then(async () => {
233
+ const onDisk = await readDedupState();
234
+ const now = Date.now();
235
+ const merged = {};
236
+ for (const [key, entry] of Object.entries(onDisk)) {
237
+ if (entry &&
238
+ typeof entry.txid === "string" &&
239
+ typeof entry.timestamp === "number" &&
240
+ isFresh(entry, now)) {
241
+ merged[key] = entry;
242
+ }
243
+ }
244
+ for (const [key, entry] of dedupCache) {
245
+ if (!isFresh(entry, now))
246
+ continue;
247
+ const existing = merged[key];
248
+ if (!existing || existing.timestamp <= entry.timestamp) {
249
+ merged[key] = entry;
250
+ }
251
+ }
252
+ await fs.mkdir(path.dirname(dedupStateFile), {
253
+ recursive: true,
254
+ mode: 0o700,
255
+ });
256
+ // pid-suffixed so concurrent servers do not clobber each other's temp.
257
+ const tmp = `${dedupStateFile}.${process.pid}.tmp`;
258
+ await fs.writeFile(tmp, JSON.stringify(merged), { mode: 0o600 });
259
+ await fs.rename(tmp, dedupStateFile);
260
+ })
261
+ .catch((err) => {
262
+ console.error(`[x402-dedup] Could not persist ${dedupStateFile}: ${err instanceof Error ? err.message : String(err)}`);
263
+ });
264
+ return dedupWriteLock;
265
+ }
109
266
  // Cleanup expired dedup entries every 5 minutes
110
267
  setInterval(() => {
111
268
  const now = Date.now();
112
269
  for (const [key, value] of dedupCache) {
113
- if (now - value.timestamp > 60000) {
270
+ if (!isFresh(value, now)) {
114
271
  dedupCache.delete(key);
115
272
  }
116
273
  }
@@ -845,26 +1002,36 @@ export function generateDedupKey(method, url, params, data) {
845
1002
  return createHash('sha256').update(payload).digest('hex');
846
1003
  }
847
1004
  /**
848
- * Check if a request was recently processed (within 60s)
1005
+ * Check if a request was recently processed (within X402_DEDUP_TTL_MS).
1006
+ *
1007
+ * Loads persisted entries first so a payment broadcast before a server restart
1008
+ * still suppresses the retry that follows it.
1009
+ *
849
1010
  * @returns txid if duplicate found, null otherwise
850
1011
  */
851
- export function checkDedupCache(key) {
1012
+ export async function checkDedupCache(key) {
1013
+ await ensureDedupLoaded();
852
1014
  const cached = dedupCache.get(key);
853
1015
  if (!cached) {
854
1016
  return null;
855
1017
  }
856
1018
  const now = Date.now();
857
- if (now - cached.timestamp > 60000) {
1019
+ if (!isFresh(cached, now)) {
858
1020
  dedupCache.delete(key);
859
1021
  return null;
860
1022
  }
861
1023
  return cached.txid;
862
1024
  }
863
1025
  /**
864
- * Record a transaction in the dedup cache
1026
+ * Record a transaction in the dedup cache and persist it.
1027
+ *
1028
+ * Awaits the write so the entry survives a crash immediately after a payment —
1029
+ * the window where losing it costs a duplicate payment.
865
1030
  */
866
- export function recordTransaction(key, txid) {
1031
+ export async function recordTransaction(key, txid) {
1032
+ await ensureDedupLoaded();
867
1033
  dedupCache.set(key, { txid, timestamp: Date.now() });
1034
+ await persistDedupCache();
868
1035
  }
869
1036
  /**
870
1037
  * Check if account has sufficient balance to pay for x402 endpoint.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Bounty hint injector.
3
+ *
4
+ * Appends a one-line pointer to the sBTC bounty board on the output of two tool
5
+ * groups:
6
+ * - SPEND_TOOLS: money-moving tools (transfers, contract calls, swaps, protocol
7
+ * deposits). When an agent has just moved value, remind it the board pays sBTC.
8
+ * - ONBOARDING_TOOLS: identity registration and wallet creation/import. When an
9
+ * agent has just set itself up, point it at the fastest way to start earning.
10
+ *
11
+ * Deliberately NOT applied to read/query tools an agent calls constantly
12
+ * (balances, status, lists) — that would be noise. Only these two groups get it.
13
+ *
14
+ * The hint is a separate text content block, static (no network call), and only
15
+ * added to successful results — never to errors, and never touching the tool's
16
+ * own JSON payload.
17
+ */
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ /**
20
+ * Tools that move or spend value. A hint is appended to their successful output.
21
+ */
22
+ export declare const SPEND_TOOLS: ReadonlySet<string>;
23
+ /**
24
+ * Onboarding tools. A hint is appended right after an agent registers an identity
25
+ * or creates/imports a wallet — the moment it's deciding what to do next.
26
+ */
27
+ export declare const ONBOARDING_TOOLS: ReadonlySet<string>;
28
+ /**
29
+ * Monkey-patch `server.registerTool` so that any tool in SPEND_TOOLS or
30
+ * ONBOARDING_TOOLS has the relevant bounty hint appended to its (successful)
31
+ * output. Must be called BEFORE registering the tools. No-op for every other tool.
32
+ *
33
+ * Returns a cleanup function that restores the original method, mirroring
34
+ * `withSkillMeta` in tools/index.ts — call it once registration is done.
35
+ */
36
+ export declare function installBountyHint(server: McpServer): () => void;
37
+ //# sourceMappingURL=bounty-hint.d.ts.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tools that move or spend value. A hint is appended to their successful output.
3
+ */
4
+ export const SPEND_TOOLS = new Set([
5
+ // Core transfers / contract / swap chokepoints
6
+ "transfer_stx",
7
+ "transfer_btc",
8
+ "transfer_token",
9
+ "transfer_nft",
10
+ "transfer_rune",
11
+ "sbtc_transfer",
12
+ "call_contract",
13
+ "deploy_contract",
14
+ "bitflow_swap",
15
+ "alex_swap",
16
+ "lightning_pay_invoice",
17
+ // Stacking / PoX
18
+ "stack_stx",
19
+ "extend_stacking",
20
+ "dual_stacking_enroll",
21
+ // Zest lending
22
+ "zest_supply",
23
+ "zest_borrow",
24
+ "zest_repay",
25
+ // Pillar smart wallet (browser-handoff)
26
+ "pillar_supply",
27
+ "pillar_boost",
28
+ "pillar_unwind",
29
+ "pillar_send",
30
+ "pillar_fund",
31
+ "pillar_auto_compound",
32
+ // Pillar smart wallet (agent-signed direct)
33
+ "pillar_direct_supply",
34
+ "pillar_direct_boost",
35
+ "pillar_direct_unwind",
36
+ "pillar_direct_send",
37
+ "pillar_direct_stack_stx",
38
+ // JingSwap batch auction
39
+ "jingswap_deposit_stx",
40
+ "jingswap_deposit_sbtc",
41
+ // Styx BTC→sBTC
42
+ "styx_deposit",
43
+ // StackSpot lottery
44
+ "stackspot_join_pot",
45
+ "stackspot_start_pot",
46
+ // sBTC peg
47
+ "sbtc_deposit",
48
+ "sbtc_withdraw",
49
+ "sbtc_initiate_withdrawal",
50
+ // Inbox (direct sBTC send)
51
+ "send_inbox_message_direct",
52
+ ]);
53
+ /**
54
+ * Onboarding tools. A hint is appended right after an agent registers an identity
55
+ * or creates/imports a wallet — the moment it's deciding what to do next.
56
+ */
57
+ export const ONBOARDING_TOOLS = new Set([
58
+ "identity_register",
59
+ "wallet_create",
60
+ "wallet_import",
61
+ ]);
62
+ const SPEND_HINT = "💰 Tip: the AIBTC bounty board pays sBTC for completed tasks — " +
63
+ "call bounty_list to see open bounties you could earn from.";
64
+ const ONBOARDING_HINT = "💰 You're set up. The fastest way to start earning sBTC is the AIBTC bounty " +
65
+ "board — call bounty_list to see open bounties, or earning_opportunities for " +
66
+ "the full menu of ways to put your assets to work.";
67
+ /** Resolve the hint text for a tool, or null if the tool gets no hint. */
68
+ function hintFor(name) {
69
+ if (SPEND_TOOLS.has(name))
70
+ return SPEND_HINT;
71
+ if (ONBOARDING_TOOLS.has(name))
72
+ return ONBOARDING_HINT;
73
+ return null;
74
+ }
75
+ function appendHint(result, hint) {
76
+ // Never annotate an error result — the agent should see the failure cleanly.
77
+ if (!result || result.isError)
78
+ return result;
79
+ const content = Array.isArray(result.content) ? result.content : [];
80
+ return {
81
+ ...result,
82
+ content: [...content, { type: "text", text: hint }],
83
+ };
84
+ }
85
+ /**
86
+ * Monkey-patch `server.registerTool` so that any tool in SPEND_TOOLS or
87
+ * ONBOARDING_TOOLS has the relevant bounty hint appended to its (successful)
88
+ * output. Must be called BEFORE registering the tools. No-op for every other tool.
89
+ *
90
+ * Returns a cleanup function that restores the original method, mirroring
91
+ * `withSkillMeta` in tools/index.ts — call it once registration is done.
92
+ */
93
+ export function installBountyHint(server) {
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ const original = server.registerTool;
96
+ const hasOwn = Object.prototype.hasOwnProperty.call(server, "registerTool");
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ server.registerTool = function (name, config, cb) {
99
+ const hint = hintFor(name);
100
+ if (!hint) {
101
+ return original.call(server, name, config, cb);
102
+ }
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ const wrapped = async (...args) => {
105
+ const result = await cb(...args);
106
+ return appendHint(result, hint);
107
+ };
108
+ return original.call(server, name, config, wrapped);
109
+ };
110
+ return () => {
111
+ if (hasOwn) {
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ server.registerTool = original;
114
+ }
115
+ else {
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ delete server.registerTool;
118
+ }
119
+ };
120
+ }
121
+ //# sourceMappingURL=bounty-hint.js.map
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { createApiClient, API_URL, probeEndpoint, formatPaymentAmount, checkSufficientBalance, generateDedupKey, checkDedupCache, recordTransaction, NETWORK } from "../services/x402.service.js";
2
+ import { createApiClient, API_URL, probeEndpoint, formatPaymentAmount, checkSufficientBalance, generateDedupKey, checkDedupCache, recordTransaction, X402_DEDUP_TTL_MS, NETWORK } from "../services/x402.service.js";
3
3
  import { ALL_ENDPOINTS, searchEndpoints, formatEndpointsTable, getEndpointsBySource, getCategories, } from "../endpoints/registry.js";
4
4
  import { createJsonResponse, createErrorResponse } from "../utils/index.js";
5
5
  import { X402_HEADERS } from "../utils/x402-protocol.js";
@@ -270,6 +270,9 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
270
270
  },
271
271
  }, async ({ method, url, path, apiUrl, params, data, autoApprove, asset }) => {
272
272
  let fullUrl = "";
273
+ // Hoisted so the catch can record a broadcast payment for dedup — a
274
+ // failed settlement still spends real funds (#630).
275
+ let dedupKey = null;
273
276
  try {
274
277
  const parsed = parseEndpointUrl({ url, path, apiUrl, params });
275
278
  fullUrl = parsed.fullUrl;
@@ -281,14 +284,27 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
281
284
  // autoApprove=true: check dedup cache before any network request, then execute
282
285
  // with a single request. Balance validation happens inside the onBeforePayment
283
286
  // callback when the interceptor receives the 402, eliminating the separate probe.
284
- const dedupKey = generateDedupKey(method, fullUrl, params, data);
285
- const existingTxid = checkDedupCache(dedupKey);
287
+ dedupKey = generateDedupKey(method, fullUrl, params, data);
288
+ const existingTxid = await checkDedupCache(dedupKey);
286
289
  if (existingTxid) {
290
+ const windowSeconds = Math.round(X402_DEDUP_TTL_MS / 1000);
291
+ const pending = existingTxid.startsWith("pending:");
287
292
  return createJsonResponse({
288
293
  endpoint: `${method} ${fullUrl}`,
289
- message: 'Request already processed within the last 60 seconds. This prevents accidental duplicate payments.',
290
- txid: existingTxid,
291
- note: 'Wait 60s or use different endpoint/params to force a new transaction.',
294
+ // Deliberately says "broadcast", not "paid": the entry is written
295
+ // whenever a payment was signed and broadcast, including when
296
+ // settlement then reported an error. Claiming it succeeded would
297
+ // be wrong in exactly the cases the caller most needs to check.
298
+ message: `An identical request already broadcast a payment within the last ${windowSeconds} seconds. This prevents accidental duplicate payments.`,
299
+ // A prior attempt whose txid was never observable is recorded with a
300
+ // synthetic marker. Report it as null rather than leaking the marker,
301
+ // which is not a chain txid and must not be treated as one.
302
+ txid: pending ? null : existingTxid,
303
+ ...(pending && {
304
+ txidNote: "The earlier payment was broadcast but its txid was not observable in the response. " +
305
+ "Query get_account_transactions to find the settled txid before paying again.",
306
+ }),
307
+ note: `The earlier payment may have settled even if it reported an error — verify it before retrying. Wait ${windowSeconds}s or vary the endpoint/params to force a new transaction.`,
292
308
  });
293
309
  }
294
310
  const api = await createApiClient(parsed.baseUrl, {
@@ -321,7 +337,7 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
321
337
  // observable, using a synthetic pending marker that cannot be
322
338
  // confused for a real chain txid.
323
339
  if (paymentAttempted) {
324
- recordTransaction(dedupKey, txid ?? `pending:${dedupKey}`);
340
+ await recordTransaction(dedupKey, txid ?? `pending:${dedupKey}`);
325
341
  }
326
342
  // Build the txid response fields per the behavior matrix:
327
343
  // payment attempted + observable txid → { txid: "0x..." }
@@ -352,9 +368,20 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
352
368
  // interceptor in x402.service.ts) and return it so the agent can verify.
353
369
  const axiosError = error;
354
370
  const canonicalStatus = axiosError.x402PaymentStatus;
371
+ // The presence of this header means the interceptor signed and
372
+ // broadcast a payment. That spends real funds whether or not
373
+ // settlement succeeded, so record it for dedup before returning any
374
+ // error — otherwise an identical retry passes the dedup guard and pays
375
+ // a second time for an invoice already settled on chain (#630).
376
+ // Mirrors the success path, including the synthetic pending marker
377
+ // used when the txid is not yet observable.
378
+ const paymentSigHeader = axiosError.config?.headers?.[X402_HEADERS.PAYMENT_SIGNATURE];
379
+ if (paymentSigHeader && dedupKey) {
380
+ const broadcastTxid = extractTxidFromPaymentSignature(paymentSigHeader);
381
+ await recordTransaction(dedupKey, broadcastTxid ?? `pending:${dedupKey}`);
382
+ }
355
383
  if (canonicalStatus) {
356
384
  const baseError = formatEndpointError(error, label);
357
- const paymentSigHeader = axiosError.config?.headers?.[X402_HEADERS.PAYMENT_SIGNATURE];
358
385
  const fallbackTxid = paymentSigHeader
359
386
  ? extractTxidFromPaymentSignature(paymentSigHeader)
360
387
  : null;
@@ -382,7 +409,6 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
382
409
  content: [{ type: "text", text }],
383
410
  };
384
411
  }
385
- const paymentSigHeader = axiosError.config?.headers?.[X402_HEADERS.PAYMENT_SIGNATURE];
386
412
  if (paymentSigHeader) {
387
413
  const txid = extractTxidFromPaymentSignature(paymentSigHeader);
388
414
  const fallbackPaymentId = extractPaymentIdFromPaymentSignature(paymentSigHeader);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibtc/mcp-server",
3
- "version": "1.64.2",
3
+ "version": "1.65.1",
4
4
  "description": "Bitcoin-native MCP server for AI agents: BTC/STX wallets, DeFi yield, sBTC peg, NFTs, and x402 payments.",
5
5
  "mcpName": "io.github.aibtcdev/mcp-server",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Bitcoin L1 wallet for agents - check balances, send BTC, manage UTX
4
4
  license: MIT
5
5
  metadata:
6
6
  author: aibtcdev
7
- version: 1.64.2 # x-release-please-version
7
+ version: 1.65.1 # x-release-please-version
8
8
  npm: "@aibtc/mcp-server"
9
9
  github: https://github.com/aibtcdev/aibtc-mcp-server
10
10
  ---