@aibtc/mcp-server 1.72.1 → 1.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/services/bitflow.service.d.ts +1 -1
- package/dist/services/bitflow.service.js +5 -17
- package/dist/services/btc-spend.d.ts +39 -0
- package/dist/services/btc-spend.js +99 -0
- package/dist/services/sbtc-deposit.service.d.ts +4 -1
- package/dist/services/sbtc-deposit.service.js +7 -4
- package/dist/services/sbtc.service.d.ts +2 -1
- package/dist/services/sbtc.service.js +7 -2
- package/dist/services/spend-limiter.d.ts +35 -3
- package/dist/services/spend-limiter.js +70 -9
- package/dist/services/tokens.service.js +9 -2
- package/dist/services/x402.service.js +5 -5
- package/dist/tools/bitcoin.tools.js +2 -6
- package/dist/tools/child-inscription.tools.js +3 -2
- package/dist/tools/contract.tools.js +18 -3
- package/dist/tools/dual-stacking.tools.js +3 -0
- package/dist/tools/inbox-x402.tools.js +1 -1
- package/dist/tools/jingswap.tools.js +10 -0
- package/dist/tools/legion.tools.js +4 -3
- package/dist/tools/lightning.tools.js +2 -1
- package/dist/tools/ordinals.tools.js +3 -2
- package/dist/tools/profiles.d.ts +5 -1
- package/dist/tools/profiles.js +5 -2
- package/dist/tools/psbt.tools.js +7 -0
- package/dist/tools/runes.tools.js +2 -1
- package/dist/tools/sbtc.tools.js +1 -1
- package/dist/tools/signing.tools.js +10 -0
- package/dist/tools/souldinals.tools.js +3 -2
- package/dist/tools/stacking-lottery.tools.js +8 -0
- package/dist/tools/stacks-market.tools.js +10 -0
- package/dist/tools/styx.tools.js +2 -1
- package/dist/transactions/builder.d.ts +17 -0
- package/dist/transactions/builder.js +26 -13
- package/dist/transactions/sponsor-builder.js +25 -4
- package/dist/utils/fee.d.ts +41 -2
- package/dist/utils/fee.js +15 -1
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ npx @aibtc/mcp-server@latest --install --cursor --testnet # Cursor, testnet
|
|
|
118
118
|
|
|
119
119
|
### Tool Profiles
|
|
120
120
|
|
|
121
|
-
Every tool definition is loaded into the model's context, so `--install` writes `AIBTC_TOOLS=core`: a lean core of
|
|
121
|
+
Every tool definition is loaded into the model's context, so `--install` writes `AIBTC_TOOLS=core`: a lean core of 24 tools: wallet (including `wallet_rotate_password`; `wallet_export` is in the `wallet` group so the mnemonic isn't one call away), balances, STX/BTC/sBTC transfers, x402 (`list_x402_endpoints`, `probe_x402_endpoint`, `execute_x402_endpoint`) and earning (`earning_opportunities`, `bounty_list/get/submit`, `identity_register`).
|
|
122
122
|
|
|
123
123
|
Add groups with `AIBTC_TOOLS=core,defi,ordinals` in the server's `env` (the core is always included), or load everything with `AIBTC_TOOLS=all` / `--profile full`. If `AIBTC_TOOLS` is not set at all, every tool is loaded, so configs written by earlier versions keep all their tools:
|
|
124
124
|
|
|
@@ -584,12 +584,13 @@ Or use any SIP-010 token by contract ID: `SP2X...::token-name`
|
|
|
584
584
|
| `CLIENT_MNEMONIC` | (Optional) Pre-configured mnemonic | - |
|
|
585
585
|
| `HIRO_API_KEY` | (Optional) Hiro API key for higher rate limits | - |
|
|
586
586
|
| `SPEND_LIMIT_ENABLED` | Set `false` to disable the wallet spending limit | `true` |
|
|
587
|
-
| `SPEND_LIMIT_DAILY_USTX` / `SPEND_LIMIT_SESSION_USTX` | STX spend cap per day / per unlock (micro-STX) | `
|
|
587
|
+
| `SPEND_LIMIT_DAILY_USTX` / `SPEND_LIMIT_SESSION_USTX` | STX spend cap per day / per unlock (micro-STX) | `50000000` (50 STX) |
|
|
588
588
|
| `SPEND_LIMIT_DAILY_SATS` / `SPEND_LIMIT_SESSION_SATS` | BTC spend cap per day / per unlock (sats) | `50000` |
|
|
589
|
+
| `AIBTC_ALLOW_BLIND_SIGN` | Allow `schnorr_sign_digest` to sign raw digests (a signed sighash spends outside the spending limit) | off |
|
|
589
590
|
|
|
590
591
|
**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 also `mainnet`.
|
|
591
592
|
|
|
592
|
-
**Note on spending limits:** A default-on safety rail meters every
|
|
593
|
+
**Note on spending limits:** A default-on safety rail meters every STX, sBTC and BTC spend (transfers, contract calls, sponsored transactions, x402/L402 auto-payments, Bitcoin broadcasts, `psbt_sign`) 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 before it is signed or broadcast, and the agent is told to ask you. Raise the caps via the env vars above, or disable with `SPEND_LIMIT_ENABLED=false`. The limit runs inside the server: it does not contain an agent with shell access to your machine, so keep spend tools out of your client's auto-approve list. See [SECURITY.md](SECURITY.md#limit-blast-radius).
|
|
593
594
|
|
|
594
595
|
**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).
|
|
595
596
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type QuoteResult, type SelectedSwapRoute, KeeperType } from "@bitflowlabs/core-sdk";
|
|
2
2
|
import { type Network } from "../config/index.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type Account, type TransferResult } from "../transactions/builder.js";
|
|
4
4
|
export interface BitflowTicker {
|
|
5
5
|
ticker_id: string;
|
|
6
6
|
base_currency: string;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { BitflowSDK, KeeperType, } from "@bitflowlabs/core-sdk";
|
|
3
|
-
import {
|
|
4
|
-
import { STACKS_MAINNET, STACKS_TESTNET } from "@stacks/network";
|
|
3
|
+
import { PostConditionMode, hexToCV, cvToJSON, } from "@stacks/transactions";
|
|
5
4
|
import { getBitflowConfig, BITFLOW_PUBLIC_API, } from "../config/index.js";
|
|
5
|
+
import { callContract } from "../transactions/builder.js";
|
|
6
6
|
// ============================================================================
|
|
7
7
|
// Bitflow Service
|
|
8
8
|
// ============================================================================
|
|
@@ -305,29 +305,17 @@ export class BitflowService {
|
|
|
305
305
|
tokenYDecimals: quoteResult.bestRoute.tokenYDecimals,
|
|
306
306
|
};
|
|
307
307
|
const swapParams = await sdk.getSwapParams(swapExecutionData, account.address, slippageTolerance);
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
// Through the shared builder so the swap's post conditions are metered by
|
|
309
|
+
// the spending limit like every other contract call.
|
|
310
|
+
return callContract(account, {
|
|
310
311
|
contractAddress: swapParams.contractAddress,
|
|
311
312
|
contractName: swapParams.contractName,
|
|
312
313
|
functionName: swapParams.functionName,
|
|
313
314
|
functionArgs: swapParams.functionArgs,
|
|
314
315
|
postConditions: swapParams.postConditions,
|
|
315
|
-
senderKey: account.privateKey,
|
|
316
|
-
network,
|
|
317
316
|
postConditionMode: PostConditionMode.Deny,
|
|
318
317
|
...(fee !== undefined && { fee }),
|
|
319
318
|
});
|
|
320
|
-
const broadcastResult = await broadcastTransaction({
|
|
321
|
-
transaction,
|
|
322
|
-
network,
|
|
323
|
-
});
|
|
324
|
-
if ("error" in broadcastResult) {
|
|
325
|
-
throw new Error(`Broadcast failed: ${broadcastResult.error} - ${broadcastResult.reason}`);
|
|
326
|
-
}
|
|
327
|
-
return {
|
|
328
|
-
txid: broadcastResult.txid,
|
|
329
|
-
rawTx: transaction.serialize(),
|
|
330
|
-
};
|
|
331
319
|
}
|
|
332
320
|
// ==========================================================================
|
|
333
321
|
// Keeper Functions (public endpoints)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spending-limit metering for Bitcoin L1 transactions.
|
|
3
|
+
*
|
|
4
|
+
* Every tool that signs a spend from this wallet's Bitcoin keys meters the
|
|
5
|
+
* sats that actually leave it: the value of the inputs it owns minus the
|
|
6
|
+
* outputs that come back to its own SegWit / Taproot addresses. That counts
|
|
7
|
+
* the payment, the fee and anything else the transaction sends away, whatever
|
|
8
|
+
* builder produced it.
|
|
9
|
+
*/
|
|
10
|
+
import * as btc from "@scure/btc-signer";
|
|
11
|
+
import type { MempoolApi } from "./mempool-api.js";
|
|
12
|
+
import type { Network } from "../config/networks.js";
|
|
13
|
+
/** The wallet a Bitcoin spend is billed to. */
|
|
14
|
+
export interface BtcSpender {
|
|
15
|
+
/** Stacks address: the spend limiter's ledger key, shared with STX/sBTC spends. */
|
|
16
|
+
address: string;
|
|
17
|
+
btcAddress?: string;
|
|
18
|
+
taprootAddress?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sats a signed raw transaction takes out of the wallet. Input values come
|
|
22
|
+
* from the previous transactions (mempool or chain), so this holds for any
|
|
23
|
+
* builder and for inputs the wallet does not own.
|
|
24
|
+
*/
|
|
25
|
+
export declare function btcTxOutflowSats(api: MempoolApi, txHex: string, spender: BtcSpender, network: Network): Promise<bigint>;
|
|
26
|
+
/**
|
|
27
|
+
* Sats the inputs this wallet just signed in a PSBT take out of it. Metered
|
|
28
|
+
* at signing, because a signed PSBT can be broadcast by anyone. Every signed
|
|
29
|
+
* input must carry its prevout amount (witnessUtxo); one without it cannot be
|
|
30
|
+
* metered, so signing is refused.
|
|
31
|
+
*/
|
|
32
|
+
export declare function psbtOutflowSats(tx: btc.Transaction, signedInputs: number[], spender: BtcSpender, network: Network): bigint;
|
|
33
|
+
/**
|
|
34
|
+
* Broadcast a signed transaction that spends from this wallet, metered by the
|
|
35
|
+
* spending limit: refused before broadcast when it would exceed the cap,
|
|
36
|
+
* recorded once broadcast.
|
|
37
|
+
*/
|
|
38
|
+
export declare function meteredBtcBroadcast(api: MempoolApi, txHex: string, spender: BtcSpender, network: Network): Promise<string>;
|
|
39
|
+
//# sourceMappingURL=btc-spend.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spending-limit metering for Bitcoin L1 transactions.
|
|
3
|
+
*
|
|
4
|
+
* Every tool that signs a spend from this wallet's Bitcoin keys meters the
|
|
5
|
+
* sats that actually leave it: the value of the inputs it owns minus the
|
|
6
|
+
* outputs that come back to its own SegWit / Taproot addresses. That counts
|
|
7
|
+
* the payment, the fee and anything else the transaction sends away, whatever
|
|
8
|
+
* builder produced it.
|
|
9
|
+
*/
|
|
10
|
+
import * as btc from "@scure/btc-signer";
|
|
11
|
+
import { hex } from "@scure/base";
|
|
12
|
+
import { getBtcNetwork } from "../transactions/bitcoin-builder.js";
|
|
13
|
+
import { getSpendLimiter } from "./spend-limiter.js";
|
|
14
|
+
function ownAddresses(spender) {
|
|
15
|
+
return [spender.btcAddress, spender.taprootAddress].filter((a) => !!a);
|
|
16
|
+
}
|
|
17
|
+
function ownScripts(spender, network) {
|
|
18
|
+
const decoder = btc.Address(getBtcNetwork(network));
|
|
19
|
+
return ownAddresses(spender).map((a) => hex.encode(btc.OutScript.encode(decoder.decode(a))));
|
|
20
|
+
}
|
|
21
|
+
/** Sats paid back to the wallet by the transaction's outputs. */
|
|
22
|
+
function ownOutputSats(tx, spender, network) {
|
|
23
|
+
const own = new Set(ownScripts(spender, network));
|
|
24
|
+
let total = 0n;
|
|
25
|
+
for (let i = 0; i < tx.outputsLength; i++) {
|
|
26
|
+
const out = tx.getOutput(i);
|
|
27
|
+
if (out.script && out.amount !== undefined && own.has(hex.encode(out.script))) {
|
|
28
|
+
total += out.amount;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return total;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sats a signed raw transaction takes out of the wallet. Input values come
|
|
35
|
+
* from the previous transactions (mempool or chain), so this holds for any
|
|
36
|
+
* builder and for inputs the wallet does not own.
|
|
37
|
+
*/
|
|
38
|
+
export async function btcTxOutflowSats(api, txHex, spender, network) {
|
|
39
|
+
const tx = btc.Transaction.fromRaw(hex.decode(txHex), {
|
|
40
|
+
allowUnknownOutputs: true,
|
|
41
|
+
allowUnknownInputs: true,
|
|
42
|
+
});
|
|
43
|
+
const own = new Set(ownAddresses(spender));
|
|
44
|
+
const prevTxs = new Map();
|
|
45
|
+
let ownIn = 0n;
|
|
46
|
+
for (let i = 0; i < tx.inputsLength; i++) {
|
|
47
|
+
const input = tx.getInput(i);
|
|
48
|
+
if (!input.txid || input.index === undefined) {
|
|
49
|
+
throw new Error(`Cannot meter Bitcoin spend: input ${i} has no outpoint.`);
|
|
50
|
+
}
|
|
51
|
+
const txid = hex.encode(input.txid);
|
|
52
|
+
let prev = prevTxs.get(txid);
|
|
53
|
+
if (!prev) {
|
|
54
|
+
prev = await api.getTx(txid);
|
|
55
|
+
prevTxs.set(txid, prev);
|
|
56
|
+
}
|
|
57
|
+
const prevOut = prev.vout[input.index];
|
|
58
|
+
if (!prevOut) {
|
|
59
|
+
throw new Error(`Cannot meter Bitcoin spend: ${txid}:${input.index} does not exist.`);
|
|
60
|
+
}
|
|
61
|
+
if (prevOut.scriptpubkey_address && own.has(prevOut.scriptpubkey_address)) {
|
|
62
|
+
ownIn += BigInt(prevOut.value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const outflow = ownIn - ownOutputSats(tx, spender, network);
|
|
66
|
+
return outflow > 0n ? outflow : 0n;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Sats the inputs this wallet just signed in a PSBT take out of it. Metered
|
|
70
|
+
* at signing, because a signed PSBT can be broadcast by anyone. Every signed
|
|
71
|
+
* input must carry its prevout amount (witnessUtxo); one without it cannot be
|
|
72
|
+
* metered, so signing is refused.
|
|
73
|
+
*/
|
|
74
|
+
export function psbtOutflowSats(tx, signedInputs, spender, network) {
|
|
75
|
+
let ownIn = 0n;
|
|
76
|
+
for (const idx of signedInputs) {
|
|
77
|
+
const amount = tx.getInput(idx).witnessUtxo?.amount;
|
|
78
|
+
if (amount === undefined) {
|
|
79
|
+
throw new Error(`Cannot meter input ${idx}: it has no witnessUtxo amount, so the spending limit ` +
|
|
80
|
+
"cannot tell what it spends. Refusing to sign.");
|
|
81
|
+
}
|
|
82
|
+
ownIn += amount;
|
|
83
|
+
}
|
|
84
|
+
const outflow = ownIn - ownOutputSats(tx, spender, network);
|
|
85
|
+
return outflow > 0n ? outflow : 0n;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Broadcast a signed transaction that spends from this wallet, metered by the
|
|
89
|
+
* spending limit: refused before broadcast when it would exceed the cap,
|
|
90
|
+
* recorded once broadcast.
|
|
91
|
+
*/
|
|
92
|
+
export async function meteredBtcBroadcast(api, txHex, spender, network) {
|
|
93
|
+
const amount = await btcTxOutflowSats(api, txHex, spender, network);
|
|
94
|
+
await getSpendLimiter().check("sats", amount, spender.address);
|
|
95
|
+
const txid = await api.broadcastTransaction(txHex);
|
|
96
|
+
await getSpendLimiter().record("sats", amount, spender.address);
|
|
97
|
+
return txid;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=btc-spend.js.map
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*
|
|
37
37
|
* // Step 4: Broadcast and notify
|
|
38
38
|
* const { txid, notification } = await service.broadcastAndNotify(
|
|
39
|
+
* { address: stacksAddress, btcAddress: bitcoinAddress }, // billed by the spending limit
|
|
39
40
|
* signedTxHex,
|
|
40
41
|
* depositResult.depositScript,
|
|
41
42
|
* depositResult.reclaimScript,
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
* ```
|
|
61
62
|
*/
|
|
62
63
|
import type { Network } from "../config/networks.js";
|
|
64
|
+
import { type BtcSpender } from "./btc-spend.js";
|
|
63
65
|
/**
|
|
64
66
|
* Result from generating a deposit address
|
|
65
67
|
*/
|
|
@@ -193,13 +195,14 @@ export declare class SbtcDepositService {
|
|
|
193
195
|
* 1. Broadcasts the signed Bitcoin transaction to the mempool
|
|
194
196
|
* 2. Notifies the Emily API about the deposit (required for sBTC minting)
|
|
195
197
|
*
|
|
198
|
+
* @param spender - Wallet the deposit is billed to by the spending limit
|
|
196
199
|
* @param signedTxHex - Signed transaction hex string
|
|
197
200
|
* @param depositScript - Deposit script hex (from buildDepositTransaction)
|
|
198
201
|
* @param reclaimScript - Reclaim script hex (from buildDepositTransaction)
|
|
199
202
|
* @param vout - Output index containing the deposit (default: 0)
|
|
200
203
|
* @returns Transaction ID and notification response
|
|
201
204
|
*/
|
|
202
|
-
broadcastAndNotify(signedTxHex: string, depositScript: string, reclaimScript: string, vout?: number): Promise<{
|
|
205
|
+
broadcastAndNotify(spender: BtcSpender, signedTxHex: string, depositScript: string, reclaimScript: string, vout?: number): Promise<{
|
|
203
206
|
txid: string;
|
|
204
207
|
notification: unknown;
|
|
205
208
|
}>;
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*
|
|
37
37
|
* // Step 4: Broadcast and notify
|
|
38
38
|
* const { txid, notification } = await service.broadcastAndNotify(
|
|
39
|
+
* { address: stacksAddress, btcAddress: bitcoinAddress }, // billed by the spending limit
|
|
39
40
|
* signedTxHex,
|
|
40
41
|
* depositResult.depositScript,
|
|
41
42
|
* depositResult.reclaimScript,
|
|
@@ -63,6 +64,7 @@ import { SbtcApiClientMainnet, SbtcApiClientTestnet, MAINNET, TESTNET, buildSbtc
|
|
|
63
64
|
import { getContracts, parseContractId } from "../config/contracts.js";
|
|
64
65
|
import { MempoolApi } from "./mempool-api.js";
|
|
65
66
|
import { UnisatIndexer } from "./unisat-indexer.js";
|
|
67
|
+
import { meteredBtcBroadcast } from "./btc-spend.js";
|
|
66
68
|
/**
|
|
67
69
|
* sBTC Deposit Service
|
|
68
70
|
*/
|
|
@@ -257,15 +259,16 @@ export class SbtcDepositService {
|
|
|
257
259
|
* 1. Broadcasts the signed Bitcoin transaction to the mempool
|
|
258
260
|
* 2. Notifies the Emily API about the deposit (required for sBTC minting)
|
|
259
261
|
*
|
|
262
|
+
* @param spender - Wallet the deposit is billed to by the spending limit
|
|
260
263
|
* @param signedTxHex - Signed transaction hex string
|
|
261
264
|
* @param depositScript - Deposit script hex (from buildDepositTransaction)
|
|
262
265
|
* @param reclaimScript - Reclaim script hex (from buildDepositTransaction)
|
|
263
266
|
* @param vout - Output index containing the deposit (default: 0)
|
|
264
267
|
* @returns Transaction ID and notification response
|
|
265
268
|
*/
|
|
266
|
-
async broadcastAndNotify(signedTxHex, depositScript, reclaimScript, vout) {
|
|
267
|
-
// Broadcast transaction to Bitcoin network
|
|
268
|
-
const txid = await this.mempoolApi.
|
|
269
|
+
async broadcastAndNotify(spender, signedTxHex, depositScript, reclaimScript, vout) {
|
|
270
|
+
// Broadcast transaction to Bitcoin network, metered by the spending limit
|
|
271
|
+
const txid = await meteredBtcBroadcast(this.mempoolApi, signedTxHex, spender, this.network);
|
|
269
272
|
// Notify Emily API about the deposit
|
|
270
273
|
const notification = await this.apiClient.notifySbtc({
|
|
271
274
|
depositScript,
|
|
@@ -354,7 +357,7 @@ export class SbtcDepositService {
|
|
|
354
357
|
// Step 2: Sign transaction via callback
|
|
355
358
|
const signedTxHex = await signTransaction(depositResult.txHex);
|
|
356
359
|
// Step 3: Broadcast and notify
|
|
357
|
-
return await this.broadcastAndNotify(signedTxHex, depositResult.depositScript, depositResult.reclaimScript, depositResult.vout);
|
|
360
|
+
return await this.broadcastAndNotify({ address: stacksAddress, btcAddress: bitcoinAddress }, signedTxHex, depositResult.depositScript, depositResult.reclaimScript, depositResult.vout);
|
|
358
361
|
}
|
|
359
362
|
}
|
|
360
363
|
/**
|
|
@@ -41,7 +41,8 @@ export declare class SbtcService {
|
|
|
41
41
|
getBalance(address: string): Promise<SbtcBalance>;
|
|
42
42
|
/**
|
|
43
43
|
* Transfer sBTC to a recipient
|
|
44
|
-
* @param fee Optional fee in micro-STX. If omitted,
|
|
44
|
+
* @param fee Optional fee in micro-STX. If omitted, the sbtc_transfer clamp
|
|
45
|
+
* is used rather than callContract's contract_call default.
|
|
45
46
|
*/
|
|
46
47
|
transfer(account: Account, recipient: string, amount: bigint, memo?: string, fee?: bigint, sponsored?: boolean): Promise<TransferResult>;
|
|
47
48
|
/**
|
|
@@ -4,6 +4,7 @@ import { getContracts, parseContractId } from "../config/index.js";
|
|
|
4
4
|
import { callContract } from "../transactions/builder.js";
|
|
5
5
|
import { sponsoredContractCall } from "../transactions/sponsor-builder.js";
|
|
6
6
|
import { createFungiblePostCondition } from "../transactions/post-conditions.js";
|
|
7
|
+
import { resolveDefaultFee } from "../utils/fee.js";
|
|
7
8
|
// ============================================================================
|
|
8
9
|
// sBTC Service
|
|
9
10
|
// ============================================================================
|
|
@@ -33,7 +34,8 @@ export class SbtcService {
|
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
36
|
* Transfer sBTC to a recipient
|
|
36
|
-
* @param fee Optional fee in micro-STX. If omitted,
|
|
37
|
+
* @param fee Optional fee in micro-STX. If omitted, the sbtc_transfer clamp
|
|
38
|
+
* is used rather than callContract's contract_call default.
|
|
37
39
|
*/
|
|
38
40
|
async transfer(account, recipient, amount, memo, fee, sponsored) {
|
|
39
41
|
const sbtcContract = this.contracts.SBTC_TOKEN;
|
|
@@ -57,7 +59,10 @@ export class SbtcService {
|
|
|
57
59
|
if (sponsored) {
|
|
58
60
|
return sponsoredContractCall(account, contractCallOptions, this.network);
|
|
59
61
|
}
|
|
60
|
-
return callContract(account,
|
|
62
|
+
return callContract(account, {
|
|
63
|
+
...contractCallOptions,
|
|
64
|
+
fee: fee ?? (await resolveDefaultFee(this.network, "sbtc_transfer")),
|
|
65
|
+
});
|
|
61
66
|
}
|
|
62
67
|
/**
|
|
63
68
|
* Get sBTC deposit instructions
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* wallet unlock/lock/switch.
|
|
17
17
|
*
|
|
18
18
|
* Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
|
|
19
|
-
* surfaces the remaining budget
|
|
20
|
-
*
|
|
19
|
+
* surfaces the remaining budget. Raising the cap is the user's call, made in
|
|
20
|
+
* the MCP client config; the message tells the agent to ask, never how to
|
|
21
|
+
* switch the rail off.
|
|
21
22
|
*
|
|
22
23
|
* Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
|
|
23
24
|
* SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
|
|
24
25
|
*/
|
|
25
|
-
import type
|
|
26
|
+
import { PostConditionMode, type PostCondition } from "@stacks/transactions";
|
|
26
27
|
export type SpendUnit = "ustx" | "sats";
|
|
27
28
|
/**
|
|
28
29
|
* Read spend ceilings out of a contract call's post conditions.
|
|
@@ -52,6 +53,37 @@ export declare function totalBoundedSpends(postConditions: PostCondition[] | und
|
|
|
52
53
|
unit: SpendUnit;
|
|
53
54
|
amount: bigint;
|
|
54
55
|
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Ceilings a contract call in post-condition mode Allow declares for the
|
|
58
|
+
* caller's own STX and sBTC. Allow lets the contract move assets that no post
|
|
59
|
+
* condition names, so the caller's outflow is only bounded if the call itself
|
|
60
|
+
* says so: callContract turns these into chain-enforced `lte` post conditions
|
|
61
|
+
* on the caller and meters them.
|
|
62
|
+
*/
|
|
63
|
+
export interface CallerSpendCaps {
|
|
64
|
+
ustx: bigint;
|
|
65
|
+
sats: bigint;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Work out what a contract call may spend, and the post conditions that make
|
|
69
|
+
* the chain enforce it. Throws when the call's caller-owned STX/sBTC outflow
|
|
70
|
+
* has no upper bound, because the rail could not meter it:
|
|
71
|
+
* - Deny mode with a `gt`/`gte` condition on the caller's STX or sBTC (a floor
|
|
72
|
+
* that permits any larger amount);
|
|
73
|
+
* - Allow mode without `callerSpendCaps`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planContractCallSpends(options: {
|
|
76
|
+
postConditionMode?: PostConditionMode;
|
|
77
|
+
postConditions?: PostCondition[];
|
|
78
|
+
callerSpendCaps?: CallerSpendCaps;
|
|
79
|
+
sbtcContract: `${string}.${string}`;
|
|
80
|
+
}, accountAddress: string): {
|
|
81
|
+
postConditions: PostCondition[];
|
|
82
|
+
spends: Array<{
|
|
83
|
+
unit: SpendUnit;
|
|
84
|
+
amount: bigint;
|
|
85
|
+
}>;
|
|
86
|
+
};
|
|
55
87
|
export declare class SpendLimitError extends Error {
|
|
56
88
|
readonly unit: SpendUnit;
|
|
57
89
|
readonly attempted: number;
|
|
@@ -1,3 +1,29 @@
|
|
|
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, and contract calls carrying a bounded caller-owned post condition)
|
|
9
|
+
* routes through it, and the limiter blocks once cumulative outflow would
|
|
10
|
+
* exceed a per-session OR per-day cap.
|
|
11
|
+
*
|
|
12
|
+
* Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
|
|
13
|
+
* (`sats`); there is no cross-asset USD normalization. Daily totals are
|
|
14
|
+
* persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
|
|
15
|
+
* the cap survives process restarts; session totals live in memory and reset on
|
|
16
|
+
* wallet unlock/lock/switch.
|
|
17
|
+
*
|
|
18
|
+
* Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
|
|
19
|
+
* surfaces the remaining budget. Raising the cap is the user's call, made in
|
|
20
|
+
* the MCP client config; the message tells the agent to ask, never how to
|
|
21
|
+
* switch the rail off.
|
|
22
|
+
*
|
|
23
|
+
* Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
|
|
24
|
+
* SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
|
|
25
|
+
*/
|
|
26
|
+
import { Pc, PostConditionMode } from "@stacks/transactions";
|
|
1
27
|
import { promises as fs } from "fs";
|
|
2
28
|
import os from "os";
|
|
3
29
|
import path from "path";
|
|
@@ -59,6 +85,41 @@ export function totalBoundedSpends(postConditions, accountAddress) {
|
|
|
59
85
|
}
|
|
60
86
|
return [...totals].map(([unit, amount]) => ({ unit, amount }));
|
|
61
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Work out what a contract call may spend, and the post conditions that make
|
|
90
|
+
* the chain enforce it. Throws when the call's caller-owned STX/sBTC outflow
|
|
91
|
+
* has no upper bound, because the rail could not meter it:
|
|
92
|
+
* - Deny mode with a `gt`/`gte` condition on the caller's STX or sBTC (a floor
|
|
93
|
+
* that permits any larger amount);
|
|
94
|
+
* - Allow mode without `callerSpendCaps`.
|
|
95
|
+
*/
|
|
96
|
+
export function planContractCallSpends(options, accountAddress) {
|
|
97
|
+
const postConditions = [...(options.postConditions ?? [])];
|
|
98
|
+
for (const condition of postConditions) {
|
|
99
|
+
const pc = condition;
|
|
100
|
+
if (pc.address !== accountAddress)
|
|
101
|
+
continue;
|
|
102
|
+
if (pc.condition !== "gt" && pc.condition !== "gte")
|
|
103
|
+
continue;
|
|
104
|
+
const metered = pc.type === "stx-postcondition" ||
|
|
105
|
+
(pc.type === "ft-postcondition" && pc.asset !== undefined && SBTC_ASSETS.has(pc.asset));
|
|
106
|
+
if (metered) {
|
|
107
|
+
throw new Error(`A "${pc.condition}" post condition on this wallet's own ${pc.type === "stx-postcondition" ? "STX" : "sBTC"} ` +
|
|
108
|
+
"sets no upper bound, so the spending limit cannot meter it. Use eq, lt or lte.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const allow = options.postConditionMode === PostConditionMode.Allow;
|
|
112
|
+
if (allow) {
|
|
113
|
+
if (!options.callerSpendCaps) {
|
|
114
|
+
throw new Error("Post condition mode Allow lets the contract move any asset this wallet holds, " +
|
|
115
|
+
"so the call must declare the most STX and sBTC it may spend (callerSpendCaps).");
|
|
116
|
+
}
|
|
117
|
+
const { ustx, sats } = options.callerSpendCaps;
|
|
118
|
+
const caller = Pc.principal(accountAddress);
|
|
119
|
+
postConditions.push(caller.willSendLte(ustx).ustx(), caller.willSendLte(sats).ft(options.sbtcContract, "sbtc-token"));
|
|
120
|
+
}
|
|
121
|
+
return { postConditions, spends: totalBoundedSpends(postConditions, accountAddress) };
|
|
122
|
+
}
|
|
62
123
|
const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
|
|
63
124
|
const DEFAULT_STATE_FILE = path.join(STORAGE_DIR, "spend-state.json");
|
|
64
125
|
/**
|
|
@@ -80,13 +141,12 @@ function parseLimit(envName, fallback) {
|
|
|
80
141
|
function isEnabled() {
|
|
81
142
|
return process.env.SPEND_LIMIT_ENABLED !== "false";
|
|
82
143
|
}
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// <=0.02 STX / 100 sats). Raise per wallet via env.
|
|
144
|
+
// Defaults: tight enough to stop a drain, generous for the micro-payment use
|
|
145
|
+
// case (x402 endpoints cost <=0.02 STX / 100 sats). Raise per wallet via env.
|
|
86
146
|
const DEFAULTS = {
|
|
87
|
-
dailyUstx:
|
|
88
|
-
sessionUstx:
|
|
89
|
-
dailySats: 50_000,
|
|
147
|
+
dailyUstx: 50_000_000, // 50 STX
|
|
148
|
+
sessionUstx: 50_000_000, // 50 STX
|
|
149
|
+
dailySats: 50_000,
|
|
90
150
|
sessionSats: 50_000,
|
|
91
151
|
};
|
|
92
152
|
function capsFor(unit) {
|
|
@@ -264,10 +324,11 @@ class SpendLimiter {
|
|
|
264
324
|
? "SPEND_LIMIT_DAILY_SATS"
|
|
265
325
|
: "SPEND_LIMIT_SESSION_SATS";
|
|
266
326
|
const label = unitLabel(unit);
|
|
267
|
-
return (`Spending limit reached: this ${attempted} ${label}
|
|
327
|
+
return (`Spending limit reached: this ${attempted} ${label} spend would exceed the ` +
|
|
268
328
|
`per-${scope} cap of ${cap} ${label} (${remaining} ${label} remaining). ` +
|
|
269
|
-
`This is a safety rail against draining the wallet.
|
|
270
|
-
`
|
|
329
|
+
`This is a safety rail against draining the wallet. Do not try to work around it: ` +
|
|
330
|
+
`ask the user whether to proceed. Only the user can raise the cap, by setting ` +
|
|
331
|
+
`${envVar} in this MCP server's config and restarting it.`);
|
|
271
332
|
}
|
|
272
333
|
}
|
|
273
334
|
export function getSpendLimiter() {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { uintCV, principalCV, noneCV, someCV, bufferCV } from "@stacks/transactions";
|
|
2
2
|
import { getHiroApi } from "./hiro-api.js";
|
|
3
|
-
import { parseContractId, getWellKnownTokens } from "../config/index.js";
|
|
3
|
+
import { parseContractId, getWellKnownTokens, getContracts } from "../config/index.js";
|
|
4
4
|
import { callContract } from "../transactions/builder.js";
|
|
5
5
|
import { createFungiblePostCondition } from "../transactions/post-conditions.js";
|
|
6
|
+
import { resolveDefaultFee } from "../utils/fee.js";
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// Tokens Service (SIP-010)
|
|
8
9
|
// ============================================================================
|
|
@@ -102,7 +103,13 @@ export class TokensService {
|
|
|
102
103
|
functionName: "transfer",
|
|
103
104
|
functionArgs,
|
|
104
105
|
postConditions: [postCondition],
|
|
105
|
-
|
|
106
|
+
// A plain sBTC transfer gets the sbtc_transfer clamp, not the
|
|
107
|
+
// contract_call default that callContract would apply.
|
|
108
|
+
...(fee !== undefined
|
|
109
|
+
? { fee }
|
|
110
|
+
: contractId === getContracts(this.network).SBTC_TOKEN && {
|
|
111
|
+
fee: await resolveDefaultFee(this.network, "sbtc_transfer"),
|
|
112
|
+
}),
|
|
106
113
|
});
|
|
107
114
|
}
|
|
108
115
|
/**
|
|
@@ -705,10 +705,10 @@ export async function createApiClient(baseUrl, options) {
|
|
|
705
705
|
.willSendEq(amount)
|
|
706
706
|
.ft(contracts.SBTC_TOKEN, "sbtc-token"),
|
|
707
707
|
],
|
|
708
|
-
// Clamped
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
fee: await resolveDefaultFee(acct.network, "
|
|
708
|
+
// Clamped fee — do NOT let @stacks auto-estimate, since Hiro's
|
|
709
|
+
// contract_call tiers are polluted by outliers (observed >2000
|
|
710
|
+
// STX). The sbtc_transfer clamp caps it at 0.003 STX.
|
|
711
|
+
fee: await resolveDefaultFee(acct.network, "sbtc_transfer"),
|
|
712
712
|
});
|
|
713
713
|
}
|
|
714
714
|
else {
|
|
@@ -1062,7 +1062,7 @@ export async function checkSufficientBalance(account, amount, asset, sponsored =
|
|
|
1062
1062
|
const stxBalanceForSbtc = BigInt(stxInfoForSbtc.balance);
|
|
1063
1063
|
// Same clamped fee the interceptor actually sets on the sBTC contract
|
|
1064
1064
|
// call — so this check is exact, not a high-priority over-estimate.
|
|
1065
|
-
const estimatedSbtcFee = await resolveDefaultFee(account.network, "
|
|
1065
|
+
const estimatedSbtcFee = await resolveDefaultFee(account.network, "sbtc_transfer");
|
|
1066
1066
|
if (stxBalanceForSbtc < estimatedSbtcFee) {
|
|
1067
1067
|
const stxShortfall = estimatedSbtcFee - stxBalanceForSbtc;
|
|
1068
1068
|
throw new InsufficientBalanceError(`Insufficient STX balance to cover sBTC transfer fee: need ${formatStx(estimatedSbtcFee.toString())} estimated fee, ` +
|
|
@@ -14,10 +14,10 @@ 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";
|
|
18
17
|
import { MempoolApi, getMempoolAddressUrl, getMempoolTxUrl, } from "../services/mempool-api.js";
|
|
19
18
|
import { buildAndSignBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
20
19
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
20
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
21
21
|
/**
|
|
22
22
|
* Get the Bitcoin address to use for queries.
|
|
23
23
|
* Prefers the provided address, falls back to wallet's btcAddress.
|
|
@@ -252,9 +252,6 @@ export function registerBitcoinTools(server) {
|
|
|
252
252
|
if (!account) {
|
|
253
253
|
throw new Error("Wallet is not unlocked. Use wallet_unlock first to enable transactions.");
|
|
254
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);
|
|
258
255
|
if (!account.btcAddress || !account.btcPrivateKey || !account.btcPublicKey) {
|
|
259
256
|
throw new Error("Bitcoin keys not available. Please unlock your wallet again.");
|
|
260
257
|
}
|
|
@@ -311,8 +308,7 @@ export function registerBitcoinTools(server) {
|
|
|
311
308
|
network: NETWORK,
|
|
312
309
|
}, account.btcPrivateKey);
|
|
313
310
|
// Broadcast the transaction
|
|
314
|
-
const txid = await api
|
|
315
|
-
await getSpendLimiter().record("sats", BigInt(amount), account.address);
|
|
311
|
+
const txid = await meteredBtcBroadcast(api, txResult.txHex, account, NETWORK);
|
|
316
312
|
const response = {
|
|
317
313
|
success: true,
|
|
318
314
|
txid,
|
|
@@ -18,6 +18,7 @@ import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
|
|
|
18
18
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
19
19
|
import { signBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
20
20
|
import { buildChildCommitTransaction, buildChildRevealTransaction, deriveChildRevealScript, lookupParentInscription, } from "../transactions/child-inscription-builder.js";
|
|
21
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
21
22
|
export function registerChildInscriptionTools(server) {
|
|
22
23
|
// Estimate child inscription fee
|
|
23
24
|
server.registerTool("estimate_child_inscription_fee", {
|
|
@@ -168,7 +169,7 @@ export function registerChildInscriptionTools(server) {
|
|
|
168
169
|
network: NETWORK,
|
|
169
170
|
});
|
|
170
171
|
const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
|
|
171
|
-
const commitTxid = await mempoolApi
|
|
172
|
+
const commitTxid = await meteredBtcBroadcast(mempoolApi, commitSigned.txHex, account, NETWORK);
|
|
172
173
|
const commitExplorerUrl = getMempoolTxUrl(commitTxid, NETWORK);
|
|
173
174
|
return createJsonResponse({
|
|
174
175
|
status: "commit_broadcast",
|
|
@@ -306,7 +307,7 @@ export function registerChildInscriptionTools(server) {
|
|
|
306
307
|
revealResult.tx.sign(account.taprootPrivateKey);
|
|
307
308
|
revealResult.tx.finalize();
|
|
308
309
|
const revealTxHex = revealResult.tx.hex;
|
|
309
|
-
const revealTxid = await mempoolApi
|
|
310
|
+
const revealTxid = await meteredBtcBroadcast(mempoolApi, revealTxHex, account, NETWORK);
|
|
310
311
|
const inscriptionId = `${revealTxid}i0`;
|
|
311
312
|
const revealExplorerUrl = getMempoolTxUrl(revealTxid, NETWORK);
|
|
312
313
|
const commitExplorerUrl = getMempoolTxUrl(commitTxid, NETWORK);
|