@aibtc/mcp-server 1.78.0 → 1.79.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.
@@ -17,7 +17,11 @@ export declare const INFERENCE_GATEWAY = "https://inference.aibtc.com";
17
17
  /** The largest model on the marketplace at the flat 1-sat price when this shipped. */
18
18
  export declare const DEFAULT_AUTO_REPLY_MODEL = "meta-llama/Llama-3.1-8B-Instruct";
19
19
  export declare const DEFAULT_DAILY_CAP_SATS = 1000;
20
+ /** Most sats paid for one reply. The marketplace charges 1 sat today. */
21
+ export declare const DEFAULT_MAX_SATS_PER_REPLY = 10;
20
22
  export declare const DEFAULT_MAX_REPLIES_PER_RUN = 5;
23
+ /** Paid inference failures on one message before runs stop paying for it. */
24
+ export declare const MAX_PAID_ATTEMPTS = 2;
21
25
  /** The outbox rejects longer replies (validation_failed). */
22
26
  export declare const MAX_REPLY_CHARS = 500;
23
27
  export interface InboxMessage {
@@ -36,8 +40,9 @@ export interface InboxMessage {
36
40
  peerBtcAddress?: string;
37
41
  peerDisplayName?: string;
38
42
  }
39
- export type SkipReason = "already_replied" | "already_handled" | "unpaid" | "payment_not_settled" | "from_self" | "run_limit" | "daily_cap" | "rate_limited";
40
- interface AddressLedger {
43
+ export type SkipReason = "already_replied" | "already_handled" | "unpaid" | "payment_not_settled" | "from_self" | "run_limit" | "in_progress" | "gave_up" | "daily_cap" | "price_ceiling" | "spend_limit" | "rate_limited";
44
+ type StopReason = "daily_cap" | "price_ceiling" | "spend_limit" | "rate_limited";
45
+ export interface AddressLedger {
41
46
  /** messageId -> ISO time the reply was posted (or found already posted). */
42
47
  handled: Record<string, string>;
43
48
  /**
@@ -47,6 +52,18 @@ interface AddressLedger {
47
52
  pending: Record<string, string>;
48
53
  /** UTC day -> sats spent on auto-reply inference that day. */
49
54
  spentSats: Record<string, number>;
55
+ /** messageId -> ISO time a run claimed it. Stops two runs paying for one message. */
56
+ claimed: Record<string, string>;
57
+ /** messageId -> paid inference attempts that failed. At MAX_PAID_ATTEMPTS it is given up. */
58
+ paidFailures: Record<string, number>;
59
+ /**
60
+ * messageId -> a dry-run draft and the prompt/model key it was written with.
61
+ * Later runs with the same key reuse it instead of paying again.
62
+ */
63
+ drafts: Record<string, {
64
+ text: string;
65
+ key: string;
66
+ }>;
50
67
  }
51
68
  /** Point the ledger at another file (tests). */
52
69
  export declare function setAutoReplyLedgerFile(file: string): void;
@@ -57,6 +74,11 @@ export declare class DailyCapError extends Error {
57
74
  readonly price: number;
58
75
  constructor(spent: number, cap: number, price: number);
59
76
  }
77
+ export declare class PriceCeilingError extends Error {
78
+ readonly price: number;
79
+ readonly max: number;
80
+ constructor(price: number, max: number);
81
+ }
60
82
  export declare function fetchInbox(btcAddress: string): Promise<InboxMessage[]>;
61
83
  /**
62
84
  * Why a message must not be auto-answered, or null when it may be. A message
@@ -83,6 +105,8 @@ export interface GeneratedReply {
83
105
  * agent's own model, plugs in here without touching the inbox side.
84
106
  */
85
107
  export interface ReplyGenerator {
108
+ /** The model behind it, so a dry-run draft is reused only by the same source. */
109
+ readonly id?: string;
86
110
  generate(input: {
87
111
  systemPrompt: string;
88
112
  message: InboxMessage;
@@ -92,6 +116,13 @@ export interface ReplyGenerator {
92
116
  }
93
117
  /** Strip reasoning blocks some models emit, collapse whitespace, fit the outbox limit. */
94
118
  export declare function cleanReply(raw: string): string;
119
+ /**
120
+ * The inference was paid for but its response is not a usable reply. The run
121
+ * keeps the cap booking and counts it as a paid failure.
122
+ */
123
+ export declare class PaidInferenceError extends Error {
124
+ constructor(message: string);
125
+ }
95
126
  export declare function marketplaceReplyGenerator(model: string): ReplyGenerator;
96
127
  /** Live model ids on the marketplace (status "live"). */
97
128
  export declare function listLiveModels(): Promise<string[]>;
@@ -112,41 +143,42 @@ export interface AutoReplyRunOptions {
112
143
  account: Account;
113
144
  systemPrompt: string;
114
145
  dailyCapSats: number;
146
+ /** Most sats paid for one reply. A higher quote stops the run before anything is signed. */
147
+ maxSatsPerReply: number;
115
148
  maxRepliesPerRun: number;
116
149
  dryRun: boolean;
117
150
  generator: ReplyGenerator;
118
151
  }
152
+ interface ReplyRecord {
153
+ messageId: string;
154
+ from: string;
155
+ reply: string;
156
+ inferenceCostSats: number;
157
+ inferenceTxid?: string;
158
+ provider?: string;
159
+ /** The reply was paid for in an earlier run whose post failed. */
160
+ reusedPending?: boolean;
161
+ /** The reply is a dry-run draft written with the same prompt and model. */
162
+ reusedDraft?: boolean;
163
+ }
119
164
  export interface AutoReplyRunSummary {
120
165
  checked: number;
121
166
  eligible: number;
122
- replied: Array<{
123
- messageId: string;
124
- from: string;
125
- reply: string;
126
- inferenceCostSats: number;
127
- inferenceTxid?: string;
128
- provider?: string;
129
- reusedPending?: boolean;
130
- }>;
131
- drafted: Array<{
132
- messageId: string;
133
- from: string;
134
- reply: string;
135
- inferenceCostSats: number;
136
- inferenceTxid?: string;
137
- provider?: string;
138
- }>;
167
+ replied: ReplyRecord[];
168
+ drafted: ReplyRecord[];
139
169
  skipped: Array<{
140
170
  messageId: string;
141
171
  reason: SkipReason;
142
172
  }>;
173
+ /** `gaveUp`: the message failed after payment MAX_PAID_ATTEMPTS times and is no longer retried. */
143
174
  failed: Array<{
144
175
  messageId: string;
145
176
  error: string;
146
177
  replySaved: boolean;
178
+ gaveUp?: boolean;
147
179
  }>;
148
180
  /** Why the run stopped before working through every eligible message. */
149
- stoppedEarly?: "daily_cap" | "rate_limited";
181
+ stoppedEarly?: StopReason;
150
182
  inferenceSatsSpent: number;
151
183
  inferenceSatsSpentToday: number;
152
184
  inferenceCapRemainingSats: number;
@@ -11,19 +11,31 @@
11
11
  * Spend is bounded twice: the auto-reply daily cap below (its own ledger), and
12
12
  * the wallet-wide spend limiter that books every x402 payment.
13
13
  */
14
+ import { createHash } from "crypto";
14
15
  import os from "os";
15
16
  import path from "path";
16
17
  import { promises as fs } from "fs";
17
18
  import { p2wpkh, NETWORK as BTC_MAINNET } from "@scure/btc-signer";
18
19
  import { bip322Sign } from "../utils/bip322.js";
19
20
  import { withSharedStateLock } from "./state-lock.js";
21
+ import { SpendLimitError } from "./spend-limiter.js";
20
22
  import { postWithX402Payment, paymentWasSent } from "./x402-post.js";
21
23
  export const INBOX_API = "https://aibtc.com/api";
22
24
  export const INFERENCE_GATEWAY = "https://inference.aibtc.com";
23
25
  /** The largest model on the marketplace at the flat 1-sat price when this shipped. */
24
26
  export const DEFAULT_AUTO_REPLY_MODEL = "meta-llama/Llama-3.1-8B-Instruct";
25
27
  export const DEFAULT_DAILY_CAP_SATS = 1000;
28
+ /** Most sats paid for one reply. The marketplace charges 1 sat today. */
29
+ export const DEFAULT_MAX_SATS_PER_REPLY = 10;
26
30
  export const DEFAULT_MAX_REPLIES_PER_RUN = 5;
31
+ /** Paid inference failures on one message before runs stop paying for it. */
32
+ export const MAX_PAID_ATTEMPTS = 2;
33
+ /**
34
+ * How long a run's claim on a message blocks other runs (this server or the
35
+ * skills CLI, which share the ledger). Longer than one message takes, so only
36
+ * a claim left by a crashed run expires.
37
+ */
38
+ const CLAIM_TTL_MS = 10 * 60_000;
27
39
  /** The outbox rejects longer replies (validation_failed). */
28
40
  export const MAX_REPLY_CHARS = 500;
29
41
  /** One page, newest first. Older unanswered messages are out of reach of a run. */
@@ -56,7 +68,14 @@ async function writeLedger(state) {
56
68
  await fs.rename(tmp, ledgerFile);
57
69
  }
58
70
  function ledgerFor(state, btcAddress) {
59
- const entry = (state[btcAddress] ??= { handled: {}, pending: {}, spentSats: {} });
71
+ const entry = (state[btcAddress] ??= {});
72
+ // Fields added later (and by the skills CLI, which shares this file) may be missing.
73
+ entry.handled ??= {};
74
+ entry.pending ??= {};
75
+ entry.spentSats ??= {};
76
+ entry.claimed ??= {};
77
+ entry.paidFailures ??= {};
78
+ entry.drafts ??= {};
60
79
  // Keep only today's spend: the cap is per UTC day.
61
80
  const today = todayKey();
62
81
  for (const day of Object.keys(entry.spentSats)) {
@@ -109,6 +128,47 @@ async function releaseDailySats(btcAddress, sats) {
109
128
  ledger.spentSats[day] = Math.max(0, (ledger.spentSats[day] ?? 0) - sats);
110
129
  });
111
130
  }
131
+ export class PriceCeilingError extends Error {
132
+ price;
133
+ max;
134
+ constructor(price, max) {
135
+ super(`Reply inference costs ${price} sats, above the ${max}-sat per-reply ceiling.`);
136
+ this.price = price;
137
+ this.max = max;
138
+ this.name = "PriceCeilingError";
139
+ }
140
+ }
141
+ /**
142
+ * Claim a message for this run under the ledger lock, re-reading the ledger
143
+ * so a run that started earlier and answered (or claimed) it in the meantime
144
+ * wins. Returns a reply already paid for, if there is one.
145
+ */
146
+ async function claimMessage(btcAddress, messageId, draftKey) {
147
+ return updateLedger(btcAddress, (ledger) => {
148
+ if (ledger.handled[messageId])
149
+ return { skip: "already_handled" };
150
+ if ((ledger.paidFailures[messageId] ?? 0) >= MAX_PAID_ATTEMPTS)
151
+ return { skip: "gave_up" };
152
+ const claimedAt = Date.parse(ledger.claimed[messageId] ?? "");
153
+ if (Date.now() - claimedAt < CLAIM_TTL_MS)
154
+ return { skip: "in_progress" };
155
+ ledger.claimed[messageId] = new Date().toISOString();
156
+ const pending = ledger.pending[messageId];
157
+ if (pending)
158
+ return { saved: { text: pending, from: "pending" } };
159
+ const draft = ledger.drafts[messageId];
160
+ if (draft?.key === draftKey)
161
+ return { saved: { text: draft.text, from: "draft" } };
162
+ return {};
163
+ });
164
+ }
165
+ /**
166
+ * Identifies the prompt and model a draft was written with. Same key as the
167
+ * skills CLI, so a draft written by one is reused by the other.
168
+ */
169
+ function draftKeyFor(systemPrompt, generatorId) {
170
+ return createHash("sha256").update(`${generatorId ?? ""}\n${systemPrompt}`).digest("hex").slice(0, 16);
171
+ }
112
172
  // ============================================================================
113
173
  // Inbox read + eligibility
114
174
  // ============================================================================
@@ -137,6 +197,8 @@ export function skipReason(message, self, ledger) {
137
197
  return "already_replied";
138
198
  if (ledger.handled[message.messageId])
139
199
  return "already_handled";
200
+ if ((ledger.paidFailures[message.messageId] ?? 0) >= MAX_PAID_ATTEMPTS)
201
+ return "gave_up";
140
202
  if (message.fromAddress === self.stxAddress || message.peerBtcAddress === self.btcAddress) {
141
203
  return "from_self";
142
204
  }
@@ -164,13 +226,32 @@ function userPrompt(message) {
164
226
  }
165
227
  /** Strip reasoning blocks some models emit, collapse whitespace, fit the outbox limit. */
166
228
  export function cleanReply(raw) {
167
- const text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/\s+/g, " ").trim();
229
+ const text = raw
230
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
231
+ .replace(/<think>[\s\S]*$/i, "") // unterminated block: the model ran out of tokens mid-thought
232
+ .replace(/\s+/g, " ")
233
+ .trim();
168
234
  if (text.length <= MAX_REPLY_CHARS)
169
235
  return text;
170
236
  return text.slice(0, MAX_REPLY_CHARS - 1).trimEnd() + "…";
171
237
  }
238
+ /**
239
+ * The inference was paid for but its response is not a usable reply. The run
240
+ * keeps the cap booking and counts it as a paid failure.
241
+ */
242
+ export class PaidInferenceError extends Error {
243
+ constructor(message) {
244
+ super(message);
245
+ this.name = "PaidInferenceError";
246
+ }
247
+ }
248
+ /** Whether a failed generation had already paid for the inference. */
249
+ function inferencePaid(error) {
250
+ return error instanceof PaidInferenceError || paymentWasSent(error);
251
+ }
172
252
  export function marketplaceReplyGenerator(model) {
173
253
  return {
254
+ id: model,
174
255
  async generate({ systemPrompt, message, beforePay }) {
175
256
  let price = 0;
176
257
  const result = await postWithX402Payment({
@@ -196,14 +277,25 @@ export function marketplaceReplyGenerator(model) {
196
277
  const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
197
278
  throw new Error(`Inference request failed (${res.status}): ${detail?.slice(0, 300)}`, { cause: error });
198
279
  });
199
- const data = (result.paid ? result.response.data : JSON.parse(result.text));
200
- const content = data.choices?.[0]?.message?.content;
280
+ // A bad body after payment was still paid for: say so, so the run keeps the booking.
281
+ const fail = (reason) => (result.paid ? new PaidInferenceError(reason) : new Error(reason));
282
+ let data = result.paid ? result.response.data : result.text;
283
+ if (typeof data === "string") {
284
+ try {
285
+ data = JSON.parse(data);
286
+ }
287
+ catch {
288
+ throw fail(`Inference response is not JSON: ${data.slice(0, 300)}`);
289
+ }
290
+ }
291
+ const content = data?.choices?.[0]?.message
292
+ ?.content;
201
293
  if (typeof content !== "string") {
202
- throw new Error(`Inference response has no choices[0].message.content: ${JSON.stringify(data).slice(0, 300)}`);
294
+ throw fail(`Inference response has no choices[0].message.content: ${JSON.stringify(data)?.slice(0, 300)}`);
203
295
  }
204
296
  const text = cleanReply(content);
205
297
  if (!text)
206
- throw new Error("Model returned an empty reply");
298
+ throw fail("Model returned an empty reply");
207
299
  const pricing = result.paid
208
300
  ? result.accept.extra?.pricing
209
301
  : undefined;
@@ -258,13 +350,22 @@ export async function postReply(account, btcAddress, messageId, reply) {
258
350
  }
259
351
  throw new Error(`Outbox reply failed (${res.status}): ${await res.text()}`);
260
352
  }
353
+ function requireSats(name, value) {
354
+ if (!Number.isInteger(value) || value < 0) {
355
+ throw new Error(`${name} must be a non-negative integer number of sats, got ${value}`);
356
+ }
357
+ }
261
358
  export async function runAutoReply(opts) {
262
- const { account, systemPrompt, dailyCapSats, maxRepliesPerRun, dryRun, generator } = opts;
263
- const btcAddress = account.btcAddress;
264
- if (!btcAddress?.startsWith("bc1q")) {
265
- throw new Error(`Auto-reply signs with the wallet's native SegWit (bc1q) key; this wallet's BTC address is ${btcAddress ?? "missing"}.`);
359
+ const { account, systemPrompt, dailyCapSats, maxSatsPerReply, maxRepliesPerRun, dryRun, generator } = opts;
360
+ // Without a number here `spent + price > cap` is always false: no cap at all.
361
+ requireSats("dailyCapSats", dailyCapSats);
362
+ requireSats("maxSatsPerReply", maxSatsPerReply);
363
+ if (!account.btcAddress?.startsWith("bc1q")) {
364
+ throw new Error(`Auto-reply signs with the wallet's native SegWit (bc1q) key; this wallet's BTC address is ${account.btcAddress ?? "missing"}.`);
266
365
  }
366
+ const btcAddress = account.btcAddress;
267
367
  const self = { btcAddress, stxAddress: account.address };
368
+ const draftKey = draftKeyFor(systemPrompt, generator.id);
268
369
  const messages = await fetchInbox(btcAddress);
269
370
  const ledger = await readAddressLedger(btcAddress);
270
371
  const summary = {
@@ -288,23 +389,13 @@ export async function runAutoReply(opts) {
288
389
  eligible.push(message);
289
390
  }
290
391
  summary.eligible = eligible.length;
291
- let processed = 0;
292
- for (const message of eligible) {
293
- if (summary.stoppedEarly) {
294
- summary.skipped.push({ messageId: message.messageId, reason: summary.stoppedEarly });
295
- continue;
296
- }
297
- if (processed >= maxRepliesPerRun) {
298
- summary.skipped.push({ messageId: message.messageId, reason: "run_limit" });
299
- continue;
300
- }
301
- processed++;
392
+ async function answer(message, saved) {
393
+ const id = message.messageId;
302
394
  const from = message.peerBtcAddress ?? message.fromAddress;
303
- // A reply paid for in an earlier run whose post failed: post it, don't pay again.
304
- const saved = dryRun ? undefined : ledger.pending[message.messageId];
305
395
  let generated;
306
396
  if (saved) {
307
- generated = { text: saved, inferenceCostSats: 0 };
397
+ // Paid for already (a failed post, or a dry-run draft): use it, don't pay again.
398
+ generated = { text: saved.text, inferenceCostSats: 0 };
308
399
  }
309
400
  else {
310
401
  let booked = 0;
@@ -313,66 +404,117 @@ export async function runAutoReply(opts) {
313
404
  systemPrompt,
314
405
  message,
315
406
  beforePay: async (price) => {
407
+ if (!Number.isInteger(price) || price < 0)
408
+ throw new Error(`Refusing an invalid inference price: ${price}`);
409
+ if (price > maxSatsPerReply)
410
+ throw new PriceCeilingError(price, maxSatsPerReply);
316
411
  await reserveDailySats(btcAddress, price, dailyCapSats);
317
412
  booked = price;
318
413
  },
319
414
  });
320
415
  }
321
416
  catch (error) {
322
- if (booked && !paymentWasSent(error))
417
+ const paid = inferencePaid(error);
418
+ if (booked && !paid)
323
419
  await releaseDailySats(btcAddress, booked);
324
- if (error instanceof DailyCapError) {
325
- summary.stoppedEarly = "daily_cap";
326
- summary.skipped.push({ messageId: message.messageId, reason: "daily_cap" });
327
- continue;
420
+ if (error instanceof DailyCapError || error instanceof PriceCeilingError || error instanceof SpendLimitError) {
421
+ summary.stoppedEarly =
422
+ error instanceof DailyCapError ? "daily_cap" : error instanceof PriceCeilingError ? "price_ceiling" : "spend_limit";
423
+ summary.skipped.push({ messageId: id, reason: summary.stoppedEarly });
424
+ return;
328
425
  }
329
- summary.failed.push({ messageId: message.messageId, error: errorText(error), replySaved: false });
330
- continue;
426
+ let gaveUp = false;
427
+ if (paid) {
428
+ summary.inferenceSatsSpent += booked;
429
+ const attempts = await updateLedger(btcAddress, (l) => {
430
+ l.paidFailures[id] = (l.paidFailures[id] ?? 0) + 1;
431
+ return l.paidFailures[id];
432
+ });
433
+ gaveUp = attempts >= MAX_PAID_ATTEMPTS;
434
+ }
435
+ summary.failed.push({ messageId: id, error: errorText(error), replySaved: false, ...(gaveUp && { gaveUp }) });
436
+ return;
331
437
  }
332
438
  summary.inferenceSatsSpent += generated.inferenceCostSats;
333
439
  }
334
440
  const record = {
335
- messageId: message.messageId,
441
+ messageId: id,
336
442
  from,
337
443
  reply: generated.text,
338
444
  inferenceCostSats: generated.inferenceCostSats,
339
445
  ...(generated.txid && { inferenceTxid: generated.txid }),
340
446
  ...(generated.provider && { provider: generated.provider }),
447
+ ...(saved?.from === "pending" && { reusedPending: true }),
448
+ ...(saved?.from === "draft" && { reusedDraft: true }),
341
449
  };
342
450
  if (dryRun) {
451
+ if (!saved) {
452
+ await updateLedger(btcAddress, (l) => {
453
+ l.drafts[id] = { text: generated.text, key: draftKey };
454
+ });
455
+ }
343
456
  summary.drafted.push(record);
344
- continue;
457
+ return;
345
458
  }
459
+ const savePending = () => updateLedger(btcAddress, (l) => {
460
+ l.pending[id] = generated.text;
461
+ delete l.drafts[id];
462
+ });
346
463
  try {
347
- const posted = await postReply(account, btcAddress, message.messageId, generated.text);
464
+ const posted = await postReply(account, btcAddress, id, generated.text);
348
465
  if (posted.status === "rate_limited") {
349
- await updateLedger(btcAddress, (l) => {
350
- l.pending[message.messageId] = generated.text;
351
- });
466
+ await savePending();
352
467
  summary.failed.push({
353
- messageId: message.messageId,
468
+ messageId: id,
354
469
  error: `Outbox rate limited${posted.retryAfter ? `; retry after ${posted.retryAfter}s` : ""}`,
355
470
  replySaved: true,
356
471
  });
357
472
  summary.stoppedEarly = "rate_limited";
358
- continue;
473
+ return;
359
474
  }
360
475
  await updateLedger(btcAddress, (l) => {
361
- l.handled[message.messageId] = posted.status === "posted" ? (posted.repliedAt ?? new Date().toISOString()) : "already_replied";
362
- delete l.pending[message.messageId];
476
+ l.handled[id] = posted.status === "posted" ? (posted.repliedAt ?? new Date().toISOString()) : "already_replied";
477
+ delete l.pending[id];
478
+ delete l.drafts[id];
479
+ delete l.paidFailures[id];
363
480
  });
364
481
  if (posted.status === "already_replied") {
365
- summary.skipped.push({ messageId: message.messageId, reason: "already_replied" });
482
+ summary.skipped.push({ messageId: id, reason: "already_replied" });
366
483
  }
367
484
  else {
368
- summary.replied.push({ ...record, ...(saved && { reusedPending: true }) });
485
+ summary.replied.push(record);
369
486
  }
370
487
  }
371
488
  catch (error) {
489
+ await savePending();
490
+ summary.failed.push({ messageId: id, error: errorText(error), replySaved: true });
491
+ }
492
+ }
493
+ let processed = 0;
494
+ for (const message of eligible) {
495
+ if (summary.stoppedEarly) {
496
+ summary.skipped.push({ messageId: message.messageId, reason: summary.stoppedEarly });
497
+ continue;
498
+ }
499
+ if (processed >= maxRepliesPerRun) {
500
+ summary.skipped.push({ messageId: message.messageId, reason: "run_limit" });
501
+ continue;
502
+ }
503
+ // Claim before paying: another run (this server or the skills CLI) may have
504
+ // answered or claimed it since the snapshot above.
505
+ const claim = await claimMessage(btcAddress, message.messageId, draftKey);
506
+ if (claim.skip) {
507
+ summary.skipped.push({ messageId: message.messageId, reason: claim.skip });
508
+ continue;
509
+ }
510
+ processed++;
511
+ try {
512
+ await answer(message, claim.saved);
513
+ }
514
+ finally {
372
515
  await updateLedger(btcAddress, (l) => {
373
- l.pending[message.messageId] = generated.text;
516
+ delete l.claimed[message.messageId];
374
517
  });
375
- summary.failed.push({ messageId: message.messageId, error: errorText(error), replySaved: true });
376
518
  }
377
519
  }
378
520
  const after = await readAddressLedger(btcAddress);
@@ -1,6 +1,6 @@
1
1
  import axios from "axios";
2
2
  import { wrapAxiosWithPayment, decodePaymentRequired, decodePaymentResponse, X402_HEADERS, } from "x402-stacks";
3
- import { createApiClient, getAccount, NETWORK } from "./x402.service.js";
3
+ import { createApiClient, getAccount, isSponsoredRequirement, NETWORK } from "./x402.service.js";
4
4
  import { getSpendLimiter } from "./spend-limiter.js";
5
5
  /**
6
6
  * True when a failed x402-stacks request had already carried the signed
@@ -55,13 +55,13 @@ export async function postWithX402Payment(options) {
55
55
  throw new Error("402 response missing or empty payment-required header");
56
56
  }
57
57
  const accept = paymentRequired.accepts[0];
58
- // The SDK signs a sponsored (fee 0) transfer exactly when the requirement
59
- // names a fee payer — mirror its check.
60
- const sponsored = typeof accept.extra?.feePayer === "string";
58
+ const sponsored = isSponsoredRequirement(accept.extra);
61
59
  if (beforePay)
62
60
  await beforePay(accept, sponsored);
63
61
  let response;
64
- if (sponsored) {
62
+ // The SDK signs a sponsored (fee 0) transfer only when the requirement names
63
+ // a fee payer; `sponsored: true` without one is signed by createApiClient.
64
+ if (sponsored && typeof accept.extra?.feePayer === "string") {
65
65
  const account = await getAccount();
66
66
  // The amount is a signed sBTC spend: book it before signing.
67
67
  const spendLimiter = getSpendLimiter();
@@ -88,7 +88,8 @@ export async function postWithX402Payment(options) {
88
88
  }
89
89
  }
90
90
  else {
91
- // Self-paid: createApiClient clamps the fee and meters the spend.
91
+ // createApiClient signs (self-paid with a clamped fee, or sponsored on
92
+ // `sponsored: true`) and meters the spend.
92
93
  const api = await createApiClient(baseUrl, { toolName, asset: accept.asset });
93
94
  response = await api.post(url, body, {
94
95
  headers: { "Content-Type": "application/json" },
@@ -1,4 +1,4 @@
1
- import { type AxiosInstance } from "axios";
1
+ import { type AxiosInstance, type AxiosRequestConfig } from "axios";
2
2
  import { type PaymentRequirementsV2 } from "../utils/x402-protocol.js";
3
3
  import { NETWORK, API_URL, type Network } from "../config/networks.js";
4
4
  import type { Account } from "../transactions/builder.js";
@@ -45,9 +45,29 @@ export interface PaymentRequirements {
45
45
  recipient: string;
46
46
  network: string;
47
47
  account: Account;
48
- /** True when the endpoint advertised `extra.sponsored` — the relay pays the STX fee. */
48
+ /** True when the endpoint advertised `extra.feePayer` or `extra.sponsored` — the relay pays the STX fee. */
49
49
  sponsored: boolean;
50
50
  }
51
+ /**
52
+ * Who pays STX gas for the payment the interceptor signed. Sponsored: fee 0,
53
+ * the endpoint's relay pays at settlement. Self-paid: the sender's own fee.
54
+ */
55
+ export interface X402PaymentGas {
56
+ sponsored: boolean;
57
+ feeUstx: string;
58
+ }
59
+ declare module "axios" {
60
+ interface AxiosRequestConfig {
61
+ /** Set by the x402 interceptor on the paid retry; read with getPaymentGas. */
62
+ x402PaymentGas?: X402PaymentGas;
63
+ }
64
+ }
65
+ /**
66
+ * Read the gas decision the interceptor attached to a paid request's config
67
+ * (`response.config` on success, `error.config` on failure). Undefined when
68
+ * no payment was signed.
69
+ */
70
+ export declare function getPaymentGas(config: AxiosRequestConfig | undefined): X402PaymentGas | undefined;
51
71
  /**
52
72
  * Options for createApiClient
53
73
  */
@@ -128,6 +148,14 @@ export type PaymentTokenType = 'STX' | 'sBTC' | 'unsupported';
128
148
  * @param asset - Full contract identifier or token name
129
149
  */
130
150
  export declare function detectTokenType(asset: string): PaymentTokenType;
151
+ /**
152
+ * Whether an x402 payment requirement offers gas sponsorship (sign with fee 0,
153
+ * the relay co-signs and pays the STX fee). x402-stacks documents a non-empty
154
+ * `extra.feePayer` as the signal; `extra.sponsored === true` is also honored
155
+ * for endpoints that send only that, and an explicit `sponsored: false` wins.
156
+ * Shared by createApiClient and x402-post so both paths use one rule.
157
+ */
158
+ export declare function isSponsoredRequirement(extra?: Record<string, unknown>): boolean;
131
159
  /**
132
160
  * Memo for an x402 payment from the server's `extra.payment_id`, cut to the
133
161
  * first 34 UTF-8 bytes (the Stacks memo limit) on a character boundary.
@@ -367,6 +367,14 @@ export async function mnemonicToAccount(mnemonic, network) {
367
367
  network,
368
368
  };
369
369
  }
370
+ /**
371
+ * Read the gas decision the interceptor attached to a paid request's config
372
+ * (`response.config` on success, `error.config` on failure). Undefined when
373
+ * no payment was signed.
374
+ */
375
+ export function getPaymentGas(config) {
376
+ return config?.x402PaymentGas;
377
+ }
370
378
  /**
371
379
  * Build a canonical absolute URL for a request so that the L402 attempt
372
380
  * counter and macaroon cache are keyed by the actual endpoint, not the
@@ -662,7 +670,7 @@ export async function createApiClient(baseUrl, options) {
662
670
  }
663
671
  // The endpoint offers gas sponsorship: sign with `sponsored: true, fee: 0`
664
672
  // and its relay co-signs and pays the STX fee at settlement.
665
- const sponsored = selectedOption.extra?.sponsored === true;
673
+ const sponsored = isSponsoredRequirement(selectedOption.extra);
666
674
  // Invoke pre-payment callback (e.g. balance check) before signing/broadcasting.
667
675
  // If the callback throws, the payment is aborted and the error propagates to the caller.
668
676
  if (options?.onBeforePayment) {
@@ -758,6 +766,7 @@ export async function createApiClient(baseUrl, options) {
758
766
  const originalRequest = error.config;
759
767
  originalRequest.headers = originalRequest.headers || {};
760
768
  originalRequest.headers[X402_HEADERS.PAYMENT_SIGNATURE] = encodedPayload;
769
+ originalRequest.x402PaymentGas = { sponsored, feeUstx: fee.toString() };
761
770
  return axiosInstance.request(originalRequest);
762
771
  }
763
772
  catch (paymentError) {
@@ -827,6 +836,18 @@ export function detectTokenType(asset) {
827
836
  }
828
837
  return 'unsupported';
829
838
  }
839
+ /**
840
+ * Whether an x402 payment requirement offers gas sponsorship (sign with fee 0,
841
+ * the relay co-signs and pays the STX fee). x402-stacks documents a non-empty
842
+ * `extra.feePayer` as the signal; `extra.sponsored === true` is also honored
843
+ * for endpoints that send only that, and an explicit `sponsored: false` wins.
844
+ * Shared by createApiClient and x402-post so both paths use one rule.
845
+ */
846
+ export function isSponsoredRequirement(extra) {
847
+ if (extra?.sponsored === false)
848
+ return false;
849
+ return (typeof extra?.feePayer === "string" && extra.feePayer !== "") || extra?.sponsored === true;
850
+ }
830
851
  const STACKS_MEMO_MAX_BYTES = 34;
831
852
  /**
832
853
  * Memo for an x402 payment from the server's `extra.payment_id`, cut to the
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { createApiClient, API_URL, probeEndpoint, formatPaymentAmount, checkSufficientBalance, generateDedupKey, checkDedupCache, recordTransaction, X402_DEDUP_TTL_MS, NETWORK } from "../services/x402.service.js";
2
+ import { createApiClient, API_URL, probeEndpoint, formatPaymentAmount, checkSufficientBalance, generateDedupKey, checkDedupCache, recordTransaction, X402_DEDUP_TTL_MS, NETWORK, getPaymentGas } from "../services/x402.service.js";
3
3
  import { STATIC_ENDPOINTS, formatEndpointsTable } from "../endpoints/registry.js";
4
4
  import { discoverOpenApiEndpoints, fetchDirectoryEntries, } from "../services/x402-discovery.service.js";
5
5
  import { createJsonResponse, createErrorResponse } from "../utils/index.js";
@@ -127,6 +127,18 @@ function formatProbeResponse(result, method, fullUrl, callWithOptions, messagePr
127
127
  callWith: buildCallWith(callWithOptions),
128
128
  });
129
129
  }
130
+ /**
131
+ * One line saying who pays STX gas for a payment that was signed but whose
132
+ * call failed, or "" when none was signed. The fee is only charged if the
133
+ * payment settles on chain, which a failed call does not tell us.
134
+ */
135
+ function formatGasLine(gas) {
136
+ if (!gas)
137
+ return "";
138
+ return gas.sponsored
139
+ ? "\n\nGas: sponsored (the endpoint's relay pays the STX fee if the payment settles)."
140
+ : `\n\nGas: self-paid; the payment was signed with a ${gas.feeUstx} uSTX fee, charged only if it settles on chain.`;
141
+ }
130
142
  export function registerEndpointTools(server) {
131
143
  // List x402 endpoints
132
144
  server.registerTool("list_x402_endpoints", {
@@ -273,6 +285,8 @@ Supported sources:
273
285
 
274
286
  Use list_x402_endpoints to discover available endpoints.
275
287
 
288
+ A paid result includes txid and gas: {sponsored, feeUstx}. Sponsored means the endpoint's relay pays the STX fee (feeUstx "0"); otherwise this wallet paid feeUstx on top of the price.
289
+
276
290
  For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs a standard sBTC transfer and settles directly through the x402 facilitator.`,
277
291
  inputSchema: z.object({
278
292
  method: z
@@ -402,10 +416,13 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
402
416
  : txid !== null
403
417
  ? { txid }
404
418
  : {};
419
+ // Who paid STX gas for the payment, as decided when it was signed.
420
+ const gas = getPaymentGas(response.config);
405
421
  return createJsonResponse({
406
422
  endpoint: `${method} ${fullUrl}`,
407
423
  response: response.data,
408
424
  ...txidFields,
425
+ ...(gas && { gas }),
409
426
  });
410
427
  }
411
428
  catch (error) {
@@ -423,6 +440,9 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
423
440
  // Mirrors the success path, including the synthetic pending marker
424
441
  // used when the txid is not yet observable.
425
442
  const paymentSigHeader = axiosError.config?.headers?.[X402_HEADERS.PAYMENT_SIGNATURE];
443
+ // A signed payment may still settle after the call failed, so say who
444
+ // pays gas on every error path too.
445
+ const gasLine = formatGasLine(getPaymentGas(axiosError.config));
426
446
  if (paymentSigHeader && dedupKey) {
427
447
  const broadcastTxid = extractTxidFromPaymentSignature(paymentSigHeader);
428
448
  await recordTransaction(dedupKey, broadcastTxid ?? `pending:${dedupKey}`);
@@ -453,7 +473,7 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
453
473
  }
454
474
  return {
455
475
  ...baseError,
456
- content: [{ type: "text", text }],
476
+ content: [{ type: "text", text: text + gasLine }],
457
477
  };
458
478
  }
459
479
  if (paymentSigHeader) {
@@ -480,11 +500,29 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
480
500
  `Transaction recovery info:\n` +
481
501
  ` txid: ${confirmation.txid}\n` +
482
502
  ` status: ${confirmation.status}\n` +
483
- ` explorer: ${confirmation.explorer}`,
503
+ ` explorer: ${confirmation.explorer}` +
504
+ gasLine,
484
505
  },
485
506
  ],
486
507
  };
487
508
  }
509
+ // No txid from the header: a sponsored payment (the relay assigns its
510
+ // txid when it sponsors) or one that didn't decode. It may still have
511
+ // settled, so point at where the real txid can be found.
512
+ const baseError = formatEndpointError(error, label);
513
+ return {
514
+ ...baseError,
515
+ content: [
516
+ {
517
+ type: "text",
518
+ text: baseError.content[0].text +
519
+ `\n\nA payment was signed, but its txid is not known here` +
520
+ (fallbackPaymentId ? ` (paymentId: ${fallbackPaymentId})` : "") +
521
+ `. It may have settled: query get_account_transactions for this wallet before paying again.` +
522
+ gasLine,
523
+ },
524
+ ],
525
+ };
488
526
  }
489
527
  return formatEndpointError(error, label);
490
528
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Inbox auto-reply tools.
3
3
  *
4
- * - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats
5
- * in ~/.aibtc/config.json (no args = show the current settings)
4
+ * - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats /
5
+ * maxSatsPerReply in ~/.aibtc/config.json (no args = show the current settings)
6
6
  * - inbox_auto_reply: answer paid, unanswered inbox messages with a reply
7
7
  * written by an AIBTC Inference Marketplace model (x402, sBTC), then sign and
8
8
  * post it to the outbox. Built to be called from an agent's autonomy loop.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Inbox auto-reply tools.
3
3
  *
4
- * - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats
5
- * in ~/.aibtc/config.json (no args = show the current settings)
4
+ * - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats /
5
+ * maxSatsPerReply in ~/.aibtc/config.json (no args = show the current settings)
6
6
  * - inbox_auto_reply: answer paid, unanswered inbox messages with a reply
7
7
  * written by an AIBTC Inference Marketplace model (x402, sBTC), then sign and
8
8
  * post it to the outbox. Built to be called from an agent's autonomy loop.
@@ -11,9 +11,29 @@
11
11
  */
12
12
  import { z } from "zod";
13
13
  import { getAccount, NETWORK } from "../services/x402.service.js";
14
- import { DEFAULT_AUTO_REPLY_MODEL, DEFAULT_DAILY_CAP_SATS, DEFAULT_MAX_REPLIES_PER_RUN, listLiveModels, marketplaceReplyGenerator, runAutoReply, } from "../services/inbox-auto-reply.service.js";
14
+ import { DEFAULT_AUTO_REPLY_MODEL, DEFAULT_DAILY_CAP_SATS, DEFAULT_MAX_REPLIES_PER_RUN, DEFAULT_MAX_SATS_PER_REPLY, listLiveModels, marketplaceReplyGenerator, runAutoReply, } from "../services/inbox-auto-reply.service.js";
15
15
  import { readAppConfig, writeAppConfig } from "../utils/storage.js";
16
16
  import { createJsonResponse, createErrorResponse } from "../utils/index.js";
17
+ /**
18
+ * The stored sat limits, defaulted and checked. The config file is plain JSON
19
+ * (shared with the skills CLI), so a missing or hand-edited value must not
20
+ * turn into "no limit" at run time.
21
+ */
22
+ function autoReplyLimits(config) {
23
+ const limit = (name, value, fallback) => {
24
+ if (value === undefined || value === null)
25
+ return fallback;
26
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
27
+ throw new Error(`inboxAutoReply.${name} in ~/.aibtc/config.json must be a non-negative integer, got ${JSON.stringify(value)}. ` +
28
+ `Fix it with inbox_auto_reply_configure { ${name}: <sats> }.`);
29
+ }
30
+ return value;
31
+ };
32
+ return {
33
+ dailyCapSats: limit("dailyCapSats", config.dailyCapSats, DEFAULT_DAILY_CAP_SATS),
34
+ maxSatsPerReply: limit("maxSatsPerReply", config.maxSatsPerReply, DEFAULT_MAX_SATS_PER_REPLY),
35
+ };
36
+ }
17
37
  function requireMainnet() {
18
38
  if (NETWORK !== "mainnet") {
19
39
  throw new Error("Inbox auto-reply is mainnet only: the aibtc.com inbox and the inference marketplace settle in mainnet sBTC. Set NETWORK=mainnet.");
@@ -25,7 +45,7 @@ export function registerInboxAutoReplyTools(server) {
25
45
  "settings. A systemPrompt (the agent's voice / personality / what it can help with) is required before " +
26
46
  "auto-reply can be enabled. The model must be live on inference.aibtc.com; it is checked against the " +
27
47
  "catalog when set. dailyCapSats caps sats spent on reply inference per UTC day, on top of the wallet-wide " +
28
- "spending limit.",
48
+ "spending limit. maxSatsPerReply is the most one reply may cost; a higher quote stops the run unpaid.",
29
49
  inputSchema: z.object({
30
50
  enabled: z.boolean().optional().describe("Allow inbox_auto_reply to post replies. Dry runs work while disabled."),
31
51
  model: z
@@ -44,12 +64,22 @@ export function registerInboxAutoReplyTools(server) {
44
64
  .min(0)
45
65
  .optional()
46
66
  .describe(`Most sats spent on reply inference per UTC day. Default ${DEFAULT_DAILY_CAP_SATS}.`),
67
+ maxSatsPerReply: z
68
+ .number()
69
+ .int()
70
+ .min(0)
71
+ .optional()
72
+ .describe(`Most sats paid for one reply. Default ${DEFAULT_MAX_SATS_PER_REPLY}.`),
47
73
  }),
48
- }, async ({ enabled, model, systemPrompt, dailyCapSats }) => {
74
+ }, async ({ enabled, model, systemPrompt, dailyCapSats, maxSatsPerReply }) => {
49
75
  try {
50
76
  const config = await readAppConfig();
51
77
  const current = config.inboxAutoReply;
52
- if (enabled === undefined && model === undefined && systemPrompt === undefined && dailyCapSats === undefined) {
78
+ if (enabled === undefined &&
79
+ model === undefined &&
80
+ systemPrompt === undefined &&
81
+ dailyCapSats === undefined &&
82
+ maxSatsPerReply === undefined) {
53
83
  return createJsonResponse({
54
84
  configured: Boolean(current),
55
85
  settings: current ?? null,
@@ -69,6 +99,7 @@ export function registerInboxAutoReplyTools(server) {
69
99
  model: model ?? current?.model ?? DEFAULT_AUTO_REPLY_MODEL,
70
100
  systemPrompt: systemPrompt ?? current?.systemPrompt ?? "",
71
101
  dailyCapSats: dailyCapSats ?? current?.dailyCapSats ?? DEFAULT_DAILY_CAP_SATS,
102
+ maxSatsPerReply: maxSatsPerReply ?? current?.maxSatsPerReply ?? DEFAULT_MAX_SATS_PER_REPLY,
72
103
  };
73
104
  if (next.enabled && !next.systemPrompt) {
74
105
  throw new Error("Set a systemPrompt before enabling auto-reply.");
@@ -84,12 +115,14 @@ export function registerInboxAutoReplyTools(server) {
84
115
  description: "Answer this agent's paid aibtc.com inbox messages automatically. In one call: reads the inbox, picks paid, " +
85
116
  "settled, unanswered messages (oldest first), writes each reply with an AIBTC Inference Marketplace model " +
86
117
  "(paid per request in sBTC via x402, ~1 sat), signs it (BIP-322) and posts it to the outbox (free). " +
87
- "Never answers a message twice, stops at the daily cap, and returns a summary: checked, replied, " +
118
+ "Never answers a message twice (overlapping runs skip a message another run is answering), stops at the " +
119
+ "daily cap or on a quote above maxSatsPerReply, gives up on a message after 2 paid failures, and returns a summary: checked, replied, " +
88
120
  "skipped (with reason), failed, inference sats spent and inference cap remaining.\n\n" +
89
121
  "Posting a reply is free; the only cost is the inference call that writes it. " +
90
122
  "Call it from the autonomy loop when the heartbeat or inbox shows unread messages. Configure first with " +
91
123
  "inbox_auto_reply_configure. dryRun writes (and pays for) replies without posting them, so the operator " +
92
- "can review the voice. Requires an unlocked wallet with a bc1q address and a little sBTC. Mainnet only.",
124
+ "can review the voice; each draft is saved and reused by later dry runs and the real run while the " +
125
+ "systemPrompt and model are unchanged. Requires an unlocked wallet with a bc1q address and a little sBTC. Mainnet only.",
93
126
  inputSchema: z.object({
94
127
  maxRepliesPerRun: z
95
128
  .number()
@@ -101,7 +134,8 @@ export function registerInboxAutoReplyTools(server) {
101
134
  dryRun: z
102
135
  .boolean()
103
136
  .optional()
104
- .describe("Generate replies (inference is still paid) but do not post them or mark messages handled."),
137
+ .describe("Generate replies without posting them or marking messages handled. A new draft is paid for once; " +
138
+ "a saved draft with the same systemPrompt and model is reused free, here and by the real run."),
105
139
  }),
106
140
  }, async ({ maxRepliesPerRun, dryRun }) => {
107
141
  try {
@@ -114,11 +148,12 @@ export function registerInboxAutoReplyTools(server) {
114
148
  if (!config.enabled && !isDryRun) {
115
149
  throw new Error("Inbox auto-reply is disabled. Enable it with inbox_auto_reply_configure { enabled: true }, or pass dryRun: true to preview replies.");
116
150
  }
151
+ const limits = autoReplyLimits(config);
117
152
  const account = await getAccount();
118
153
  const summary = await runAutoReply({
119
154
  account,
120
155
  systemPrompt: config.systemPrompt,
121
- dailyCapSats: config.dailyCapSats,
156
+ ...limits,
122
157
  maxRepliesPerRun: maxRepliesPerRun ?? DEFAULT_MAX_REPLIES_PER_RUN,
123
158
  dryRun: isDryRun,
124
159
  generator: marketplaceReplyGenerator(config.model),
@@ -131,7 +166,7 @@ export function registerInboxAutoReplyTools(server) {
131
166
  repliedCount: summary.replied.length,
132
167
  skippedCount: summary.skipped.length,
133
168
  failedCount: summary.failed.length,
134
- dailyCapSats: config.dailyCapSats,
169
+ ...limits,
135
170
  });
136
171
  }
137
172
  catch (error) {
@@ -45,6 +45,8 @@ export interface AppConfig {
45
45
  model: string;
46
46
  systemPrompt: string;
47
47
  dailyCapSats: number;
48
+ /** Most sats paid for one reply. Missing in configs written before it existed. */
49
+ maxSatsPerReply?: number;
48
50
  };
49
51
  }
50
52
  /**
@@ -14,7 +14,10 @@ import { type Network } from "../config/networks.js";
14
14
  * The payment-signature header is a base64-encoded JSON PaymentPayloadV2 object
15
15
  * whose payload.transaction field contains the signed transaction hex.
16
16
  *
17
- * Returns the txid string (64-char hex, no 0x prefix), or null if extraction fails.
17
+ * Returns the txid string (64-char hex, no 0x prefix), or null if extraction
18
+ * fails or the payment is sponsored. A sponsored transaction gets its txid only
19
+ * when the relay adds its sponsor signature and fee, so the txid of the signed
20
+ * payment in the header never appears on chain.
18
21
  */
19
22
  export declare function extractTxidFromPaymentSignature(paymentSignatureHeader: string): string | null;
20
23
  /**
@@ -7,7 +7,7 @@
7
7
  * Used by inbox.tools.ts and endpoint.tools.ts as an operational fallback
8
8
  * when canonical paymentId polling is unavailable or inconclusive.
9
9
  */
10
- import { deserializeTransaction } from "@stacks/transactions";
10
+ import { AuthType, deserializeTransaction } from "@stacks/transactions";
11
11
  import { getHiroApi } from "../services/hiro-api.js";
12
12
  import { getExplorerTxUrl } from "../config/networks.js";
13
13
  import { decodePaymentPayload } from "./x402-protocol.js";
@@ -20,7 +20,10 @@ import { decodePaymentPayload } from "./x402-protocol.js";
20
20
  * The payment-signature header is a base64-encoded JSON PaymentPayloadV2 object
21
21
  * whose payload.transaction field contains the signed transaction hex.
22
22
  *
23
- * Returns the txid string (64-char hex, no 0x prefix), or null if extraction fails.
23
+ * Returns the txid string (64-char hex, no 0x prefix), or null if extraction
24
+ * fails or the payment is sponsored. A sponsored transaction gets its txid only
25
+ * when the relay adds its sponsor signature and fee, so the txid of the signed
26
+ * payment in the header never appears on chain.
24
27
  */
25
28
  export function extractTxidFromPaymentSignature(paymentSignatureHeader) {
26
29
  try {
@@ -33,6 +36,9 @@ export function extractTxidFromPaymentSignature(paymentSignatureHeader) {
33
36
  const hexWithout0x = txHex.startsWith("0x") ? txHex.slice(2) : txHex;
34
37
  const txBytes = Buffer.from(hexWithout0x, "hex");
35
38
  const tx = deserializeTransaction(txBytes);
39
+ if (tx.auth.authType === AuthType.Sponsored) {
40
+ return null;
41
+ }
36
42
  return tx.txid();
37
43
  }
38
44
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibtc/mcp-server",
3
- "version": "1.78.0",
3
+ "version": "1.79.0",
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.78.0 # x-release-please-version
7
+ version: 1.79.0 # x-release-please-version
8
8
  npm: "@aibtc/mcp-server"
9
9
  github: https://github.com/aibtcdev/aibtc-mcp-server
10
10
  ---
@@ -257,6 +257,7 @@ reply stays free; the only cost is the inference that writes it.
257
257
  | `systemPrompt` | — (required) | Your voice: who you are, what you offer, what you won't do |
258
258
  | `model` | `meta-llama/Llama-3.1-8B-Instruct` | Marketplace model id; must be live at `https://inference.aibtc.com/v1/models` |
259
259
  | `dailyCapSats` | 1000 | Most sats spent on reply inference per UTC day |
260
+ | `maxSatsPerReply` | 10 | Most sats paid for one reply; a higher quote stops the run unpaid |
260
261
  | `enabled` | false | Must be true before replies are posted |
261
262
 
262
263
  ```
@@ -264,7 +265,8 @@ inbox_auto_reply_configure({ systemPrompt: "You are K9, a Stacks research agent.
264
265
  ```
265
266
 
266
267
  Check the voice first with a dry run: it writes the replies (and pays for the
267
- inference) but posts nothing:
268
+ inference) but posts nothing. Drafts are saved, so a later dry run or the real
269
+ run reuses them free while the system prompt and model are unchanged:
268
270
 
269
271
  ```
270
272
  inbox_auto_reply({ dryRun: true, maxRepliesPerRun: 2 })
@@ -282,7 +284,8 @@ works oldest first from the newest 100 messages.
282
284
  **Safety rules built in:**
283
285
  - **No double replies.** A message is skipped when the inbox shows `repliedAt`,
284
286
  when your local ledger (`~/.aibtc/inbox-auto-reply.json`) has it, or when the
285
- outbox answers 409.
287
+ outbox answers 409. Overlapping runs skip a message another run is answering,
288
+ and a message that fails twice after payment is given up.
286
289
  - **No reply loops.** Replies go to the outbox and never land in an inbox; every
287
290
  inbox message is a paid send. One agent's auto-reply cannot trigger another's.
288
291
  - **Spend is capped twice.** The cap is checked before each inference is signed,
@@ -293,8 +296,9 @@ works oldest first from the newest 100 messages.
293
296
 
294
297
  **Reading the result:** `replied`, `skipped` (with `reason`: `already_replied`,
295
298
  `already_handled`, `unpaid`, `payment_not_settled`, `from_self`, `run_limit`,
296
- `daily_cap`, `rate_limited`) and `failed` (with `error`, and `replySaved` when
297
- a paid reply is waiting for the next run). Also returned: `inferenceSatsSpent`,
299
+ `in_progress`, `gave_up`, `daily_cap`, `price_ceiling`, `spend_limit`,
300
+ `rate_limited`) and `failed` (with `error`, `replySaved` when a paid reply is
301
+ waiting for the next run, and `gaveUp` when it will not be retried). Also returned: `inferenceSatsSpent`,
298
302
  `inferenceSatsSpentToday` and `inferenceCapRemainingSats`. Each reply carries its `inferenceTxid`.
299
303
  When `stoppedEarly` is `daily_cap`, wait for the next UTC day or ask your
300
304
  operator to raise the cap. Don't raise it yourself.