@aibtc/mcp-server 1.59.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/bridge/index.d.ts +31 -0
- package/dist/bridge/index.js +285 -0
- package/dist/index.js +10 -0
- package/dist/services/spend-limiter.d.ts +45 -0
- package/dist/services/spend-limiter.js +218 -0
- package/dist/services/wallet-manager.js +5 -0
- package/dist/services/x402.service.js +18 -0
- package/dist/tools/bitcoin.tools.js +5 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/inference-marketplace.tools.d.ts +30 -0
- package/dist/tools/inference-marketplace.tools.js +303 -0
- package/dist/tools/news.tools.js +5 -4
- package/dist/transactions/builder.js +5 -0
- package/package.json +2 -1
- package/skill/SKILL.md +28 -1
package/README.md
CHANGED
|
@@ -69,6 +69,38 @@ npx @aibtc/mcp-server@latest --install --vscode # VS Code (writes ./.vscode/
|
|
|
69
69
|
|
|
70
70
|
Each installer merges into the existing config — it won't clobber other servers or settings. Restart the client afterward.
|
|
71
71
|
|
|
72
|
+
### OpenRouter (any model)
|
|
73
|
+
|
|
74
|
+
The clients above are MCP hosts — they connect to this server for you. To drive the tools with an [OpenRouter](https://openrouter.ai) model instead, the server ships a built-in bridge: it spawns itself in server mode, exposes the tools to the model as function tools, and runs the tool-call loop. This is the client-side pattern from [OpenRouter's MCP cookbook](https://openrouter.ai/docs/cookbook/coding-agents/mcp-servers), packaged into the binary.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
78
|
+
npx @aibtc/mcp-server@latest bridge "what's the STX balance of SP3...?"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Safety flags (this server moves real funds, so the bridge defaults to nothing extra and lets you constrain it):
|
|
82
|
+
|
|
83
|
+
| Flag | Effect |
|
|
84
|
+
|------|--------|
|
|
85
|
+
| `--read-only` | Expose only read-only tools (no transfer/swap/deploy/etc.) |
|
|
86
|
+
| `--allow a,b` | Force-allow specific tool names (added to the set) |
|
|
87
|
+
| `--block a,b` | Force-remove specific tool names (wins over `--allow`) |
|
|
88
|
+
| `--max-spend-ustx <n>` / `--max-spend-sats <n>` | Cap spend via the server's spend-limit rail (enforced before signing) |
|
|
89
|
+
| `--list-tools` | Print the exposed tool set and exit (no API key needed) |
|
|
90
|
+
| `--model <id>` | OpenRouter model (default `anthropic/claude-3.5-haiku`) |
|
|
91
|
+
| `--network <net>` | `mainnet` or `testnet` (default `mainnet`) |
|
|
92
|
+
| `--max-turns <n>` | Tool-call loop cap (default 10) |
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Preview what a read-only session would expose, no key needed:
|
|
96
|
+
npx @aibtc/mcp-server@latest bridge --read-only --list-tools
|
|
97
|
+
|
|
98
|
+
# Read-only chat, plus one explicitly allowed write tool:
|
|
99
|
+
npx @aibtc/mcp-server@latest bridge --read-only --allow transfer_stx "send 1 STX to SP3..."
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
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
|
+
|
|
72
104
|
### Testnet Mode
|
|
73
105
|
|
|
74
106
|
Add `--testnet` to any install command:
|
|
@@ -512,9 +544,14 @@ Or use any SIP-010 token by contract ID: `SP2X...::token-name`
|
|
|
512
544
|
| `API_URL` | Default x402 API base URL | `https://x402.biwas.xyz` |
|
|
513
545
|
| `CLIENT_MNEMONIC` | (Optional) Pre-configured mnemonic | - |
|
|
514
546
|
| `HIRO_API_KEY` | (Optional) Hiro API key for higher rate limits | - |
|
|
547
|
+
| `SPEND_LIMIT_ENABLED` | Set `false` to disable the wallet spending limit | `true` |
|
|
548
|
+
| `SPEND_LIMIT_DAILY_USTX` / `SPEND_LIMIT_SESSION_USTX` | STX spend cap per day / per unlock (micro-STX) | `10000000` (10 STX) |
|
|
549
|
+
| `SPEND_LIMIT_DAILY_SATS` / `SPEND_LIMIT_SESSION_SATS` | BTC spend cap per day / per unlock (sats) | `50000` |
|
|
515
550
|
|
|
516
551
|
**Note on `NETWORK`:** The `--install` command writes `NETWORK=mainnet` by default (pass `--testnet` to use testnet). If you omit `NETWORK` from your config entirely, the runtime fallback is `testnet`. Most users should set this explicitly.
|
|
517
552
|
|
|
553
|
+
**Note on spending limits:** A default-on safety rail meters every outbound spend (`transfer_stx`, `transfer_btc`, x402/L402 auto-payments) against a cumulative per-session and per-day cap, so a single bad instruction or a malicious endpoint can't drain the wallet. A spend over the cap is blocked and reports the remaining budget. Raise the caps via the env vars above, or disable with `SPEND_LIMIT_ENABLED=false`. See [SECURITY.md](SECURITY.md#limit-blast-radius).
|
|
554
|
+
|
|
518
555
|
**Note:** `CLIENT_MNEMONIC` is optional. The recommended approach is to let Claude create its own wallet. `HIRO_API_KEY` is optional but recommended for production use — without it, you may hit Hiro's public rate limits (429 responses). Get a key at [platform.hiro.so](https://platform.hiro.so).
|
|
519
556
|
|
|
520
557
|
## Architecture
|
|
@@ -538,8 +575,12 @@ You ←→ Claude ←→ aibtc-mcp-server
|
|
|
538
575
|
- Mnemonics shown only once at creation
|
|
539
576
|
- Auto-lock after 15 minutes (configurable)
|
|
540
577
|
- Transactions signed locally before broadcast
|
|
578
|
+
- **Spending limit (default-on):** outbound spends are capped per session and per day so a single bad instruction can't drain the wallet — see [Configuration](#configuration)
|
|
579
|
+
- **Secret-scanning pre-commit hook:** contributors get a hook (auto-installed via `npm install`) that blocks committing a seed phrase or private key
|
|
541
580
|
- For mainnet: Fund with small amounts first
|
|
542
581
|
|
|
582
|
+
See [SECURITY.md](SECURITY.md) for the full wallet-key protection model and key-leak recovery steps.
|
|
583
|
+
|
|
543
584
|
## Advanced: Pre-configured Mnemonic
|
|
544
585
|
|
|
545
586
|
For automated setups where Claude needs immediate wallet access, add the `CLIENT_MNEMONIC` environment variable to your MCP server config (in `~/.claude.json` for Claude Code, or `claude_desktop_config.json` for Claude Desktop):
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter <-> MCP bridge (`aibtc-mcp-server bridge`).
|
|
3
|
+
*
|
|
4
|
+
* Lets any OpenRouter model call this server's tools. This is the canonical
|
|
5
|
+
* integration described in OpenRouter's own cookbook
|
|
6
|
+
* (https://openrouter.ai/docs/cookbook/coding-agents/mcp-servers): the CLIENT
|
|
7
|
+
* connects to the MCP server and routes LLM calls through OpenRouter — there is
|
|
8
|
+
* no OpenRouter-side MCP hosting. This is a Node port of that pattern with two
|
|
9
|
+
* upgrades: it handles every tool_call (not just the first) and loops to
|
|
10
|
+
* completion (not a single round).
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* 1. Spawn this same binary in server mode over stdio, connect, list tools.
|
|
14
|
+
* 2. Apply safety filters, then convert each MCP tool -> OpenAI function tool.
|
|
15
|
+
* 3. POST conversation + tools to OpenRouter /chat/completions.
|
|
16
|
+
* 4. On tool_calls, execute via MCP and feed results back as role:"tool".
|
|
17
|
+
* 5. Loop until the model answers in plain text (capped by --max-turns).
|
|
18
|
+
*
|
|
19
|
+
* Safety flags:
|
|
20
|
+
* --read-only expose only read-only tools (heuristic allowlist)
|
|
21
|
+
* --allow a,b,c force-allow these tool names (added to the set)
|
|
22
|
+
* --block a,b,c force-remove these tool names (applied last, wins)
|
|
23
|
+
* --max-spend-ustx <n> forward to the server's SPEND_LIMIT_SESSION_USTX rail
|
|
24
|
+
* --max-spend-sats <n> forward to the server's SPEND_LIMIT_SESSION_SATS rail
|
|
25
|
+
* --list-tools print the exposed tool set and exit (no API key needed)
|
|
26
|
+
* --model <id> OpenRouter model (default anthropic/claude-3.5-haiku)
|
|
27
|
+
* --network <net> mainnet|testnet for the spawned server (default mainnet)
|
|
28
|
+
* --max-turns <n> tool-call loop cap (default 10)
|
|
29
|
+
*/
|
|
30
|
+
export declare function runBridge(argv: string[]): Promise<void>;
|
|
31
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter <-> MCP bridge (`aibtc-mcp-server bridge`).
|
|
3
|
+
*
|
|
4
|
+
* Lets any OpenRouter model call this server's tools. This is the canonical
|
|
5
|
+
* integration described in OpenRouter's own cookbook
|
|
6
|
+
* (https://openrouter.ai/docs/cookbook/coding-agents/mcp-servers): the CLIENT
|
|
7
|
+
* connects to the MCP server and routes LLM calls through OpenRouter — there is
|
|
8
|
+
* no OpenRouter-side MCP hosting. This is a Node port of that pattern with two
|
|
9
|
+
* upgrades: it handles every tool_call (not just the first) and loops to
|
|
10
|
+
* completion (not a single round).
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* 1. Spawn this same binary in server mode over stdio, connect, list tools.
|
|
14
|
+
* 2. Apply safety filters, then convert each MCP tool -> OpenAI function tool.
|
|
15
|
+
* 3. POST conversation + tools to OpenRouter /chat/completions.
|
|
16
|
+
* 4. On tool_calls, execute via MCP and feed results back as role:"tool".
|
|
17
|
+
* 5. Loop until the model answers in plain text (capped by --max-turns).
|
|
18
|
+
*
|
|
19
|
+
* Safety flags:
|
|
20
|
+
* --read-only expose only read-only tools (heuristic allowlist)
|
|
21
|
+
* --allow a,b,c force-allow these tool names (added to the set)
|
|
22
|
+
* --block a,b,c force-remove these tool names (applied last, wins)
|
|
23
|
+
* --max-spend-ustx <n> forward to the server's SPEND_LIMIT_SESSION_USTX rail
|
|
24
|
+
* --max-spend-sats <n> forward to the server's SPEND_LIMIT_SESSION_SATS rail
|
|
25
|
+
* --list-tools print the exposed tool set and exit (no API key needed)
|
|
26
|
+
* --model <id> OpenRouter model (default anthropic/claude-3.5-haiku)
|
|
27
|
+
* --network <net> mainnet|testnet for the spawned server (default mainnet)
|
|
28
|
+
* --max-turns <n> tool-call loop cap (default 10)
|
|
29
|
+
*/
|
|
30
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
31
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
32
|
+
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
33
|
+
const DEFAULT_MODEL = "anthropic/claude-3.5-haiku";
|
|
34
|
+
/**
|
|
35
|
+
* Read-only heuristic. A tool counts as read-only only if it matches a known
|
|
36
|
+
* read pattern AND contains no mutating verb — deliberately conservative so a
|
|
37
|
+
* spend/write tool is never auto-included. Use --allow/--block for precision.
|
|
38
|
+
*/
|
|
39
|
+
const READ_PATTERNS = [
|
|
40
|
+
/^get_/, /^list_/, /^check_/, /^lookup_/, /^reverse_/, /^probe_/, /^read_/,
|
|
41
|
+
/_status$/, /_info$/, /_quote$/, /_quote_/, /_models$/, /_guide$/, /_list$/,
|
|
42
|
+
/_balance$/, /_fees$/, /_utxos$/, /_history$/, /_holdings$/, /_overview$/,
|
|
43
|
+
/_summary$/, /_health$/, /_price$/, /_prices$/, /_state$/, /_positions$/,
|
|
44
|
+
/_breakdown$/, /_dashboard_/, /_stats$/, /_metadata$/, /_owner$/, /_holders$/,
|
|
45
|
+
/_search$/, /_feed$/, /_profile$/, /_pubkey$/, /_availability$/, /_count$/,
|
|
46
|
+
];
|
|
47
|
+
const WRITE_VERB = /(transfer|send|swap|deploy|broadcast|buy|sell|pay|supply|withdraw|borrow|repay|create|delete|register|claim|deposit|stack|boost|unwind|invite|set_|sign|submit|enroll|opt_|file_|preorder|mint|inscribe|approve|revoke|connect|disconnect|rotate|export|import|unlock|lock|start|stop|join|settle|redeem|fund|accept|cancel|counter|append|give|add_|deactivate|record|compile|configure|heal|fill_gap|recover|rebalance|hunter|enable)/;
|
|
48
|
+
function isReadOnly(name) {
|
|
49
|
+
if (WRITE_VERB.test(name))
|
|
50
|
+
return false;
|
|
51
|
+
return READ_PATTERNS.some((p) => p.test(name));
|
|
52
|
+
}
|
|
53
|
+
/** MCP tool -> OpenRouter (OpenAI) function-tool schema (verbatim JSON Schema). */
|
|
54
|
+
function toOpenAITool(tool) {
|
|
55
|
+
// Clone so we never mutate the tool descriptor handed back by the MCP SDK.
|
|
56
|
+
const raw = tool.inputSchema ?? {};
|
|
57
|
+
const schema = { type: "object", properties: {}, ...raw };
|
|
58
|
+
return {
|
|
59
|
+
type: "function",
|
|
60
|
+
function: {
|
|
61
|
+
name: tool.name,
|
|
62
|
+
description: tool.description ?? "",
|
|
63
|
+
parameters: schema,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function parseArgs(argv) {
|
|
68
|
+
const opts = {
|
|
69
|
+
prompt: "",
|
|
70
|
+
model: process.env.OPENROUTER_MODEL || DEFAULT_MODEL,
|
|
71
|
+
network: process.env.NETWORK || "mainnet",
|
|
72
|
+
readOnly: false,
|
|
73
|
+
allow: new Set(),
|
|
74
|
+
block: new Set(),
|
|
75
|
+
maxTurns: 10,
|
|
76
|
+
listTools: false,
|
|
77
|
+
};
|
|
78
|
+
const promptWords = [];
|
|
79
|
+
const csv = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
80
|
+
for (let i = 0; i < argv.length; i++) {
|
|
81
|
+
const a = argv[i];
|
|
82
|
+
// Value-taking flags must be followed by a value — otherwise the next loop
|
|
83
|
+
// iteration would silently consume the following flag (or leave the option
|
|
84
|
+
// unset, which for a spend cap means the rail is quietly disabled).
|
|
85
|
+
const value = (flag) => {
|
|
86
|
+
const v = argv[++i];
|
|
87
|
+
if (v === undefined || v.startsWith("--")) {
|
|
88
|
+
console.error(`Missing value for ${flag}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
return v;
|
|
92
|
+
};
|
|
93
|
+
switch (a) {
|
|
94
|
+
case "--read-only":
|
|
95
|
+
opts.readOnly = true;
|
|
96
|
+
break;
|
|
97
|
+
case "--list-tools":
|
|
98
|
+
opts.listTools = true;
|
|
99
|
+
break;
|
|
100
|
+
case "--model":
|
|
101
|
+
opts.model = value(a);
|
|
102
|
+
break;
|
|
103
|
+
case "--network":
|
|
104
|
+
opts.network = value(a);
|
|
105
|
+
break;
|
|
106
|
+
case "--allow":
|
|
107
|
+
csv(value(a)).forEach((t) => opts.allow.add(t));
|
|
108
|
+
break;
|
|
109
|
+
case "--block":
|
|
110
|
+
csv(value(a)).forEach((t) => opts.block.add(t));
|
|
111
|
+
break;
|
|
112
|
+
case "--max-spend-ustx":
|
|
113
|
+
opts.maxSpendUstx = value(a);
|
|
114
|
+
break;
|
|
115
|
+
case "--max-spend-sats":
|
|
116
|
+
opts.maxSpendSats = value(a);
|
|
117
|
+
break;
|
|
118
|
+
case "--max-turns": {
|
|
119
|
+
const raw = value(a);
|
|
120
|
+
const n = parseInt(raw, 10);
|
|
121
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
122
|
+
console.error(`--max-turns must be a positive integer (got "${raw}")`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
opts.maxTurns = n;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
if (a.startsWith("--")) {
|
|
130
|
+
console.error(`Unknown flag: ${a}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
promptWords.push(a);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
opts.prompt = promptWords.join(" ").trim();
|
|
137
|
+
return opts;
|
|
138
|
+
}
|
|
139
|
+
/** Apply read-only + allow + block to the raw MCP tool list. */
|
|
140
|
+
function selectTools(tools, opts) {
|
|
141
|
+
return tools.filter((t) => {
|
|
142
|
+
if (opts.block.has(t.name))
|
|
143
|
+
return false;
|
|
144
|
+
if (opts.allow.has(t.name))
|
|
145
|
+
return true;
|
|
146
|
+
if (opts.readOnly)
|
|
147
|
+
return isReadOnly(t.name);
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async function callOpenRouter(apiKey, model, messages, tools) {
|
|
152
|
+
const res = await fetch(OPENROUTER_URL, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${apiKey}`,
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"HTTP-Referer": "https://github.com/aibtcdev/aibtc-mcp",
|
|
158
|
+
"X-Title": "aibtc-mcp bridge",
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({ model, messages, tools, tool_choice: "auto" }),
|
|
161
|
+
});
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
throw new Error(`OpenRouter ${res.status}: ${await res.text()}`);
|
|
164
|
+
}
|
|
165
|
+
// OpenRouter can return HTTP 200 with an error payload (rate limit, moderation,
|
|
166
|
+
// provider failure) and no choices — surface it instead of crashing on [0].
|
|
167
|
+
const data = (await res.json());
|
|
168
|
+
const message = data.choices?.[0]?.message;
|
|
169
|
+
if (!message) {
|
|
170
|
+
const detail = data.error?.message ?? JSON.stringify(data);
|
|
171
|
+
throw new Error(`OpenRouter returned no message: ${detail}`);
|
|
172
|
+
}
|
|
173
|
+
return message;
|
|
174
|
+
}
|
|
175
|
+
export async function runBridge(argv) {
|
|
176
|
+
const opts = parseArgs(argv);
|
|
177
|
+
// Spawn this same binary in plain server mode (no extra args -> MCP server).
|
|
178
|
+
const serverEnv = {
|
|
179
|
+
...process.env,
|
|
180
|
+
NETWORK: opts.network,
|
|
181
|
+
};
|
|
182
|
+
if (opts.maxSpendUstx)
|
|
183
|
+
serverEnv.SPEND_LIMIT_SESSION_USTX = opts.maxSpendUstx;
|
|
184
|
+
if (opts.maxSpendSats)
|
|
185
|
+
serverEnv.SPEND_LIMIT_SESSION_SATS = opts.maxSpendSats;
|
|
186
|
+
const transport = new StdioClientTransport({
|
|
187
|
+
command: process.execPath,
|
|
188
|
+
args: [process.argv[1]],
|
|
189
|
+
env: serverEnv,
|
|
190
|
+
});
|
|
191
|
+
const client = new Client({ name: "aibtc-openrouter-bridge", version: "1.0.0" });
|
|
192
|
+
await client.connect(transport);
|
|
193
|
+
const { tools: allTools } = await client.listTools();
|
|
194
|
+
const exposed = selectTools(allTools, opts);
|
|
195
|
+
if (opts.listTools) {
|
|
196
|
+
console.log(`Exposed ${exposed.length}/${allTools.length} tools` +
|
|
197
|
+
(opts.readOnly ? " (read-only)" : "") + ":");
|
|
198
|
+
for (const t of exposed)
|
|
199
|
+
console.log(` ${t.name}`);
|
|
200
|
+
await client.close();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
204
|
+
if (!apiKey) {
|
|
205
|
+
console.error("Set OPENROUTER_API_KEY (or use --list-tools to preview without a model).");
|
|
206
|
+
await client.close();
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
if (!opts.prompt) {
|
|
210
|
+
console.error('Provide a prompt, e.g. aibtc-mcp-server bridge "what is the STX balance of SP3..."');
|
|
211
|
+
await client.close();
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
const writeCount = exposed.filter((t) => !isReadOnly(t.name)).length;
|
|
215
|
+
console.error(`Bridge: ${exposed.length} tools -> ${opts.model}` +
|
|
216
|
+
(opts.readOnly ? " [read-only]" : ` [${writeCount} can write/spend]`) +
|
|
217
|
+
(opts.maxSpendUstx || opts.maxSpendSats
|
|
218
|
+
? ` (cap ${opts.maxSpendUstx ?? "-"} uSTX / ${opts.maxSpendSats ?? "-"} sats)`
|
|
219
|
+
: ""));
|
|
220
|
+
const openaiTools = exposed.map(toOpenAITool);
|
|
221
|
+
const messages = [
|
|
222
|
+
{
|
|
223
|
+
role: "system",
|
|
224
|
+
content: "You are an agent with access to aibtc-mcp tools for Stacks/Bitcoin and " +
|
|
225
|
+
"x402 actions. Use tools when needed. Be concise.",
|
|
226
|
+
},
|
|
227
|
+
{ role: "user", content: opts.prompt },
|
|
228
|
+
];
|
|
229
|
+
try {
|
|
230
|
+
let completed = false;
|
|
231
|
+
for (let turn = 0; turn < opts.maxTurns; turn++) {
|
|
232
|
+
const reply = await callOpenRouter(apiKey, opts.model, messages, openaiTools);
|
|
233
|
+
messages.push(reply);
|
|
234
|
+
const toolCalls = reply.tool_calls ?? [];
|
|
235
|
+
if (toolCalls.length === 0) {
|
|
236
|
+
console.log("\n" + (reply.content ?? ""));
|
|
237
|
+
completed = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
for (const call of toolCalls) {
|
|
241
|
+
const name = call.function.name;
|
|
242
|
+
// Enforce the allowlist at execution time too — never trust the model
|
|
243
|
+
// to stay inside the exposed set.
|
|
244
|
+
if (!exposed.some((t) => t.name === name)) {
|
|
245
|
+
messages.push({
|
|
246
|
+
role: "tool",
|
|
247
|
+
tool_call_id: call.id,
|
|
248
|
+
name,
|
|
249
|
+
content: `Tool ${name} is not permitted by the bridge.`,
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
let args = {};
|
|
254
|
+
try {
|
|
255
|
+
args = JSON.parse(call.function.arguments || "{}");
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* malformed JSON -> empty args */
|
|
259
|
+
}
|
|
260
|
+
console.error(`-> ${name}(${call.function.arguments || "{}"})`);
|
|
261
|
+
let content;
|
|
262
|
+
try {
|
|
263
|
+
const result = await client.callTool({ name, arguments: args });
|
|
264
|
+
content = (result.content ?? [])
|
|
265
|
+
.map((c) => (c?.type === "text" ? c.text : JSON.stringify(c)))
|
|
266
|
+
.join("\n");
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
content = `Tool error: ${err.message}`;
|
|
270
|
+
}
|
|
271
|
+
messages.push({ role: "tool", tool_call_id: call.id, name, content });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Don't let the cap swallow the session silently — the model was still
|
|
275
|
+
// calling tools when we ran out of turns.
|
|
276
|
+
if (!completed) {
|
|
277
|
+
console.error(`Bridge: reached --max-turns limit (${opts.maxTurns}) without a final response. ` +
|
|
278
|
+
"Re-run with a higher --max-turns to let it finish.");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
await client.close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=index.js.map
|
package/dist/index.js
CHANGED
|
@@ -229,6 +229,16 @@ if (process.argv[2] === "yield-hunter") {
|
|
|
229
229
|
process.exit(1);
|
|
230
230
|
});
|
|
231
231
|
}
|
|
232
|
+
// Check for bridge command (OpenRouter <-> MCP)
|
|
233
|
+
else if (process.argv[2] === "bridge") {
|
|
234
|
+
import("./bridge/index.js")
|
|
235
|
+
.then(({ runBridge }) => runBridge(process.argv.slice(3)))
|
|
236
|
+
.then(() => process.exit(0))
|
|
237
|
+
.catch((error) => {
|
|
238
|
+
console.error("❌ Bridge error:", redactSensitive(String(error)));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
232
242
|
// Check for --install flag
|
|
233
243
|
else if (process.argv.includes("--install") || process.argv.includes("install")) {
|
|
234
244
|
runInstall()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type SpendUnit = "ustx" | "sats";
|
|
2
|
+
export declare class SpendLimitError extends Error {
|
|
3
|
+
readonly unit: SpendUnit;
|
|
4
|
+
readonly attempted: number;
|
|
5
|
+
readonly remaining: number;
|
|
6
|
+
readonly scope: "session" | "day";
|
|
7
|
+
constructor(message: string, unit: SpendUnit, attempted: number, remaining: number, scope: "session" | "day");
|
|
8
|
+
}
|
|
9
|
+
declare class SpendLimiter {
|
|
10
|
+
private static instance;
|
|
11
|
+
private stateFile;
|
|
12
|
+
private session;
|
|
13
|
+
private writeLock;
|
|
14
|
+
static getInstance(): SpendLimiter;
|
|
15
|
+
/** Test seam: redirect the state file. */
|
|
16
|
+
setStateFile(file: string): void;
|
|
17
|
+
private readState;
|
|
18
|
+
private writeState;
|
|
19
|
+
private getSession;
|
|
20
|
+
/** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
|
|
21
|
+
resetSession(addr: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Throw SpendLimitError if `amount` would push the session OR day total over
|
|
24
|
+
* its cap. Does NOT record — call recordSpend after a successful broadcast.
|
|
25
|
+
*/
|
|
26
|
+
check(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
|
|
27
|
+
/** Record a completed spend against both the session and day ledgers. */
|
|
28
|
+
record(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
|
|
29
|
+
/** Remaining session/day budget for status reporting. */
|
|
30
|
+
status(addr: string): Promise<{
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
ustx: {
|
|
33
|
+
sessionRemaining: number;
|
|
34
|
+
dailyRemaining: number;
|
|
35
|
+
};
|
|
36
|
+
sats: {
|
|
37
|
+
sessionRemaining: number;
|
|
38
|
+
dailyRemaining: number;
|
|
39
|
+
};
|
|
40
|
+
}>;
|
|
41
|
+
private message;
|
|
42
|
+
}
|
|
43
|
+
export declare function getSpendLimiter(): SpendLimiter;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=spend-limiter.d.ts.map
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-on cumulative spending limit for the wallet.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server lets an LLM move real funds with no human click-through, so a
|
|
5
|
+
* single poisoned instruction (or a malicious x402 endpoint) could otherwise
|
|
6
|
+
* drain the wallet. This module is the safety rail: every outbound spend path
|
|
7
|
+
* (native STX transfer, BTC L1 transfer, x402/L402 auto-payments, Lightning
|
|
8
|
+
* pays) routes through it, and the limiter blocks once cumulative outflow would
|
|
9
|
+
* exceed a per-session OR per-day cap.
|
|
10
|
+
*
|
|
11
|
+
* Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
|
|
12
|
+
* (`sats`); there is no cross-asset USD normalization. Daily totals are
|
|
13
|
+
* persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
|
|
14
|
+
* the cap survives process restarts; session totals live in memory and reset on
|
|
15
|
+
* wallet unlock/lock/switch.
|
|
16
|
+
*
|
|
17
|
+
* Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
|
|
18
|
+
* surfaces the remaining budget + the env var to raise. An LLM cannot set env
|
|
19
|
+
* vars mid-session, so it cannot raise its own ceiling.
|
|
20
|
+
*
|
|
21
|
+
* Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
|
|
22
|
+
* SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
|
|
23
|
+
*/
|
|
24
|
+
import { promises as fs } from "fs";
|
|
25
|
+
import os from "os";
|
|
26
|
+
import path from "path";
|
|
27
|
+
const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
|
|
28
|
+
const DEFAULT_STATE_FILE = path.join(STORAGE_DIR, "spend-state.json");
|
|
29
|
+
/**
|
|
30
|
+
* Parse a positive-integer env override, falling back (with a warning) on any
|
|
31
|
+
* invalid value — never silently disable a cap. Mirrors parseSatsCap in
|
|
32
|
+
* x402.service.ts.
|
|
33
|
+
*/
|
|
34
|
+
function parseLimit(envName, fallback) {
|
|
35
|
+
const raw = process.env[envName];
|
|
36
|
+
if (raw === undefined || raw === "")
|
|
37
|
+
return fallback;
|
|
38
|
+
const parsed = Number(raw);
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
40
|
+
console.error(`[spend-limit] Invalid ${envName}="${raw}", falling back to ${fallback}`);
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
return Math.floor(parsed);
|
|
44
|
+
}
|
|
45
|
+
function isEnabled() {
|
|
46
|
+
return process.env.SPEND_LIMIT_ENABLED !== "false";
|
|
47
|
+
}
|
|
48
|
+
// Conservative defaults (~$30/day at the time of writing): tight enough to stop
|
|
49
|
+
// a drain, generous for the micro-payment use case (registry endpoints cost
|
|
50
|
+
// <=0.02 STX / 100 sats). Raise per wallet via env.
|
|
51
|
+
const DEFAULTS = {
|
|
52
|
+
dailyUstx: 10_000_000, // 10 STX
|
|
53
|
+
sessionUstx: 10_000_000, // 10 STX
|
|
54
|
+
dailySats: 50_000, // ~$30 BTC
|
|
55
|
+
sessionSats: 50_000,
|
|
56
|
+
};
|
|
57
|
+
function capsFor(unit) {
|
|
58
|
+
if (unit === "ustx") {
|
|
59
|
+
return {
|
|
60
|
+
daily: parseLimit("SPEND_LIMIT_DAILY_USTX", DEFAULTS.dailyUstx),
|
|
61
|
+
session: parseLimit("SPEND_LIMIT_SESSION_USTX", DEFAULTS.sessionUstx),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
daily: parseLimit("SPEND_LIMIT_DAILY_SATS", DEFAULTS.dailySats),
|
|
66
|
+
session: parseLimit("SPEND_LIMIT_SESSION_SATS", DEFAULTS.sessionSats),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** UTC day bucket, e.g. "2026-06-11". */
|
|
70
|
+
function todayKey() {
|
|
71
|
+
return new Date().toISOString().slice(0, 10);
|
|
72
|
+
}
|
|
73
|
+
function unitLabel(unit) {
|
|
74
|
+
return unit === "ustx" ? "uSTX" : "sats";
|
|
75
|
+
}
|
|
76
|
+
export class SpendLimitError extends Error {
|
|
77
|
+
unit;
|
|
78
|
+
attempted;
|
|
79
|
+
remaining;
|
|
80
|
+
scope;
|
|
81
|
+
constructor(message, unit, attempted, remaining, scope) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.unit = unit;
|
|
84
|
+
this.attempted = attempted;
|
|
85
|
+
this.remaining = remaining;
|
|
86
|
+
this.scope = scope;
|
|
87
|
+
this.name = "SpendLimitError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
class SpendLimiter {
|
|
91
|
+
static instance;
|
|
92
|
+
stateFile = DEFAULT_STATE_FILE;
|
|
93
|
+
// In-memory per-session totals, keyed by wallet address.
|
|
94
|
+
session = new Map();
|
|
95
|
+
writeLock = Promise.resolve();
|
|
96
|
+
static getInstance() {
|
|
97
|
+
if (!SpendLimiter.instance)
|
|
98
|
+
SpendLimiter.instance = new SpendLimiter();
|
|
99
|
+
return SpendLimiter.instance;
|
|
100
|
+
}
|
|
101
|
+
/** Test seam: redirect the state file. */
|
|
102
|
+
setStateFile(file) {
|
|
103
|
+
this.stateFile = file;
|
|
104
|
+
}
|
|
105
|
+
async readState() {
|
|
106
|
+
try {
|
|
107
|
+
const content = await fs.readFile(this.stateFile, "utf8");
|
|
108
|
+
return JSON.parse(content);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async writeState(state) {
|
|
115
|
+
const dir = path.dirname(this.stateFile);
|
|
116
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
const tmp = `${this.stateFile}.tmp`;
|
|
118
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
119
|
+
await fs.rename(tmp, this.stateFile);
|
|
120
|
+
}
|
|
121
|
+
getSession(addr) {
|
|
122
|
+
let s = this.session.get(addr);
|
|
123
|
+
if (!s) {
|
|
124
|
+
s = { ustx: 0, sats: 0 };
|
|
125
|
+
this.session.set(addr, s);
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
/** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
|
|
130
|
+
resetSession(addr) {
|
|
131
|
+
this.session.delete(addr);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Throw SpendLimitError if `amount` would push the session OR day total over
|
|
135
|
+
* its cap. Does NOT record — call recordSpend after a successful broadcast.
|
|
136
|
+
*/
|
|
137
|
+
async check(unit, amount, addr) {
|
|
138
|
+
if (!isEnabled())
|
|
139
|
+
return;
|
|
140
|
+
if (amount <= 0n)
|
|
141
|
+
return;
|
|
142
|
+
const amt = Number(amount);
|
|
143
|
+
const { daily, session } = capsFor(unit);
|
|
144
|
+
const sessionSpent = this.getSession(addr)[unit];
|
|
145
|
+
if (sessionSpent + amt > session) {
|
|
146
|
+
const remaining = Math.max(0, session - sessionSpent);
|
|
147
|
+
throw new SpendLimitError(this.message(unit, amt, remaining, "session", session), unit, amt, remaining, "session");
|
|
148
|
+
}
|
|
149
|
+
const state = await this.readState();
|
|
150
|
+
const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
|
|
151
|
+
if (daySpent + amt > daily) {
|
|
152
|
+
const remaining = Math.max(0, daily - daySpent);
|
|
153
|
+
throw new SpendLimitError(this.message(unit, amt, remaining, "day", daily), unit, amt, remaining, "day");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Record a completed spend against both the session and day ledgers. */
|
|
157
|
+
async record(unit, amount, addr) {
|
|
158
|
+
if (!isEnabled())
|
|
159
|
+
return;
|
|
160
|
+
if (amount <= 0n)
|
|
161
|
+
return;
|
|
162
|
+
const amt = Number(amount);
|
|
163
|
+
this.getSession(addr)[unit] += amt;
|
|
164
|
+
// Serialize read-modify-write to avoid lost updates under concurrency.
|
|
165
|
+
this.writeLock = this.writeLock.then(async () => {
|
|
166
|
+
const state = await this.readState();
|
|
167
|
+
const today = todayKey();
|
|
168
|
+
// Prune stale days so the file does not grow unbounded.
|
|
169
|
+
for (const a of Object.keys(state)) {
|
|
170
|
+
for (const day of Object.keys(state[a])) {
|
|
171
|
+
if (day !== today)
|
|
172
|
+
delete state[a][day];
|
|
173
|
+
}
|
|
174
|
+
if (Object.keys(state[a]).length === 0)
|
|
175
|
+
delete state[a];
|
|
176
|
+
}
|
|
177
|
+
if (!state[addr])
|
|
178
|
+
state[addr] = {};
|
|
179
|
+
if (!state[addr][today])
|
|
180
|
+
state[addr][today] = { ustx: 0, sats: 0 };
|
|
181
|
+
state[addr][today][unit] += amt;
|
|
182
|
+
await this.writeState(state);
|
|
183
|
+
});
|
|
184
|
+
await this.writeLock;
|
|
185
|
+
}
|
|
186
|
+
/** Remaining session/day budget for status reporting. */
|
|
187
|
+
async status(addr) {
|
|
188
|
+
const state = await this.readState();
|
|
189
|
+
const mk = (unit) => {
|
|
190
|
+
const { daily, session } = capsFor(unit);
|
|
191
|
+
const sessionSpent = this.session.get(addr)?.[unit] ?? 0;
|
|
192
|
+
const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
|
|
193
|
+
return {
|
|
194
|
+
sessionRemaining: Math.max(0, session - sessionSpent),
|
|
195
|
+
dailyRemaining: Math.max(0, daily - daySpent),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
return { enabled: isEnabled(), ustx: mk("ustx"), sats: mk("sats") };
|
|
199
|
+
}
|
|
200
|
+
message(unit, attempted, remaining, scope, cap) {
|
|
201
|
+
const envVar = unit === "ustx"
|
|
202
|
+
? scope === "day"
|
|
203
|
+
? "SPEND_LIMIT_DAILY_USTX"
|
|
204
|
+
: "SPEND_LIMIT_SESSION_USTX"
|
|
205
|
+
: scope === "day"
|
|
206
|
+
? "SPEND_LIMIT_DAILY_SATS"
|
|
207
|
+
: "SPEND_LIMIT_SESSION_SATS";
|
|
208
|
+
const label = unitLabel(unit);
|
|
209
|
+
return (`Spending limit reached: this ${attempted} ${label} transfer would exceed the ` +
|
|
210
|
+
`per-${scope} cap of ${cap} ${label} (${remaining} ${label} remaining). ` +
|
|
211
|
+
`This is a safety rail against draining the wallet. To allow a larger spend, ` +
|
|
212
|
+
`raise ${envVar} (or set SPEND_LIMIT_ENABLED=false to disable), then retry.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function getSpendLimiter() {
|
|
216
|
+
return SpendLimiter.getInstance();
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=spend-limiter.js.map
|
|
@@ -5,6 +5,7 @@ import { encrypt, decrypt, generateWalletId, initializeStorage, readWalletIndex,
|
|
|
5
5
|
import { WalletError, WalletNotFoundError, InvalidPasswordError, InvalidMnemonicError, } from "../utils/errors.js";
|
|
6
6
|
import { NETWORK } from "../config/networks.js";
|
|
7
7
|
import { resetPendingNonce } from "../transactions/builder.js";
|
|
8
|
+
import { getSpendLimiter } from "./spend-limiter.js";
|
|
8
9
|
import { deriveBitcoinAddress, deriveBitcoinKeyPair, deriveTaprootAddress, deriveTaprootKeyPair, deriveNostrKeyPair } from "../utils/bitcoin.js";
|
|
9
10
|
/**
|
|
10
11
|
* Wallet manager singleton - handles wallet creation, encryption, and session management
|
|
@@ -170,6 +171,8 @@ class WalletManager {
|
|
|
170
171
|
await writeAppConfig(config);
|
|
171
172
|
// Reset pending nonce counter so the new session re-syncs with the chain
|
|
172
173
|
await resetPendingNonce(account.address);
|
|
174
|
+
// Fresh unlock starts a fresh per-session spend budget.
|
|
175
|
+
getSpendLimiter().resetSession(account.address);
|
|
173
176
|
return account;
|
|
174
177
|
}
|
|
175
178
|
/**
|
|
@@ -180,6 +183,8 @@ class WalletManager {
|
|
|
180
183
|
// Reset pending nonce counter before clearing session
|
|
181
184
|
if (this.session?.account) {
|
|
182
185
|
await resetPendingNonce(this.session.account.address);
|
|
186
|
+
// Clear the per-session spend budget so the next unlock starts fresh.
|
|
187
|
+
getSpendLimiter().resetSession(this.session.account.address);
|
|
183
188
|
}
|
|
184
189
|
// Zero out sensitive key buffers before dropping references
|
|
185
190
|
if (this.session?.account) {
|
|
@@ -5,6 +5,7 @@ import { generateWallet, getStxAddress } from "@stacks/wallet-sdk";
|
|
|
5
5
|
import { NETWORK, API_URL, getStacksNetwork } from "../config/networks.js";
|
|
6
6
|
import { getNetworkFromStacksChainId } from "../config/caip.js";
|
|
7
7
|
import { getWalletManager } from "./wallet-manager.js";
|
|
8
|
+
import { getSpendLimiter } from "./spend-limiter.js";
|
|
8
9
|
import { formatStx, formatSbtc } from "../utils/formatting.js";
|
|
9
10
|
import { getSbtcService } from "./sbtc.service.js";
|
|
10
11
|
import { getHiroApi } from "./hiro-api.js";
|
|
@@ -365,6 +366,13 @@ export async function createApiClient(baseUrl, options) {
|
|
|
365
366
|
`of ${L402_MAX_SATS_PER_INVOICE} sats (L402_MAX_SATS_PER_INVOICE). ` +
|
|
366
367
|
`Refusing to pay.`));
|
|
367
368
|
}
|
|
369
|
+
// Cumulative spending limit (sats ledger) — bounds a drain-by-loop of
|
|
370
|
+
// sub-cap L402 invoices. Keyed by the wallet's Stacks address so all sats
|
|
371
|
+
// spends (BTC L1, sBTC, L402) share one ledger.
|
|
372
|
+
const l402Addr = getWalletManager().getActiveAccount()?.address;
|
|
373
|
+
if (l402Addr) {
|
|
374
|
+
await getSpendLimiter().check("sats", BigInt(amountSats), l402Addr);
|
|
375
|
+
}
|
|
368
376
|
// Pay the Lightning invoice.
|
|
369
377
|
let payment;
|
|
370
378
|
try {
|
|
@@ -373,6 +381,9 @@ export async function createApiClient(baseUrl, options) {
|
|
|
373
381
|
catch (payErr) {
|
|
374
382
|
return Promise.reject(new Error(`L402 payment failed: ${payErr instanceof Error ? payErr.message : String(payErr)}`));
|
|
375
383
|
}
|
|
384
|
+
if (l402Addr) {
|
|
385
|
+
await getSpendLimiter().record("sats", BigInt(amountSats), l402Addr);
|
|
386
|
+
}
|
|
376
387
|
cacheL402Auth(method, canonicalUrlKey, challenge.macaroon, payment.preimage);
|
|
377
388
|
const originalRequest = error.config;
|
|
378
389
|
originalRequest.headers = originalRequest.headers || {};
|
|
@@ -486,6 +497,10 @@ export async function createApiClient(baseUrl, options) {
|
|
|
486
497
|
return Promise.reject(new Error(`Network mismatch: endpoint requires ${paymentNetwork} but wallet is configured for ${acct.network}. ` +
|
|
487
498
|
`Switch to a ${paymentNetwork} wallet or use a ${acct.network} endpoint.`));
|
|
488
499
|
}
|
|
500
|
+
// Cumulative spending limit: the per-payment cap above bounds a single
|
|
501
|
+
// 402, but a malicious endpoint can loop sub-cap payments. This blocks
|
|
502
|
+
// once the session/day total would be exceeded.
|
|
503
|
+
await getSpendLimiter().check(isSbtc ? "sats" : "ustx", amount, acct.address);
|
|
489
504
|
// Invoke pre-payment callback (e.g. balance check) before signing/broadcasting.
|
|
490
505
|
// If the callback throws, the payment is aborted and the error propagates to the caller.
|
|
491
506
|
if (options?.onBeforePayment) {
|
|
@@ -561,6 +576,9 @@ export async function createApiClient(baseUrl, options) {
|
|
|
561
576
|
action: "payment_submitted",
|
|
562
577
|
compatShimUsed: false,
|
|
563
578
|
});
|
|
579
|
+
// Record the spend against the cumulative ledger now that the signed
|
|
580
|
+
// payment tx is submitted to the facilitator.
|
|
581
|
+
await getSpendLimiter().record(isSbtc ? "sats" : "ustx", amount, acct.address);
|
|
564
582
|
// Retry the original request with the payment header
|
|
565
583
|
const originalRequest = error.config;
|
|
566
584
|
originalRequest.headers = originalRequest.headers || {};
|
|
@@ -14,6 +14,7 @@ import { z } from "zod";
|
|
|
14
14
|
import { NETWORK } from "../config/networks.js";
|
|
15
15
|
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
16
16
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
17
|
+
import { getSpendLimiter } from "../services/spend-limiter.js";
|
|
17
18
|
import { MempoolApi, getMempoolAddressUrl, getMempoolTxUrl, } from "../services/mempool-api.js";
|
|
18
19
|
import { buildAndSignBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
19
20
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
@@ -251,6 +252,9 @@ export function registerBitcoinTools(server) {
|
|
|
251
252
|
if (!account) {
|
|
252
253
|
throw new Error("Wallet is not unlocked. Use wallet_unlock first to enable transactions.");
|
|
253
254
|
}
|
|
255
|
+
// Safety rail: block before signing if this would exceed the wallet's
|
|
256
|
+
// cumulative spending limit (per-session or per-day).
|
|
257
|
+
await getSpendLimiter().check("sats", BigInt(amount), account.address);
|
|
254
258
|
if (!account.btcAddress || !account.btcPrivateKey || !account.btcPublicKey) {
|
|
255
259
|
throw new Error("Bitcoin keys not available. Please unlock your wallet again.");
|
|
256
260
|
}
|
|
@@ -308,6 +312,7 @@ export function registerBitcoinTools(server) {
|
|
|
308
312
|
}, account.btcPrivateKey);
|
|
309
313
|
// Broadcast the transaction
|
|
310
314
|
const txid = await api.broadcastTransaction(txResult.txHex);
|
|
315
|
+
await getSpendLimiter().record("sats", BigInt(amount), account.address);
|
|
311
316
|
const response = {
|
|
312
317
|
success: true,
|
|
313
318
|
txid,
|
package/dist/tools/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { registerOrdinalsMarketplaceTools } from "./ordinals-marketplace.tools.j
|
|
|
34
34
|
import { registerTaprootMultisigTools } from "./taproot-multisig.tools.js";
|
|
35
35
|
import { registerJingswapTools } from "./jingswap.tools.js";
|
|
36
36
|
import { registerSigningTools } from "./signing.tools.js";
|
|
37
|
+
import { registerInferenceMarketplaceTools } from "./inference-marketplace.tools.js";
|
|
37
38
|
import { registerNewsTools } from "./news.tools.js";
|
|
38
39
|
import { registerIdentityTools } from "./identity.tools.js";
|
|
39
40
|
import { registerCredentialsTools } from "./credentials.tools.js";
|
|
@@ -160,6 +161,8 @@ export function registerAllTools(server) {
|
|
|
160
161
|
registerJingswapTools(server);
|
|
161
162
|
// Message Signing (BTC BIP-322, Stacks SIWS, SIP-018 structured data, Nostr NIP-01)
|
|
162
163
|
registerSigningTools(server);
|
|
164
|
+
// AIBTC Inference Marketplace (list/manage a paid model endpoint via wallet signature)
|
|
165
|
+
registerInferenceMarketplaceTools(server);
|
|
163
166
|
// AIBTC News (signal feed, leaderboard, file signals)
|
|
164
167
|
registerNewsTools(server);
|
|
165
168
|
// Identity (ERC-8004 on-chain agent identity management)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIBTC Inference Marketplace Tools
|
|
3
|
+
*
|
|
4
|
+
* List an OpenAI-compatible model endpoint on the marketplace and manage it, all
|
|
5
|
+
* authorized by a wallet signature (the same wallet that receives sBTC payouts).
|
|
6
|
+
* Ownership is proven by signing a short message with the payout wallet — no
|
|
7
|
+
* accounts, no API keys. These tools sign locally with the unlocked wallet and
|
|
8
|
+
* call the gateway, so an agent can register/update/check in one step.
|
|
9
|
+
*
|
|
10
|
+
* - inference_register_provider: verify + list an endpoint (signed)
|
|
11
|
+
* - inference_update_provider: change name/models/payout/endpoint in place (signed)
|
|
12
|
+
* - inference_reveal_key: reveal or rotate the gateway↔endpoint shared key (signed)
|
|
13
|
+
* - inference_check_provider: re-run the health/functional probe (unsigned)
|
|
14
|
+
* - inference_list_providers: list registered providers + health (unsigned)
|
|
15
|
+
*
|
|
16
|
+
* The gateway verifies the signature recovers to `payoutAddress` (which also
|
|
17
|
+
* enforces the network), that the endpoint is reachable AND serving inference,
|
|
18
|
+
* and that model ids are real, text-generation, commercially-licensed HF repos.
|
|
19
|
+
*/
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
type AuthAction = "register" | "update" | "reveal-key";
|
|
22
|
+
/** The exact message the gateway rebuilds and verifies. Must match byte-for-byte. */
|
|
23
|
+
export declare function authMessage(action: AuthAction, providerId: string, timestamp: number): string;
|
|
24
|
+
/** Sign an auth message with the unlocked wallet and return the gateway headers. */
|
|
25
|
+
export declare function signAuthHeaders(account: {
|
|
26
|
+
privateKey: string;
|
|
27
|
+
}, action: AuthAction, providerId: string): Record<string, string>;
|
|
28
|
+
export declare function registerInferenceMarketplaceTools(server: McpServer): void;
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=inference-marketplace.tools.d.ts.map
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIBTC Inference Marketplace Tools
|
|
3
|
+
*
|
|
4
|
+
* List an OpenAI-compatible model endpoint on the marketplace and manage it, all
|
|
5
|
+
* authorized by a wallet signature (the same wallet that receives sBTC payouts).
|
|
6
|
+
* Ownership is proven by signing a short message with the payout wallet — no
|
|
7
|
+
* accounts, no API keys. These tools sign locally with the unlocked wallet and
|
|
8
|
+
* call the gateway, so an agent can register/update/check in one step.
|
|
9
|
+
*
|
|
10
|
+
* - inference_register_provider: verify + list an endpoint (signed)
|
|
11
|
+
* - inference_update_provider: change name/models/payout/endpoint in place (signed)
|
|
12
|
+
* - inference_reveal_key: reveal or rotate the gateway↔endpoint shared key (signed)
|
|
13
|
+
* - inference_check_provider: re-run the health/functional probe (unsigned)
|
|
14
|
+
* - inference_list_providers: list registered providers + health (unsigned)
|
|
15
|
+
*
|
|
16
|
+
* The gateway verifies the signature recovers to `payoutAddress` (which also
|
|
17
|
+
* enforces the network), that the endpoint is reachable AND serving inference,
|
|
18
|
+
* and that model ids are real, text-generation, commercially-licensed HF repos.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { signMessageHashRsv, privateKeyToPublic, compressPublicKey, } from "@stacks/transactions";
|
|
22
|
+
import { hashMessage } from "@stacks/encryption";
|
|
23
|
+
import { bytesToHex } from "@stacks/common";
|
|
24
|
+
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
25
|
+
import { getWalletManager } from "../services/wallet-manager.js";
|
|
26
|
+
/** Default marketplace gateway; override with the `gateway` arg for local dev. */
|
|
27
|
+
const DEFAULT_GATEWAY = "https://inference.aibtc.com";
|
|
28
|
+
/** Bound network calls so a hung endpoint can't block the tool call forever. */
|
|
29
|
+
const GATEWAY_TIMEOUT_MS = 15_000;
|
|
30
|
+
/** Loopback hosts always allowed for local dev. */
|
|
31
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
32
|
+
/**
|
|
33
|
+
* These tools sign an ownership message with the payout wallet and may forward
|
|
34
|
+
* the endpoint's own `apiKey` — both secrets. If the gateway destination were
|
|
35
|
+
* free-form, a prompt-injected "use gateway https://attacker.example" could
|
|
36
|
+
* redirect that signed request and leak the signature + apiKey. So the override
|
|
37
|
+
* is restricted to localhost and the marketplace (`*.aibtc.com`); any other host
|
|
38
|
+
* requires an explicit `allowUnsafeGateway: true`, which natural language alone
|
|
39
|
+
* won't set.
|
|
40
|
+
*/
|
|
41
|
+
function isAllowedGatewayHost(hostname) {
|
|
42
|
+
const host = hostname.toLowerCase();
|
|
43
|
+
if (LOOPBACK_HOSTS.has(host))
|
|
44
|
+
return true;
|
|
45
|
+
return host === "aibtc.com" || host.endsWith(".aibtc.com");
|
|
46
|
+
}
|
|
47
|
+
/** Validate + normalize the gateway base URL (trailing slash stripped). */
|
|
48
|
+
function resolveGateway(gateway, allowUnsafe) {
|
|
49
|
+
const base = (gateway ?? DEFAULT_GATEWAY).replace(/\/$/, "");
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(base);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`Invalid gateway URL: ${base}`);
|
|
56
|
+
}
|
|
57
|
+
if (!allowUnsafe && !isAllowedGatewayHost(url.hostname)) {
|
|
58
|
+
throw new Error(`Gateway host "${url.hostname}" is not allowed. Wallet-signed headers and your endpoint apiKey ` +
|
|
59
|
+
`are only sent to the marketplace (*.aibtc.com) or localhost. To target another host on ` +
|
|
60
|
+
`purpose, pass allowUnsafeGateway: true.`);
|
|
61
|
+
}
|
|
62
|
+
return base;
|
|
63
|
+
}
|
|
64
|
+
function requireUnlockedWallet() {
|
|
65
|
+
const account = getWalletManager().getActiveAccount();
|
|
66
|
+
if (!account) {
|
|
67
|
+
throw new Error("Wallet is not unlocked. Use wallet_unlock first to enable signing.");
|
|
68
|
+
}
|
|
69
|
+
return account;
|
|
70
|
+
}
|
|
71
|
+
/** The exact message the gateway rebuilds and verifies. Must match byte-for-byte. */
|
|
72
|
+
export function authMessage(action, providerId, timestamp) {
|
|
73
|
+
return `Inference Marketplace\nAction: ${action}\nProvider: ${providerId}\nTimestamp: ${timestamp}`;
|
|
74
|
+
}
|
|
75
|
+
/** Sign an auth message with the unlocked wallet and return the gateway headers. */
|
|
76
|
+
export function signAuthHeaders(account, action, providerId) {
|
|
77
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
78
|
+
const message = authMessage(action, providerId, timestamp);
|
|
79
|
+
const signature = signMessageHashRsv({
|
|
80
|
+
messageHash: bytesToHex(hashMessage(message)),
|
|
81
|
+
privateKey: account.privateKey,
|
|
82
|
+
});
|
|
83
|
+
const publicKey = compressPublicKey(privateKeyToPublic(account.privateKey));
|
|
84
|
+
return {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"X-Stacks-Signature": signature,
|
|
87
|
+
"X-Stacks-Public-Key": publicKey,
|
|
88
|
+
"X-Stacks-Timestamp": String(timestamp),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function gatewayFetch(url, init) {
|
|
92
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS) });
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
let body;
|
|
95
|
+
try {
|
|
96
|
+
body = JSON.parse(text);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
body = { raw: text };
|
|
100
|
+
}
|
|
101
|
+
return { ok: res.ok, status: res.status, body };
|
|
102
|
+
}
|
|
103
|
+
const gatewayArg = z
|
|
104
|
+
.string()
|
|
105
|
+
.url()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe(`Marketplace gateway base URL. Defaults to ${DEFAULT_GATEWAY}. Use http://localhost:8787 for local dev.`);
|
|
108
|
+
const allowUnsafeGatewayArg = z
|
|
109
|
+
.boolean()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Allow a gateway host outside localhost / *.aibtc.com. Required to send wallet-signed headers " +
|
|
112
|
+
"(and any apiKey) to an arbitrary host — leave unset unless you deliberately self-host the gateway.");
|
|
113
|
+
export function registerInferenceMarketplaceTools(server) {
|
|
114
|
+
// Register (verify + list) an endpoint — signed
|
|
115
|
+
server.registerTool("inference_register_provider", {
|
|
116
|
+
description: "List an OpenAI-compatible model endpoint on the AIBTC Inference Marketplace and get paid per request in sBTC. " +
|
|
117
|
+
"Signs a registration message with the unlocked payout wallet (proving ownership) and POSTs it. " +
|
|
118
|
+
"The gateway verifies the signature recovers to payoutAddress, that the endpoint is reachable AND actually " +
|
|
119
|
+
"serving inference, and that model ids are real/commercial Hugging Face repos — then lists it. " +
|
|
120
|
+
"Requires an unlocked wallet on the gateway's network.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
name: z.string().describe("Display name for your node, e.g. \"Alice's Qwen node\"."),
|
|
123
|
+
endpoint: z
|
|
124
|
+
.string()
|
|
125
|
+
.url()
|
|
126
|
+
.describe("OpenAI-compatible base URL, e.g. https://my-host/v1 (must expose /models and /chat/completions)."),
|
|
127
|
+
models: z
|
|
128
|
+
.array(z.string())
|
|
129
|
+
.min(1)
|
|
130
|
+
.describe("Hugging Face repo ids you serve, e.g. [\"Qwen/Qwen2.5-7B-Instruct\"]."),
|
|
131
|
+
payoutAddress: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Stacks address that receives sBTC. Defaults to the unlocked wallet's address (must match the gateway network)."),
|
|
135
|
+
apiKey: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Optional: a key your endpoint requires. Locks the endpoint so only the gateway (which presents it) can call it."),
|
|
139
|
+
gateway: gatewayArg,
|
|
140
|
+
allowUnsafeGateway: allowUnsafeGatewayArg,
|
|
141
|
+
},
|
|
142
|
+
}, async ({ name, endpoint, models, payoutAddress, apiKey, gateway, allowUnsafeGateway }) => {
|
|
143
|
+
try {
|
|
144
|
+
const account = requireUnlockedWallet();
|
|
145
|
+
const base = resolveGateway(gateway, allowUnsafeGateway ?? false);
|
|
146
|
+
const payout = payoutAddress ?? account.address;
|
|
147
|
+
const headers = signAuthHeaders(account, "register", endpoint);
|
|
148
|
+
const { ok, status, body } = await gatewayFetch(`${base}/v1/providers`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers,
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
name,
|
|
153
|
+
endpoint,
|
|
154
|
+
payoutAddress: payout,
|
|
155
|
+
models,
|
|
156
|
+
...(apiKey ? { apiKey } : {}),
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
return createJsonResponse({
|
|
160
|
+
success: ok,
|
|
161
|
+
status,
|
|
162
|
+
payoutAddress: payout,
|
|
163
|
+
gateway: base,
|
|
164
|
+
result: body,
|
|
165
|
+
note: ok
|
|
166
|
+
? "Registered and verified. Your endpoint is listed and callable."
|
|
167
|
+
: "Registration failed — see result.error (e.g. wrong network, endpoint unreachable, or invalid model id).",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return createErrorResponse(error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// Update a listing in place — signed
|
|
175
|
+
server.registerTool("inference_update_provider", {
|
|
176
|
+
description: "Update your marketplace listing in place — name, models, payout address, endpoint, or description — no delete/re-add. " +
|
|
177
|
+
"Signs an update message with the unlocked payout wallet and PATCHes it. Only fields you pass change. " +
|
|
178
|
+
"Changing the endpoint re-verifies reachability before taking effect. Requires an unlocked wallet.",
|
|
179
|
+
inputSchema: {
|
|
180
|
+
providerId: z.string().describe("The provider id returned at registration (from inference_list_providers)."),
|
|
181
|
+
name: z.string().optional().describe("New display name."),
|
|
182
|
+
endpoint: z.string().url().optional().describe("New OpenAI-compatible base URL (re-verified before it takes effect)."),
|
|
183
|
+
models: z.array(z.string()).optional().describe("New Hugging Face repo id list."),
|
|
184
|
+
payoutAddress: z.string().optional().describe("New payout address (must match the gateway network)."),
|
|
185
|
+
description: z.string().optional().describe("New description."),
|
|
186
|
+
apiKey: z.string().optional().describe("Set/replace the key your endpoint requires (locks the endpoint)."),
|
|
187
|
+
gateway: gatewayArg,
|
|
188
|
+
allowUnsafeGateway: allowUnsafeGatewayArg,
|
|
189
|
+
},
|
|
190
|
+
}, async ({ providerId, name, endpoint, models, payoutAddress, description, apiKey, gateway, allowUnsafeGateway }) => {
|
|
191
|
+
try {
|
|
192
|
+
const account = requireUnlockedWallet();
|
|
193
|
+
const base = resolveGateway(gateway, allowUnsafeGateway ?? false);
|
|
194
|
+
const patch = {};
|
|
195
|
+
if (name !== undefined)
|
|
196
|
+
patch.name = name;
|
|
197
|
+
if (endpoint !== undefined)
|
|
198
|
+
patch.endpoint = endpoint;
|
|
199
|
+
if (models !== undefined)
|
|
200
|
+
patch.models = models;
|
|
201
|
+
if (payoutAddress !== undefined)
|
|
202
|
+
patch.payoutAddress = payoutAddress;
|
|
203
|
+
if (description !== undefined)
|
|
204
|
+
patch.description = description;
|
|
205
|
+
if (apiKey !== undefined)
|
|
206
|
+
patch.apiKey = apiKey;
|
|
207
|
+
if (Object.keys(patch).length === 0) {
|
|
208
|
+
throw new Error("Nothing to update — pass at least one of name/endpoint/models/payoutAddress/description/apiKey.");
|
|
209
|
+
}
|
|
210
|
+
const headers = signAuthHeaders(account, "update", providerId);
|
|
211
|
+
const { ok, status, body } = await gatewayFetch(`${base}/v1/providers/${providerId}`, {
|
|
212
|
+
method: "PATCH",
|
|
213
|
+
headers,
|
|
214
|
+
body: JSON.stringify(patch),
|
|
215
|
+
});
|
|
216
|
+
return createJsonResponse({
|
|
217
|
+
success: ok,
|
|
218
|
+
status,
|
|
219
|
+
gateway: base,
|
|
220
|
+
result: body,
|
|
221
|
+
note: ok ? "Listing updated." : "Update failed — see result.error.",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return createErrorResponse(error);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
// Reveal or rotate the shared key — signed
|
|
229
|
+
server.registerTool("inference_reveal_key", {
|
|
230
|
+
description: "Reveal or rotate your provider's shared key — the gateway↔endpoint credential the gateway presents when it " +
|
|
231
|
+
"calls your endpoint (NOT an ownership token; ownership is your wallet). The gateway stores it write-only, so a " +
|
|
232
|
+
"wallet signature is the only way to read it back. Pass rotate=true to issue a new one. Requires an unlocked wallet.",
|
|
233
|
+
inputSchema: {
|
|
234
|
+
providerId: z.string().describe("The provider id (from inference_list_providers)."),
|
|
235
|
+
rotate: z.boolean().optional().describe("true = generate and return a NEW key (invalidates the old one). false/omitted = reveal the current key."),
|
|
236
|
+
gateway: gatewayArg,
|
|
237
|
+
allowUnsafeGateway: allowUnsafeGatewayArg,
|
|
238
|
+
},
|
|
239
|
+
}, async ({ providerId, rotate, gateway, allowUnsafeGateway }) => {
|
|
240
|
+
try {
|
|
241
|
+
const account = requireUnlockedWallet();
|
|
242
|
+
const base = resolveGateway(gateway, allowUnsafeGateway ?? false);
|
|
243
|
+
const headers = signAuthHeaders(account, "reveal-key", providerId);
|
|
244
|
+
const { ok, status, body } = await gatewayFetch(`${base}/v1/providers/${providerId}/key`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers,
|
|
247
|
+
body: JSON.stringify(rotate ? { rotate: true } : {}),
|
|
248
|
+
});
|
|
249
|
+
return createJsonResponse({
|
|
250
|
+
success: ok,
|
|
251
|
+
status,
|
|
252
|
+
gateway: base,
|
|
253
|
+
result: body,
|
|
254
|
+
note: ok
|
|
255
|
+
? "Your endpoint must REQUIRE this key (proxy / Cloudflare Access) for it to prevent freeloading — an open endpoint ignores it."
|
|
256
|
+
: "Reveal/rotate failed — see result.error.",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
return createErrorResponse(error);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
// Re-run the health/functional probe — unsigned
|
|
264
|
+
server.registerTool("inference_check_provider", {
|
|
265
|
+
description: "Re-run the marketplace's health + functional probe for a provider on demand (checks the endpoint is reachable and " +
|
|
266
|
+
"actually returns a completion). No signature required. Useful right after (re)starting your node or tunnel.",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
providerId: z.string().describe("The provider id to check."),
|
|
269
|
+
gateway: gatewayArg,
|
|
270
|
+
allowUnsafeGateway: allowUnsafeGatewayArg,
|
|
271
|
+
},
|
|
272
|
+
}, async ({ providerId, gateway, allowUnsafeGateway }) => {
|
|
273
|
+
try {
|
|
274
|
+
const base = resolveGateway(gateway, allowUnsafeGateway ?? false);
|
|
275
|
+
const { ok, status, body } = await gatewayFetch(`${base}/v1/providers/${providerId}/check`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
});
|
|
278
|
+
return createJsonResponse({ success: ok, status, gateway: base, result: body });
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return createErrorResponse(error);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
// List registered providers — unsigned
|
|
285
|
+
server.registerTool("inference_list_providers", {
|
|
286
|
+
description: "List providers registered on the marketplace with their models, health/status, and flagged state. No signature " +
|
|
287
|
+
"required. Use it to find your provider id (match on payoutAddress) after registering.",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
gateway: gatewayArg,
|
|
290
|
+
allowUnsafeGateway: allowUnsafeGatewayArg,
|
|
291
|
+
},
|
|
292
|
+
}, async ({ gateway, allowUnsafeGateway }) => {
|
|
293
|
+
try {
|
|
294
|
+
const base = resolveGateway(gateway, allowUnsafeGateway ?? false);
|
|
295
|
+
const { ok, status, body } = await gatewayFetch(`${base}/v1/providers`, { method: "GET" });
|
|
296
|
+
return createJsonResponse({ success: ok, status, gateway: base, result: body });
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
return createErrorResponse(error);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=inference-marketplace.tools.js.map
|
package/dist/tools/news.tools.js
CHANGED
|
@@ -463,10 +463,11 @@ Fields:
|
|
|
463
463
|
server.registerTool("news_file_signal", {
|
|
464
464
|
description: `File a signal on a beat at aibtc.news.
|
|
465
465
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
466
|
+
Filing is free — no payment required. Requires an unlocked wallet with a
|
|
467
|
+
P2WPKH (bc1q) BTC address for BIP-322 authentication. If the endpoint ever
|
|
468
|
+
returns a 402 payment challenge, the tool falls back to the x402 sBTC flow:
|
|
469
|
+
1. POST with BIP-322 auth → signal filed (or receive 402 payment challenge)
|
|
470
|
+
2. On 402: build sponsored sBTC transfer (relay pays gas)
|
|
470
471
|
3. Retry with payment proof → signal filed
|
|
471
472
|
|
|
472
473
|
Authentication: BIP-322 signed headers (X-BTC-Address, X-BTC-Signature, X-BTC-Timestamp).
|
|
@@ -3,6 +3,7 @@ import { hexToBytes } from "@stacks/common";
|
|
|
3
3
|
import { getStacksNetwork, getApiBaseUrl } from "../config/networks.js";
|
|
4
4
|
import { getHiroApi } from "../services/hiro-api.js";
|
|
5
5
|
import { resolveDefaultFee } from "../utils/fee.js";
|
|
6
|
+
import { getSpendLimiter } from "../services/spend-limiter.js";
|
|
6
7
|
import { getTrackedNonce, recordNonceUsed, resetTrackedNonce, reconcileWithChain, } from "../services/nonce-tracker.js";
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Pending nonce tracking (unified via SharedNonceTracker, issue #413)
|
|
@@ -82,6 +83,9 @@ export function advancePendingNonce(address, nonce, txid = "") {
|
|
|
82
83
|
* @param fee Optional fee in micro-STX. If omitted, a medium-priority clamped fee is resolved.
|
|
83
84
|
*/
|
|
84
85
|
export async function transferStx(account, recipient, amount, memo, fee) {
|
|
86
|
+
// Safety rail: block before signing if this would exceed the wallet's
|
|
87
|
+
// cumulative spending limit (per-session or per-day).
|
|
88
|
+
await getSpendLimiter().check("ustx", amount, account.address);
|
|
85
89
|
const networkName = getStacksNetwork(account.network);
|
|
86
90
|
const nonce = await getNextNonce(account.address, account.network);
|
|
87
91
|
// Always resolve a clamped fee — prevents @stacks/transactions from over-estimating.
|
|
@@ -103,6 +107,7 @@ export async function transferStx(account, recipient, amount, memo, fee) {
|
|
|
103
107
|
throw new Error(`Broadcast failed: ${broadcastResponse.error} - ${broadcastResponse.reason}`);
|
|
104
108
|
}
|
|
105
109
|
advancePendingNonce(account.address, nonce, broadcastResponse.txid);
|
|
110
|
+
await getSpendLimiter().record("ustx", amount, account.address);
|
|
106
111
|
return {
|
|
107
112
|
txid: broadcastResponse.txid,
|
|
108
113
|
rawTx: transaction.serialize(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibtc/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.61.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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"dev": "tsx src/index.ts",
|
|
15
15
|
"test": "vitest run",
|
|
16
16
|
"test:watch": "vitest",
|
|
17
|
+
"prepare": "git config core.hooksPath >/dev/null 2>&1 || git config core.hooksPath .githooks 2>/dev/null || true",
|
|
17
18
|
"prepublishOnly": "npm run build"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
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.61.0 # x-release-please-version
|
|
8
8
|
npm: "@aibtc/mcp-server"
|
|
9
9
|
github: https://github.com/aibtcdev/aibtc-mcp-server
|
|
10
10
|
---
|
|
@@ -214,6 +214,33 @@ Always probe before executing paid endpoints. Never call `execute_x402_endpoint`
|
|
|
214
214
|
See: [references/stacks-defi.md](references/stacks-defi.md) for endpoint catalog
|
|
215
215
|
See: [references/x402-inbox.md](references/x402-inbox.md) for inbox-specific flow details
|
|
216
216
|
|
|
217
|
+
### Inference Marketplace (earn sBTC serving models)
|
|
218
|
+
|
|
219
|
+
List an OpenAI-compatible model endpoint on the AIBTC Inference Marketplace and
|
|
220
|
+
get paid per request in sBTC. Ownership is proven by a wallet signature — the
|
|
221
|
+
same wallet that receives payouts — so there are no accounts or API keys. The
|
|
222
|
+
tools sign locally with the unlocked wallet and call the gateway, so an agent can
|
|
223
|
+
register and manage a listing in one step.
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
"Register my endpoint https://my-host/v1 serving Qwen/Qwen2.5-7B-Instruct on the inference marketplace"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| Tool | Description | Signed |
|
|
230
|
+
|------|-------------|:------:|
|
|
231
|
+
| `inference_register_provider` | Verify + list an endpoint (name, models, payout, optional apiKey) | ✅ |
|
|
232
|
+
| `inference_update_provider` | Change name/models/payout/endpoint/description/apiKey in place | ✅ |
|
|
233
|
+
| `inference_reveal_key` | Reveal or rotate the gateway↔endpoint shared key (`rotate: true`) | ✅ |
|
|
234
|
+
| `inference_check_provider` | Re-run the health/functional probe | — |
|
|
235
|
+
| `inference_list_providers` | List providers + health (find your id by `payoutAddress`) | — |
|
|
236
|
+
|
|
237
|
+
The gateway verifies the signature recovers to `payoutAddress` (which also fixes
|
|
238
|
+
the network), that the endpoint is reachable AND actually serving inference, and
|
|
239
|
+
that model ids are real, commercially-licensed Hugging Face repos. Requires an
|
|
240
|
+
unlocked wallet on the gateway's network. Every tool takes an optional `gateway`
|
|
241
|
+
arg (defaults to `https://inference.aibtc.com`; use `http://localhost:8787` for
|
|
242
|
+
local dev).
|
|
243
|
+
|
|
217
244
|
### Genesis Lifecycle
|
|
218
245
|
|
|
219
246
|
Agent identity and reputation on Bitcoin and Stacks:
|