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