@aibtc/mcp-server 1.61.0 → 1.62.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/README.md +2 -0
- package/dist/bridge/index.js +26 -6
- package/dist/tools/dual-stacking.tools.js +25 -3
- package/dist/tools/inbox.tools.js +26 -569
- package/dist/tools/lightning.tools.js +30 -0
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,8 @@ npx @aibtc/mcp-server@latest bridge --read-only --list-tools
|
|
|
99
99
|
npx @aibtc/mcp-server@latest bridge --read-only --allow transfer_stx "send 1 STX to SP3..."
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
Before any agent loop runs (and on `--list-tools`), the bridge prints a compact **safety receipt** to stderr — network, read-only mode, exposed/write/blocked tool counts, the session spend cap, and the number of known x402 endpoints — so the configured execution boundaries are visible up front. It reports boundaries only; it never claims any value moved.
|
|
103
|
+
|
|
102
104
|
The allowlist is re-enforced at execution time, so a model can never call a tool outside the exposed set. Any MCP-capable agent framework (`@openrouter/agent`, OpenAI Agents SDK, Claude Agent SDK) can also point at this server directly — the bridge is for driving it through OpenRouter's raw API without adopting a framework.
|
|
103
105
|
|
|
104
106
|
### Testnet Mode
|
package/dist/bridge/index.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
31
31
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
32
|
+
import { ALL_ENDPOINTS } from "../endpoints/registry.js";
|
|
32
33
|
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
33
34
|
const DEFAULT_MODEL = "anthropic/claude-3.5-haiku";
|
|
34
35
|
/**
|
|
@@ -136,6 +137,28 @@ function parseArgs(argv) {
|
|
|
136
137
|
opts.prompt = promptWords.join(" ").trim();
|
|
137
138
|
return opts;
|
|
138
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Compact safety receipt printed before any agent loop runs (#583). Makes the
|
|
142
|
+
* configured execution boundaries visible up front so a user can trust the
|
|
143
|
+
* bridge without digging through flags/config. It reports ONLY configured
|
|
144
|
+
* boundaries — it never claims that any value moved. Emitted on stderr so it
|
|
145
|
+
* can't pollute stdout (the final assistant reply / --list-tools piping).
|
|
146
|
+
*/
|
|
147
|
+
function printSafetyReceipt(opts, exposed, totalTools) {
|
|
148
|
+
const writeToolCount = exposed.filter((t) => !isReadOnly(t.name)).length;
|
|
149
|
+
const lines = [
|
|
150
|
+
"aibtc-mcp bridge — safety receipt",
|
|
151
|
+
` network=${opts.network}`,
|
|
152
|
+
` read_only=${opts.readOnly}`,
|
|
153
|
+
` exposed_tools=${exposed.length}/${totalTools}`,
|
|
154
|
+
` write_tool_count=${writeToolCount}`,
|
|
155
|
+
` blocked_tool_count=${opts.block.size}`,
|
|
156
|
+
` session_spend_cap=${opts.maxSpendUstx ?? "default"} uSTX / ${opts.maxSpendSats ?? "default"} sats`,
|
|
157
|
+
` x402_endpoints_known=${ALL_ENDPOINTS.length}`,
|
|
158
|
+
" (reports configured execution boundaries only; no value has moved)",
|
|
159
|
+
];
|
|
160
|
+
console.error(lines.join("\n"));
|
|
161
|
+
}
|
|
139
162
|
/** Apply read-only + allow + block to the raw MCP tool list. */
|
|
140
163
|
function selectTools(tools, opts) {
|
|
141
164
|
return tools.filter((t) => {
|
|
@@ -193,6 +216,7 @@ export async function runBridge(argv) {
|
|
|
193
216
|
const { tools: allTools } = await client.listTools();
|
|
194
217
|
const exposed = selectTools(allTools, opts);
|
|
195
218
|
if (opts.listTools) {
|
|
219
|
+
printSafetyReceipt(opts, exposed, allTools.length);
|
|
196
220
|
console.log(`Exposed ${exposed.length}/${allTools.length} tools` +
|
|
197
221
|
(opts.readOnly ? " (read-only)" : "") + ":");
|
|
198
222
|
for (const t of exposed)
|
|
@@ -211,12 +235,8 @@ export async function runBridge(argv) {
|
|
|
211
235
|
await client.close();
|
|
212
236
|
process.exit(1);
|
|
213
237
|
}
|
|
214
|
-
|
|
215
|
-
console.error(`
|
|
216
|
-
(opts.readOnly ? " [read-only]" : ` [${writeCount} can write/spend]`) +
|
|
217
|
-
(opts.maxSpendUstx || opts.maxSpendSats
|
|
218
|
-
? ` (cap ${opts.maxSpendUstx ?? "-"} uSTX / ${opts.maxSpendSats ?? "-"} sats)`
|
|
219
|
-
: ""));
|
|
238
|
+
printSafetyReceipt(opts, exposed, allTools.length);
|
|
239
|
+
console.error(` model=${opts.model}, max_turns=${opts.maxTurns}`);
|
|
220
240
|
const openaiTools = exposed.map(toOpenAITool);
|
|
221
241
|
const messages = [
|
|
222
242
|
{
|
|
@@ -64,13 +64,33 @@ Note: Dual Stacking is only available on mainnet.`,
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
const resolvedAddress = address || (await getWalletAddress());
|
|
67
|
-
|
|
67
|
+
// Use allSettled, not all: `is-enrolled-this-cycle` fails contract-side
|
|
68
|
+
// on mainnet with RuntimeCheck(AtBlockUnavailable) (it reads historical
|
|
69
|
+
// block state Hiro has pruned). With Promise.all a single failing read
|
|
70
|
+
// sinks the whole response, hiding the other 4 useful fields. Recover
|
|
71
|
+
// per-call and surface a `warnings` array instead. (#554)
|
|
72
|
+
const warnings = [];
|
|
73
|
+
const settled = await Promise.allSettled([
|
|
68
74
|
callDualStackingReadOnly("is-enrolled-this-cycle", [principalCV(resolvedAddress)]),
|
|
69
75
|
callDualStackingReadOnly("is-enrolled-in-next-cycle", [principalCV(resolvedAddress)]),
|
|
70
76
|
callDualStackingReadOnly("get-minimum-enrollment-amount", []),
|
|
71
77
|
callDualStackingReadOnly("get-apr-data", []),
|
|
72
78
|
callDualStackingReadOnly("current-overview-data", []),
|
|
73
79
|
]);
|
|
80
|
+
const readNames = [
|
|
81
|
+
"is-enrolled-this-cycle",
|
|
82
|
+
"is-enrolled-in-next-cycle",
|
|
83
|
+
"get-minimum-enrollment-amount",
|
|
84
|
+
"get-apr-data",
|
|
85
|
+
"current-overview-data",
|
|
86
|
+
];
|
|
87
|
+
const values = settled.map((r, i) => {
|
|
88
|
+
if (r.status === "fulfilled")
|
|
89
|
+
return r.value;
|
|
90
|
+
warnings.push(`${readNames[i]}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
91
|
+
return null;
|
|
92
|
+
});
|
|
93
|
+
const [enrolledThisCycleRaw, enrolledNextCycleRaw, minimumAmountRaw, aprDataRaw, cycleOverviewRaw,] = values;
|
|
74
94
|
// Parse APR data — returns {min-apr: uint, max-apr: uint} divided by 1_000_000 for %
|
|
75
95
|
let apr = {
|
|
76
96
|
minApr: 0,
|
|
@@ -107,10 +127,11 @@ Note: Dual Stacking is only available on mainnet.`,
|
|
|
107
127
|
const raw = minimumAmountRaw;
|
|
108
128
|
minimumEnrollmentSats = raw.value !== undefined ? Number(raw.value) : 0;
|
|
109
129
|
}
|
|
110
|
-
// Parse boolean enrollment flags
|
|
130
|
+
// Parse boolean enrollment flags. A failed read stays null (unknown)
|
|
131
|
+
// rather than collapsing to a misleading `false`.
|
|
111
132
|
const parseBoolean = (raw) => {
|
|
112
133
|
if (raw === null || raw === undefined)
|
|
113
|
-
return
|
|
134
|
+
return null;
|
|
114
135
|
if (typeof raw === "boolean")
|
|
115
136
|
return raw;
|
|
116
137
|
const obj = raw;
|
|
@@ -126,6 +147,7 @@ Note: Dual Stacking is only available on mainnet.`,
|
|
|
126
147
|
minimumEnrollmentSats,
|
|
127
148
|
apr,
|
|
128
149
|
cycleOverview,
|
|
150
|
+
...(warnings.length > 0 && { warnings }),
|
|
129
151
|
});
|
|
130
152
|
}
|
|
131
153
|
catch (error) {
|
|
@@ -1,192 +1,26 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import { decodePaymentRequired, decodePaymentResponse, encodePaymentPayload, generatePaymentId, buildPaymentIdentifierExtension, X402_HEADERS } from "../utils/x402-protocol.js";
|
|
4
|
-
import { getAccount, NETWORK } from "../services/x402.service.js";
|
|
5
|
-
import { getSbtcService } from "../services/sbtc.service.js";
|
|
6
|
-
import { getStacksNetwork, getExplorerTxUrl } from "../config/networks.js";
|
|
7
|
-
import { getContracts, parseContractId } from "../config/contracts.js";
|
|
8
|
-
import { createFungiblePostCondition } from "../transactions/post-conditions.js";
|
|
9
|
-
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
10
|
-
import { InsufficientBalanceError } from "../utils/errors.js";
|
|
11
|
-
import { formatSbtc } from "../utils/formatting.js";
|
|
12
|
-
import { getHiroApi } from "../services/hiro-api.js";
|
|
13
|
-
import { extractPaymentIdFromPaymentSignature, extractTxidFromPaymentSignature, pollTransactionConfirmation, } from "../utils/x402-recovery.js";
|
|
14
|
-
import { classifyCanonicalPaymentStatus, extractCanonicalPaymentHints, formatCanonicalPaymentStatus, normalizeCallerFacingStatus, resolveCanonicalPaymentStatus, } from "../utils/x402-payment-state.js";
|
|
15
|
-
import { emitCanonicalPaymentDecisionLogs, emitCanonicalPaymentPollLogs, emitPaymentLog, } from "../utils/x402-payment-logging.js";
|
|
16
|
-
const INBOX_BASE = "https://aibtc.com/api/inbox";
|
|
17
|
-
// Deprecation switch for the sponsored relay send. The full implementation is
|
|
18
|
-
// retained below but gated off behind this flag — the relay-sponsored path was
|
|
19
|
-
// unstable, so all inbox sends go through send_inbox_message_direct. Flip to
|
|
20
|
-
// `false` to re-enable. Typed as `boolean` (not a literal) so TypeScript keeps
|
|
21
|
-
// type-checking the retained handler body instead of treating it as dead code.
|
|
22
|
-
const SPONSORED_INBOX_DISABLED = true;
|
|
2
|
+
import { createJsonResponse } from "../utils/index.js";
|
|
23
3
|
// ============================================================================
|
|
24
|
-
//
|
|
4
|
+
// send_inbox_message — DEPRECATED redirect
|
|
25
5
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// 1. Check shared tracker (fast, no network)
|
|
38
|
-
const localNext = await getTrackedNonce(address);
|
|
39
|
-
// 2. Fetch chain state for reconciliation
|
|
40
|
-
const hiroApi = getHiroApi(NETWORK);
|
|
41
|
-
const accountInfo = await hiroApi.getAccountInfo(address);
|
|
42
|
-
const confirmedNonce = accountInfo.nonce;
|
|
43
|
-
// The mempool scan is what makes the nonce safe against pending txs —
|
|
44
|
-
// silently skipping it on failure risks a nonce collision, so let it fail.
|
|
45
|
-
let highestMempoolNonce = -1;
|
|
46
|
-
const mempool = await hiroApi.getMempoolTransactions({
|
|
47
|
-
sender_address: address,
|
|
48
|
-
limit: 50,
|
|
49
|
-
});
|
|
50
|
-
for (const tx of mempool.results) {
|
|
51
|
-
if (tx.nonce > highestMempoolNonce) {
|
|
52
|
-
highestMempoolNonce = tx.nonce;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const chainNext = Math.max(confirmedNonce, highestMempoolNonce + 1);
|
|
56
|
-
// 3. Reconcile tracker with chain state
|
|
57
|
-
await reconcileWithChain(address, chainNext);
|
|
58
|
-
// 4. Return max(chain, local) — same logic as builder path
|
|
59
|
-
return Math.max(chainNext, localNext ?? 0);
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Record that we used a nonce for an address so subsequent calls use a higher value.
|
|
63
|
-
*/
|
|
64
|
-
async function advanceNonceCache(address, usedNonce, txid = "") {
|
|
65
|
-
await recordNonceUsed(address, usedNonce, txid);
|
|
66
|
-
}
|
|
67
|
-
// ============================================================================
|
|
68
|
-
// Transaction Builder
|
|
69
|
-
// ============================================================================
|
|
70
|
-
/**
|
|
71
|
-
* Build a sponsored sBTC transfer transaction (signed, not broadcast).
|
|
72
|
-
* The inbox API handles settlement via the x402 relay.
|
|
73
|
-
* Explicit nonce avoids ConflictingNonceInMempool; optional memo (max 34 bytes)
|
|
74
|
-
* can be used for on-chain labeling.
|
|
75
|
-
*/
|
|
76
|
-
async function buildSponsoredSbtcTransfer(senderKey, senderAddress, recipient, amount, nonce, memo) {
|
|
77
|
-
const contracts = getContracts(NETWORK);
|
|
78
|
-
const { address: contractAddress, name: contractName } = parseContractId(contracts.SBTC_TOKEN);
|
|
79
|
-
const networkName = getStacksNetwork(NETWORK);
|
|
80
|
-
const postCondition = createFungiblePostCondition(senderAddress, contracts.SBTC_TOKEN, "sbtc-token", "eq", amount);
|
|
81
|
-
// Encode memo as (optional (buff 34)): some(buff) if provided, none() otherwise.
|
|
82
|
-
const memoArg = memo
|
|
83
|
-
? someCV(bufferCV(Buffer.from(memo).slice(0, 34)))
|
|
84
|
-
: noneCV();
|
|
85
|
-
const transaction = await makeContractCall({
|
|
86
|
-
contractAddress,
|
|
87
|
-
contractName,
|
|
88
|
-
functionName: "transfer",
|
|
89
|
-
functionArgs: [
|
|
90
|
-
uintCV(amount),
|
|
91
|
-
principalCV(senderAddress),
|
|
92
|
-
principalCV(recipient),
|
|
93
|
-
memoArg,
|
|
94
|
-
],
|
|
95
|
-
senderKey,
|
|
96
|
-
network: networkName,
|
|
97
|
-
postConditions: [postCondition],
|
|
98
|
-
sponsored: true,
|
|
99
|
-
fee: 0n,
|
|
100
|
-
nonce,
|
|
101
|
-
});
|
|
102
|
-
// serialize() returns Hex string (no 0x prefix) in @stacks/transactions v7+
|
|
103
|
-
return "0x" + transaction.serialize();
|
|
104
|
-
}
|
|
105
|
-
/** Default delay when no retryAfter hint is provided. */
|
|
106
|
-
const DEFAULT_RETRY_DELAY_MS = 2_000;
|
|
107
|
-
/** Cap retryAfter to avoid blocking too long (seconds). */
|
|
108
|
-
const MAX_RETRY_AFTER_CAP_S = 60;
|
|
109
|
-
/**
|
|
110
|
-
* Classify a response as retryable and extract retry timing.
|
|
111
|
-
*/
|
|
112
|
-
function classifyLegacyRetryableError(status, body) {
|
|
113
|
-
const NOT_RETRYABLE = { retryable: false, delayMs: 0, relaySideConflict: false };
|
|
114
|
-
// Duplicate-message 409 from the inbox API must NOT be retried —
|
|
115
|
-
// the message was already delivered and retrying would re-pay.
|
|
116
|
-
if (status === 409) {
|
|
117
|
-
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
|
118
|
-
if (/already exists|duplicate/i.test(bodyStr)) {
|
|
119
|
-
return NOT_RETRYABLE;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
if (typeof body === "object" && body !== null) {
|
|
123
|
-
const b = body;
|
|
124
|
-
// Parse relay's retryAfter hint (seconds → ms), capped.
|
|
125
|
-
const rawRetryAfter = typeof b["retryAfter"] === "number" ? b["retryAfter"] : 0;
|
|
126
|
-
const retryAfterMs = rawRetryAfter > 0
|
|
127
|
-
? Math.min(rawRetryAfter, MAX_RETRY_AFTER_CAP_S) * 1000
|
|
128
|
-
: DEFAULT_RETRY_DELAY_MS;
|
|
129
|
-
// Relay returns retryable: true for SETTLEMENT_BROADCAST_FAILED (issue #157)
|
|
130
|
-
if (b["retryable"] === true) {
|
|
131
|
-
return { retryable: true, delayMs: retryAfterMs, relaySideConflict: false };
|
|
132
|
-
}
|
|
133
|
-
// Relay returns HTTP 409 with code: "NONCE_CONFLICT" — relay-side conflict,
|
|
134
|
-
// safe to resubmit the same transaction for dedup.
|
|
135
|
-
if (status === 409 && b["code"] === "NONCE_CONFLICT") {
|
|
136
|
-
return { retryable: true, delayMs: retryAfterMs, relaySideConflict: true };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// Sender-side nonce conflict from the Stacks node (not relay) — needs fresh tx.
|
|
140
|
-
if (typeof body === "string") {
|
|
141
|
-
if (body.includes("ConflictingNonceInMempool") || body.includes("BadNonce")) {
|
|
142
|
-
return { retryable: true, delayMs: DEFAULT_RETRY_DELAY_MS, relaySideConflict: false };
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return NOT_RETRYABLE;
|
|
146
|
-
}
|
|
147
|
-
function getInboxPaymentStatusUrl(paymentId) {
|
|
148
|
-
const inboxBaseUrl = new URL(INBOX_BASE);
|
|
149
|
-
return `${inboxBaseUrl.origin}/api/payment-status/${paymentId}`;
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Sleep for ms milliseconds.
|
|
153
|
-
*/
|
|
154
|
-
function sleep(ms) {
|
|
155
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* POST a message to the inbox using a confirmed txid as payment proof.
|
|
159
|
-
* Returns a structured result so callers can decide how to handle failure
|
|
160
|
-
* (manual recovery throws, auto-recovery tries the next txid).
|
|
161
|
-
*/
|
|
162
|
-
async function submitWithPaymentTxid(recipientBtcAddress, recipientStxAddress, content, txid) {
|
|
163
|
-
const url = `${INBOX_BASE}/${recipientBtcAddress}`;
|
|
164
|
-
const res = await fetch(url, {
|
|
165
|
-
method: "POST",
|
|
166
|
-
headers: { "Content-Type": "application/json" },
|
|
167
|
-
body: JSON.stringify({
|
|
168
|
-
toBtcAddress: recipientBtcAddress,
|
|
169
|
-
toStxAddress: recipientStxAddress,
|
|
170
|
-
content,
|
|
171
|
-
paymentTxid: txid,
|
|
172
|
-
}),
|
|
173
|
-
});
|
|
174
|
-
const body = await res.text();
|
|
175
|
-
const ok = res.status === 200 || res.status === 201 || res.status === 409;
|
|
176
|
-
return { ok, status: res.status, body };
|
|
177
|
-
}
|
|
178
|
-
// ============================================================================
|
|
179
|
-
// Tool Registration
|
|
6
|
+
// The sponsored (relay) inbox send has been removed. The relay-sponsored path
|
|
7
|
+
// was unstable: a burst of sends with an uninitialized local nonce could wedge
|
|
8
|
+
// the relay's sponsor queue (nonces stuck `held(gap)`), leaving payments
|
|
9
|
+
// accepted but messages undelivered (issue #540/#592). All inbox sends now go
|
|
10
|
+
// through send_inbox_message_direct, which signs a standard non-sponsored sBTC
|
|
11
|
+
// transfer and settles directly through the x402 facilitator with no relay in
|
|
12
|
+
// the middle.
|
|
13
|
+
//
|
|
14
|
+
// This tool is retained only as a redirect so existing agents that still call
|
|
15
|
+
// send_inbox_message get pointed to the working tool instead of an
|
|
16
|
+
// "unknown tool" error.
|
|
180
17
|
// ============================================================================
|
|
181
18
|
export function registerInboxTools(server) {
|
|
182
19
|
server.registerTool("send_inbox_message", {
|
|
183
|
-
description: "⛔ DEPRECATED — do not use.
|
|
184
|
-
"a redirect to send_inbox_message_direct.\n\n" +
|
|
20
|
+
description: "⛔ DEPRECATED — do not use. The sponsored (relay) inbox send has been removed. " +
|
|
185
21
|
"Use send_inbox_message_direct instead. It signs a standard sBTC transfer and settles directly " +
|
|
186
22
|
"through the x402 facilitator (no relay). Requires an unlocked wallet holding sBTC (message cost) " +
|
|
187
|
-
"and STX (gas). Mainnet only
|
|
188
|
-
"The sponsored implementation below is retained but gated off behind a deprecation guard — the " +
|
|
189
|
-
"relay-sponsored path was unstable, so it is disabled rather than removed.",
|
|
23
|
+
"and STX (gas). Mainnet only.",
|
|
190
24
|
inputSchema: {
|
|
191
25
|
recipientBtcAddress: z
|
|
192
26
|
.string()
|
|
@@ -198,395 +32,18 @@ export function registerInboxTools(server) {
|
|
|
198
32
|
.string()
|
|
199
33
|
.max(500)
|
|
200
34
|
.describe("Message content (max 500 characters)"),
|
|
201
|
-
paymentTxid: z
|
|
202
|
-
.string()
|
|
203
|
-
.optional()
|
|
204
|
-
.describe("Optional: a confirmed on-chain sBTC transfer txid to use as payment proof. " +
|
|
205
|
-
"When provided, skips the x402 payment flow and resubmits the message directly. " +
|
|
206
|
-
"Use for manual recovery after a settlement timeout left the payment confirmed but the message undelivered."),
|
|
207
35
|
},
|
|
208
|
-
}, async (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
useInstead: "send_inbox_message_direct",
|
|
220
|
-
note: "Call send_inbox_message_direct with the same recipientBtcAddress, recipientStxAddress, " +
|
|
221
|
-
"and content. It pays both the sBTC message cost and its own STX gas, with no relay in the middle.",
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
try {
|
|
225
|
-
// Network mismatch guard: fail early if testnet MCP server targets mainnet inbox.
|
|
226
|
-
// Match the parsed hostname exactly (not a substring) so lookalike hosts can't pass.
|
|
227
|
-
if (NETWORK === "testnet" && new URL(INBOX_BASE).hostname === "aibtc.com") {
|
|
228
|
-
throw new Error("Network mismatch: MCP server is configured for testnet but the inbox service at aibtc.com requires mainnet. " +
|
|
229
|
-
"Set NETWORK=mainnet or use a testnet inbox endpoint.");
|
|
230
|
-
}
|
|
231
|
-
const account = await getAccount();
|
|
232
|
-
// Manual recovery: skip x402 flow and POST with the provided txid as proof
|
|
233
|
-
if (paymentTxid) {
|
|
234
|
-
const result = await submitWithPaymentTxid(recipientBtcAddress, recipientStxAddress, content, paymentTxid);
|
|
235
|
-
if (!result.ok) {
|
|
236
|
-
throw new Error(`paymentTxid recovery failed (${result.status}): ${result.body}`);
|
|
237
|
-
}
|
|
238
|
-
return createJsonResponse({
|
|
239
|
-
success: true,
|
|
240
|
-
message: result.status === 409
|
|
241
|
-
? "Message already delivered"
|
|
242
|
-
: "Message delivered (manual txid recovery)",
|
|
243
|
-
recipient: {
|
|
244
|
-
btcAddress: recipientBtcAddress,
|
|
245
|
-
stxAddress: recipientStxAddress,
|
|
246
|
-
},
|
|
247
|
-
contentLength: content.length,
|
|
248
|
-
payment: {
|
|
249
|
-
txid: paymentTxid,
|
|
250
|
-
recovered: true,
|
|
251
|
-
explorer: getExplorerTxUrl(paymentTxid, NETWORK),
|
|
252
|
-
},
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
// Step 1: POST without payment → get 402 challenge
|
|
256
|
-
const inboxUrl = `${INBOX_BASE}/${recipientBtcAddress}`;
|
|
257
|
-
const body = {
|
|
258
|
-
toBtcAddress: recipientBtcAddress,
|
|
259
|
-
toStxAddress: recipientStxAddress,
|
|
260
|
-
content,
|
|
261
|
-
};
|
|
262
|
-
const initialRes = await fetch(inboxUrl, {
|
|
263
|
-
method: "POST",
|
|
264
|
-
headers: { "Content-Type": "application/json" },
|
|
265
|
-
body: JSON.stringify(body),
|
|
266
|
-
});
|
|
267
|
-
if (initialRes.status !== 402) {
|
|
268
|
-
const text = await initialRes.text();
|
|
269
|
-
if (initialRes.ok) {
|
|
270
|
-
return createJsonResponse({
|
|
271
|
-
success: true,
|
|
272
|
-
message: "Message sent (no payment required)",
|
|
273
|
-
response: text,
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
throw new Error(`Expected 402 payment challenge, got ${initialRes.status}: ${text}`);
|
|
277
|
-
}
|
|
278
|
-
// Step 2: Parse payment requirements
|
|
279
|
-
const paymentHeader = initialRes.headers.get(X402_HEADERS.PAYMENT_REQUIRED);
|
|
280
|
-
if (!paymentHeader) {
|
|
281
|
-
throw new Error("402 response missing payment-required header");
|
|
282
|
-
}
|
|
283
|
-
const paymentRequired = decodePaymentRequired(paymentHeader);
|
|
284
|
-
if (!paymentRequired || !paymentRequired.accepts || paymentRequired.accepts.length === 0) {
|
|
285
|
-
throw new Error("No accepted payment methods in 402 response");
|
|
286
|
-
}
|
|
287
|
-
const accept = paymentRequired.accepts[0];
|
|
288
|
-
const amount = BigInt(accept.amount);
|
|
289
|
-
// Pre-check sBTC balance only — sponsored txs have fee: 0n so STX gas is not required
|
|
290
|
-
const sbtcService = getSbtcService(NETWORK);
|
|
291
|
-
const balanceInfo = await sbtcService.getBalance(account.address);
|
|
292
|
-
const sbtcBalance = BigInt(balanceInfo.balance);
|
|
293
|
-
if (sbtcBalance < amount) {
|
|
294
|
-
const shortfall = amount - sbtcBalance;
|
|
295
|
-
throw new InsufficientBalanceError(`Insufficient sBTC balance: need ${formatSbtc(accept.amount)}, have ${formatSbtc(balanceInfo.balance)} (shortfall: ${formatSbtc(shortfall.toString())}). ` +
|
|
296
|
-
`Deposit more sBTC via the bridge at https://bridge.stx.eco or use a different wallet.`, "sBTC", balanceInfo.balance, accept.amount, shortfall.toString());
|
|
297
|
-
}
|
|
298
|
-
// Steps 3-5: Build payment and send with retry loop
|
|
299
|
-
// Up to 3 attempts, waiting nextRetryDelayMs (or retryAfter) before each retry.
|
|
300
|
-
const MAX_ATTEMPTS = 3;
|
|
301
|
-
let lastError = "";
|
|
302
|
-
let paymentSignature = null;
|
|
303
|
-
// Track relay txids across failed attempts to detect stale dedup.
|
|
304
|
-
const seenRelayTxids = new Set();
|
|
305
|
-
// Stash first attempt's tx + paymentId for reuse on relay-side conflicts.
|
|
306
|
-
let cachedTxHex = null;
|
|
307
|
-
let cachedPaymentId = null;
|
|
308
|
-
let cachedNonce = null;
|
|
309
|
-
let nextRetryDelayMs = 0;
|
|
310
|
-
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
311
|
-
if (attempt > 0 && nextRetryDelayMs > 0) {
|
|
312
|
-
console.error(`[send_inbox_message] Retry attempt ${attempt}/${MAX_ATTEMPTS - 1} after ${nextRetryDelayMs}ms`);
|
|
313
|
-
await sleep(nextRetryDelayMs);
|
|
314
|
-
}
|
|
315
|
-
// Step 3: Build sponsored sBTC transfer.
|
|
316
|
-
// Reuse the cached transaction on relay-side nonce conflicts (the relay
|
|
317
|
-
// deduplicates by paymentId so we avoid consuming additional nonce slots).
|
|
318
|
-
let nonce;
|
|
319
|
-
let txHex;
|
|
320
|
-
let paymentId;
|
|
321
|
-
if (cachedTxHex && cachedPaymentId && cachedNonce !== null) {
|
|
322
|
-
// Relay-side conflict: resubmit the same tx for dedup
|
|
323
|
-
nonce = cachedNonce;
|
|
324
|
-
txHex = cachedTxHex;
|
|
325
|
-
paymentId = cachedPaymentId;
|
|
326
|
-
console.error(`[send_inbox_message] Reusing cached tx (nonce=${nonce}) for relay-side dedup`);
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
// Fresh tx: sender-side conflict or first attempt
|
|
330
|
-
nonce = await getNextNonce(account.address);
|
|
331
|
-
txHex = await buildSponsoredSbtcTransfer(account.privateKey, account.address, accept.payTo, amount, BigInt(nonce));
|
|
332
|
-
paymentId = generatePaymentId();
|
|
333
|
-
// Cache for potential reuse on relay-side conflicts
|
|
334
|
-
cachedTxHex = txHex;
|
|
335
|
-
cachedPaymentId = paymentId;
|
|
336
|
-
cachedNonce = nonce;
|
|
337
|
-
}
|
|
338
|
-
// Step 4: Encode PaymentPayloadV2 with payment-identifier extension.
|
|
339
|
-
paymentSignature = encodePaymentPayload({
|
|
340
|
-
x402Version: 2,
|
|
341
|
-
resource: paymentRequired.resource,
|
|
342
|
-
accepted: accept,
|
|
343
|
-
payload: { transaction: txHex },
|
|
344
|
-
extensions: buildPaymentIdentifierExtension(paymentId),
|
|
345
|
-
});
|
|
346
|
-
emitPaymentLog("payment.accepted", {
|
|
347
|
-
tool: "send_inbox_message",
|
|
348
|
-
paymentId,
|
|
349
|
-
action: "payment_submitted",
|
|
350
|
-
compatShimUsed: false,
|
|
351
|
-
});
|
|
352
|
-
// Step 5: Send with payment header
|
|
353
|
-
const finalRes = await fetch(inboxUrl, {
|
|
354
|
-
method: "POST",
|
|
355
|
-
headers: {
|
|
356
|
-
"Content-Type": "application/json",
|
|
357
|
-
[X402_HEADERS.PAYMENT_SIGNATURE]: paymentSignature,
|
|
358
|
-
},
|
|
359
|
-
body: JSON.stringify(body),
|
|
360
|
-
});
|
|
361
|
-
const responseData = await finalRes.text();
|
|
362
|
-
let parsed;
|
|
363
|
-
try {
|
|
364
|
-
parsed = JSON.parse(responseData);
|
|
365
|
-
}
|
|
366
|
-
catch {
|
|
367
|
-
parsed = { raw: responseData };
|
|
368
|
-
}
|
|
369
|
-
if (finalRes.status === 200 || finalRes.status === 201 || finalRes.status === 202) {
|
|
370
|
-
// Extract payment response header for txid
|
|
371
|
-
const settlement = decodePaymentResponse(finalRes.headers.get(X402_HEADERS.PAYMENT_RESPONSE));
|
|
372
|
-
const txid = settlement?.transaction;
|
|
373
|
-
// Extract paymentId and paymentStatus from inbox response body.
|
|
374
|
-
// The inbox API nests these under "inbox": { paymentId, paymentStatus }.
|
|
375
|
-
const inboxData = parsed.inbox;
|
|
376
|
-
const inboxPaymentId = (inboxData?.paymentId ?? parsed.paymentId);
|
|
377
|
-
const inboxPaymentStatus = (inboxData?.paymentStatus ?? parsed.paymentStatus);
|
|
378
|
-
const resolvedPaymentId = inboxPaymentId || paymentId;
|
|
379
|
-
const canonicalHints = extractCanonicalPaymentHints({
|
|
380
|
-
payload: parsed,
|
|
381
|
-
paymentId: resolvedPaymentId,
|
|
382
|
-
baseUrl: INBOX_BASE,
|
|
383
|
-
});
|
|
384
|
-
let compatShimUsed = false;
|
|
385
|
-
const canonicalStatus = await resolveCanonicalPaymentStatus({
|
|
386
|
-
payload: parsed,
|
|
387
|
-
paymentId: resolvedPaymentId,
|
|
388
|
-
baseUrl: INBOX_BASE,
|
|
389
|
-
fallbackCheckStatusUrl: resolvedPaymentId
|
|
390
|
-
? getInboxPaymentStatusUrl(resolvedPaymentId)
|
|
391
|
-
: undefined,
|
|
392
|
-
onPoll: ({ paymentId: polledPaymentId, checkStatusUrl, source }) => {
|
|
393
|
-
compatShimUsed = emitCanonicalPaymentPollLogs({
|
|
394
|
-
tool: "send_inbox_message",
|
|
395
|
-
paymentId: polledPaymentId ?? resolvedPaymentId,
|
|
396
|
-
checkStatusUrl,
|
|
397
|
-
source,
|
|
398
|
-
});
|
|
399
|
-
},
|
|
400
|
-
});
|
|
401
|
-
if (canonicalStatus) {
|
|
402
|
-
const decision = classifyCanonicalPaymentStatus(canonicalStatus);
|
|
403
|
-
emitCanonicalPaymentDecisionLogs({
|
|
404
|
-
tool: "send_inbox_message",
|
|
405
|
-
status: canonicalStatus,
|
|
406
|
-
decision,
|
|
407
|
-
compatShimUsed,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
// Advance shared nonce tracker on success.
|
|
411
|
-
// When no txid is available (pending settlement), record the paymentId
|
|
412
|
-
// so the agent can correlate the nonce with a trackable reference.
|
|
413
|
-
// Fall back to the client-generated paymentId if the response body is
|
|
414
|
-
// missing/malformed, so success responses always record a non-empty ref.
|
|
415
|
-
const nonceRef = txid || `pending:${resolvedPaymentId}`;
|
|
416
|
-
await advanceNonceCache(account.address, nonce, nonceRef);
|
|
417
|
-
// Build payment info — always include when we have any payment reference.
|
|
418
|
-
// This ensures the agent sees payment status even when settlement is pending.
|
|
419
|
-
// Prefer canonical poll hints when available; synthesize an inbox-local
|
|
420
|
-
// payment-status URL only as an explicit compatibility fallback.
|
|
421
|
-
const paymentCheckUrl = canonicalStatus?.checkStatusUrl ??
|
|
422
|
-
canonicalHints.checkStatusUrl ??
|
|
423
|
-
(resolvedPaymentId ? getInboxPaymentStatusUrl(resolvedPaymentId) : undefined);
|
|
424
|
-
return createJsonResponse({
|
|
425
|
-
success: true,
|
|
426
|
-
message: "Message delivered",
|
|
427
|
-
recipient: {
|
|
428
|
-
btcAddress: recipientBtcAddress,
|
|
429
|
-
stxAddress: recipientStxAddress,
|
|
430
|
-
},
|
|
431
|
-
contentLength: content.length,
|
|
432
|
-
inbox: parsed,
|
|
433
|
-
payment: {
|
|
434
|
-
...(txid && {
|
|
435
|
-
txid,
|
|
436
|
-
explorer: getExplorerTxUrl(txid, NETWORK),
|
|
437
|
-
}),
|
|
438
|
-
amount: accept.amount + " sats sBTC",
|
|
439
|
-
status: canonicalStatus?.status ??
|
|
440
|
-
normalizeCallerFacingStatus(inboxPaymentStatus) ??
|
|
441
|
-
(txid ? "confirmed" : resolvedPaymentId ? "queued" : "unknown"),
|
|
442
|
-
paymentId: resolvedPaymentId,
|
|
443
|
-
...(canonicalStatus?.terminalReason && {
|
|
444
|
-
terminalReason: canonicalStatus.terminalReason,
|
|
445
|
-
}),
|
|
446
|
-
...(paymentCheckUrl && { checkStatusUrl: paymentCheckUrl }),
|
|
447
|
-
},
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
// Extract relay txid from payment-response header (forwarded even on failure).
|
|
451
|
-
// If we have seen it before, the relay is serving a stale cached result.
|
|
452
|
-
const failedTxid = decodePaymentResponse(finalRes.headers.get(X402_HEADERS.PAYMENT_RESPONSE))?.transaction;
|
|
453
|
-
if (failedTxid && seenRelayTxids.has(failedTxid)) {
|
|
454
|
-
console.error(`[send_inbox_message] Stale dedup: relay returned previously-seen txid ${failedTxid} on attempt ${attempt + 1}`);
|
|
455
|
-
}
|
|
456
|
-
else if (failedTxid) {
|
|
457
|
-
seenRelayTxids.add(failedTxid);
|
|
458
|
-
}
|
|
459
|
-
const paymentIdForStatus = cachedPaymentId ??
|
|
460
|
-
extractPaymentIdFromPaymentSignature(paymentSignature) ??
|
|
461
|
-
undefined;
|
|
462
|
-
let compatShimUsed = false;
|
|
463
|
-
const canonicalStatus = await resolveCanonicalPaymentStatus({
|
|
464
|
-
payload: parsed,
|
|
465
|
-
paymentId: paymentIdForStatus,
|
|
466
|
-
baseUrl: INBOX_BASE,
|
|
467
|
-
fallbackCheckStatusUrl: paymentIdForStatus
|
|
468
|
-
? getInboxPaymentStatusUrl(paymentIdForStatus)
|
|
469
|
-
: undefined,
|
|
470
|
-
onPoll: ({ paymentId: polledPaymentId, checkStatusUrl, source }) => {
|
|
471
|
-
compatShimUsed = emitCanonicalPaymentPollLogs({
|
|
472
|
-
tool: "send_inbox_message",
|
|
473
|
-
paymentId: polledPaymentId ?? paymentIdForStatus,
|
|
474
|
-
checkStatusUrl,
|
|
475
|
-
source,
|
|
476
|
-
});
|
|
477
|
-
},
|
|
478
|
-
});
|
|
479
|
-
if (canonicalStatus) {
|
|
480
|
-
const decision = classifyCanonicalPaymentStatus(canonicalStatus);
|
|
481
|
-
emitCanonicalPaymentDecisionLogs({
|
|
482
|
-
tool: "send_inbox_message",
|
|
483
|
-
status: canonicalStatus,
|
|
484
|
-
decision,
|
|
485
|
-
compatShimUsed,
|
|
486
|
-
});
|
|
487
|
-
if ((decision.action === "poll_same_payment" || decision.action === "rebuild_sender") &&
|
|
488
|
-
attempt < MAX_ATTEMPTS - 1) {
|
|
489
|
-
nextRetryDelayMs = decision.action === "poll_same_payment" ? DEFAULT_RETRY_DELAY_MS : 0;
|
|
490
|
-
if (decision.action === "rebuild_sender") {
|
|
491
|
-
cachedTxHex = null;
|
|
492
|
-
cachedPaymentId = null;
|
|
493
|
-
cachedNonce = null;
|
|
494
|
-
await advanceNonceCache(account.address, nonce);
|
|
495
|
-
}
|
|
496
|
-
lastError = `${finalRes.status}: ${decision.summary}`;
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
const errorBase = `Message delivery failed (${finalRes.status}). ${decision.summary}` +
|
|
500
|
-
(canonicalStatus.error ? ` Relay detail: ${canonicalStatus.error}` : "");
|
|
501
|
-
throw new Error(`${errorBase}\n\nCanonical payment status:\n${formatCanonicalPaymentStatus(canonicalStatus)}`);
|
|
502
|
-
}
|
|
503
|
-
// Fall back to transport-specific retry hints only when canonical status is unavailable.
|
|
504
|
-
const retry = classifyLegacyRetryableError(finalRes.status, parsed);
|
|
505
|
-
if (retry.retryable && attempt < MAX_ATTEMPTS - 1) {
|
|
506
|
-
console.error(`[send_inbox_message] Retryable error on attempt ${attempt + 1}: status=${finalRes.status} relaySide=${retry.relaySideConflict} delayMs=${retry.delayMs} body=${responseData}`);
|
|
507
|
-
nextRetryDelayMs = retry.delayMs;
|
|
508
|
-
if (retry.relaySideConflict) {
|
|
509
|
-
// Keep cached tx/paymentId — relay will dedup on resubmit
|
|
510
|
-
}
|
|
511
|
-
else {
|
|
512
|
-
// Sender-side conflict: need a fresh tx with new nonce
|
|
513
|
-
cachedTxHex = null;
|
|
514
|
-
cachedPaymentId = null;
|
|
515
|
-
cachedNonce = null;
|
|
516
|
-
// Advance nonce cache so the next attempt uses a strictly higher nonce.
|
|
517
|
-
await advanceNonceCache(account.address, nonce);
|
|
518
|
-
}
|
|
519
|
-
lastError = `${finalRes.status}: ${responseData}`;
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
// Non-retryable or last attempt — build error with txid recovery info
|
|
523
|
-
const txid = paymentSignature
|
|
524
|
-
? extractTxidFromPaymentSignature(paymentSignature)
|
|
525
|
-
: null;
|
|
526
|
-
const errorBase = `Message delivery failed (${finalRes.status}): ${responseData}`;
|
|
527
|
-
if (txid) {
|
|
528
|
-
// Poll briefly for on-chain status so the error includes actionable info
|
|
529
|
-
const confirmation = await pollTransactionConfirmation(txid, NETWORK);
|
|
530
|
-
emitPaymentLog("payment.fallback_used", {
|
|
531
|
-
tool: "send_inbox_message",
|
|
532
|
-
paymentId: paymentIdForStatus,
|
|
533
|
-
status: confirmation.status,
|
|
534
|
-
action: "txid_recovery",
|
|
535
|
-
compatShimUsed: false,
|
|
536
|
-
});
|
|
537
|
-
throw new Error(`${errorBase}\n\nCanonical payment status was unavailable, so only txid recovery fallback is available. ` +
|
|
538
|
-
`Payment transaction may already exist on-chain. ` +
|
|
539
|
-
`Transaction recovery info:\n txid: ${confirmation.txid}\n status: ${confirmation.status}\n explorer: ${confirmation.explorer}`);
|
|
540
|
-
}
|
|
541
|
-
throw new Error(errorBase);
|
|
542
|
-
}
|
|
543
|
-
// Retries exhausted -- check if any relay txid confirmed on-chain and
|
|
544
|
-
// resubmit with the confirmed txid as payment proof.
|
|
545
|
-
if (seenRelayTxids.size > 0) {
|
|
546
|
-
console.error(`[send_inbox_message] Checking on-chain status of ${seenRelayTxids.size} seen txid(s) before giving up.`);
|
|
547
|
-
for (const seenTxid of seenRelayTxids) {
|
|
548
|
-
try {
|
|
549
|
-
const confirmation = await pollTransactionConfirmation(seenTxid, NETWORK, 5_000);
|
|
550
|
-
if (confirmation.status !== "success" && confirmation.status !== "confirmed") {
|
|
551
|
-
continue;
|
|
552
|
-
}
|
|
553
|
-
console.error(`[send_inbox_message] Auto-recovery: txid ${seenTxid} confirmed on-chain. Resubmitting.`);
|
|
554
|
-
const result = await submitWithPaymentTxid(recipientBtcAddress, recipientStxAddress, content, seenTxid);
|
|
555
|
-
if (result.ok) {
|
|
556
|
-
return createJsonResponse({
|
|
557
|
-
success: true,
|
|
558
|
-
message: result.status === 409
|
|
559
|
-
? "Message already delivered"
|
|
560
|
-
: "Message delivered (auto-recovered with confirmed txid)",
|
|
561
|
-
recipient: {
|
|
562
|
-
btcAddress: recipientBtcAddress,
|
|
563
|
-
stxAddress: recipientStxAddress,
|
|
564
|
-
},
|
|
565
|
-
contentLength: content.length,
|
|
566
|
-
payment: {
|
|
567
|
-
txid: seenTxid,
|
|
568
|
-
amount: accept.amount + " sats sBTC",
|
|
569
|
-
explorer: getExplorerTxUrl(seenTxid, NETWORK),
|
|
570
|
-
recovered: true,
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
console.error(`[send_inbox_message] Auto-recovery resubmission failed for txid ${seenTxid}: ${result.status} ${result.body}`);
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
// Non-fatal: move on to the next txid
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
// Include all seen txids in the error for diagnostics
|
|
582
|
-
const txidSummary = seenRelayTxids.size > 0
|
|
583
|
-
? `\n\nSeen relay txids (all failed or pending):\n${[...seenRelayTxids].map((id) => ` ${id}`).join("\n")}`
|
|
584
|
-
: "";
|
|
585
|
-
throw new Error(`Message delivery failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastError}${txidSummary}`);
|
|
586
|
-
}
|
|
587
|
-
catch (error) {
|
|
588
|
-
return createErrorResponse(error);
|
|
589
|
-
}
|
|
36
|
+
}, async () => {
|
|
37
|
+
return createJsonResponse({
|
|
38
|
+
success: false,
|
|
39
|
+
deprecated: true,
|
|
40
|
+
error: "send_inbox_message has been removed. The sponsored relay path was unstable " +
|
|
41
|
+
"(payments could be accepted but never delivered when the relay's sponsor nonce " +
|
|
42
|
+
"queue wedged).",
|
|
43
|
+
useInstead: "send_inbox_message_direct",
|
|
44
|
+
note: "Call send_inbox_message_direct with the same recipientBtcAddress, recipientStxAddress, " +
|
|
45
|
+
"and content. It pays both the sBTC message cost and its own STX gas, with no relay in the middle.",
|
|
46
|
+
});
|
|
590
47
|
});
|
|
591
48
|
}
|
|
592
49
|
//# sourceMappingURL=inbox.tools.js.map
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* same LightningProvider singleton to auto-pay invoice challenges on 402s.
|
|
8
8
|
*/
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { decode as decodeBolt11 } from "light-bolt11-decoder";
|
|
10
11
|
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
11
12
|
import { getLightningManager } from "../services/lightning-manager.js";
|
|
12
13
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
14
|
+
import { getSpendLimiter } from "../services/spend-limiter.js";
|
|
13
15
|
import { MempoolApi, getMempoolTxUrl, } from "../services/mempool-api.js";
|
|
14
16
|
import { buildAndSignBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
15
17
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
@@ -284,11 +286,39 @@ export function registerLightningTools(server) {
|
|
|
284
286
|
if (!provider) {
|
|
285
287
|
throw new Error("Lightning wallet is locked. Use lightning_unlock first.");
|
|
286
288
|
}
|
|
289
|
+
// Meter the invoice face value against the cumulative sats spend limit
|
|
290
|
+
// (#572). maxFeeSats only bounds the routing fee, not the payable amount,
|
|
291
|
+
// so without this a single invoice can pay out an unbounded amount.
|
|
292
|
+
// Decode the invoice to read the amount (millisats), mirroring the L402
|
|
293
|
+
// auto-pay path in x402.service.ts.
|
|
294
|
+
let decoded;
|
|
295
|
+
try {
|
|
296
|
+
decoded = decodeBolt11(bolt11);
|
|
297
|
+
}
|
|
298
|
+
catch (decodeErr) {
|
|
299
|
+
throw new Error(`BOLT-11 invoice could not be decoded: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}`);
|
|
300
|
+
}
|
|
301
|
+
const amountSection = decoded.sections.find((s) => s.name === "amount");
|
|
302
|
+
const amountMsat = amountSection?.value ? BigInt(amountSection.value) : 0n;
|
|
303
|
+
const amountSats = Number(amountMsat / 1000n);
|
|
304
|
+
if (amountSats === 0) {
|
|
305
|
+
throw new Error("Invoice has no amount; refusing to pay amountless invoices for safety (an unmeterable spend defeats the spending cap).");
|
|
306
|
+
}
|
|
307
|
+
// Key the sats ledger by the active Stacks address so BTC L1 / sBTC /
|
|
308
|
+
// L402 / Lightning spends share one budget. When the main wallet is
|
|
309
|
+
// locked (Lightning has its own session), fall back to a dedicated
|
|
310
|
+
// Lightning bucket so the pay is always metered. Note: a locked-STX
|
|
311
|
+
// user therefore gets a separate Lightning ledger from their STX-address
|
|
312
|
+
// ledger — see SECURITY.md.
|
|
313
|
+
const ledgerKey = getWalletManager().getActiveAccount()?.address ?? "__lightning__";
|
|
314
|
+
await getSpendLimiter().check("sats", BigInt(amountSats), ledgerKey);
|
|
287
315
|
const result = await provider.payInvoice(bolt11, maxFeeSats);
|
|
316
|
+
await getSpendLimiter().record("sats", BigInt(amountSats), ledgerKey);
|
|
288
317
|
return createJsonResponse({
|
|
289
318
|
success: true,
|
|
290
319
|
preimage: result.preimage,
|
|
291
320
|
feesPaidSats: result.feesPaid,
|
|
321
|
+
amountSats,
|
|
292
322
|
});
|
|
293
323
|
}
|
|
294
324
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibtc/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.62.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.
|
|
7
|
+
version: 1.62.1 # x-release-please-version
|
|
8
8
|
npm: "@aibtc/mcp-server"
|
|
9
9
|
github: https://github.com/aibtcdev/aibtc-mcp-server
|
|
10
10
|
---
|