@aibtc/mcp-server 1.72.0 → 1.73.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 +4 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +0 -1
- 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/spend-limiter.d.ts +35 -3
- package/dist/services/spend-limiter.js +70 -9
- 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/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/package.json +4 -4
- 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
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -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() {
|
|
@@ -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);
|
|
@@ -92,7 +92,9 @@ For typed arguments, use objects like {type: 'uint', value: 100} or {type: 'prin
|
|
|
92
92
|
Post conditions constrain what assets the transaction can move. Each condition is an object:
|
|
93
93
|
- STX: {type: 'stx', principal: 'SP...', conditionCode: 'eq'|'gt'|'gte'|'lt'|'lte', amount: '1000000'}
|
|
94
94
|
- FT: {type: 'ft', principal: 'SP...', asset: 'SP...contract', assetName: 'token-name', conditionCode: 'eq', amount: '1000'}
|
|
95
|
-
- NFT: {type: 'nft', principal: 'SP...', asset: 'SP...contract', assetName: 'nft-name', tokenId: '1', notSend?: boolean}
|
|
95
|
+
- NFT: {type: 'nft', principal: 'SP...', asset: 'SP...contract', assetName: 'nft-name', tokenId: '1', notSend?: boolean}
|
|
96
|
+
|
|
97
|
+
Spending limit: STX and sBTC leaving this wallet are metered against the wallet's spending cap. In 'deny' mode that is read from eq/lt/lte post conditions on this wallet ('gt'/'gte' on this wallet's own STX or sBTC are refused: they set no upper bound). In 'allow' mode, set maxStxSpend / maxSbtcSpend: they are added as on-chain post conditions on this wallet and metered.`,
|
|
96
98
|
inputSchema: z.object({
|
|
97
99
|
contractAddress: z.string().describe("The contract deployer's address (e.g., SP2...)"),
|
|
98
100
|
contractName: z.string().describe("The contract name (e.g., 'my-token')"),
|
|
@@ -104,7 +106,17 @@ Post conditions constrain what assets the transaction can move. Each condition i
|
|
|
104
106
|
postConditionMode: z
|
|
105
107
|
.enum(["allow", "deny"])
|
|
106
108
|
.default("deny")
|
|
107
|
-
.describe("'deny' (default): Blocks unexpected transfers. 'allow': Permits
|
|
109
|
+
.describe("'deny' (default): Blocks unexpected transfers. 'allow': Permits transfers not covered by a post condition, except this wallet's STX/sBTC, which maxStxSpend/maxSbtcSpend bound."),
|
|
110
|
+
maxStxSpend: z
|
|
111
|
+
.string()
|
|
112
|
+
.regex(/^\d+$/)
|
|
113
|
+
.default("0")
|
|
114
|
+
.describe("'allow' mode only: most micro-STX this call may take from this wallet (on-chain post condition, metered). Default 0."),
|
|
115
|
+
maxSbtcSpend: z
|
|
116
|
+
.string()
|
|
117
|
+
.regex(/^\d+$/)
|
|
118
|
+
.default("0")
|
|
119
|
+
.describe("'allow' mode only: most sBTC sats this call may take from this wallet (on-chain post condition, metered). Default 0."),
|
|
108
120
|
postConditions: z
|
|
109
121
|
.array(z.unknown())
|
|
110
122
|
.optional()
|
|
@@ -115,7 +127,7 @@ Post conditions constrain what assets the transaction can move. Each condition i
|
|
|
115
127
|
.describe("Optional fee: 'low' | 'medium' | 'high' preset or micro-STX amount. Clamped to 50,000 uSTX max for contract calls. If omitted, medium-priority fee is auto-resolved. Ignored when sponsored=true."),
|
|
116
128
|
sponsored: sponsoredSchema,
|
|
117
129
|
}),
|
|
118
|
-
}, async ({ contractAddress, contractName, functionName, functionArgs, postConditionMode, postConditions, fee, sponsored }) => {
|
|
130
|
+
}, async ({ contractAddress, contractName, functionName, functionArgs, postConditionMode, maxStxSpend, maxSbtcSpend, postConditions, fee, sponsored }) => {
|
|
119
131
|
try {
|
|
120
132
|
const account = await getAccount();
|
|
121
133
|
const clarityArgs = functionArgs.map(parseArgToClarityValue);
|
|
@@ -130,6 +142,9 @@ Post conditions constrain what assets the transaction can move. Each condition i
|
|
|
130
142
|
functionArgs: clarityArgs,
|
|
131
143
|
postConditionMode: postConditionMode === "allow" ? PostConditionMode.Allow : PostConditionMode.Deny,
|
|
132
144
|
...(parsedPostConditions && { postConditions: parsedPostConditions }),
|
|
145
|
+
...(postConditionMode === "allow" && {
|
|
146
|
+
callerSpendCaps: { ustx: BigInt(maxStxSpend), sats: BigInt(maxSbtcSpend) },
|
|
147
|
+
}),
|
|
133
148
|
};
|
|
134
149
|
let result;
|
|
135
150
|
if (sponsored) {
|
|
@@ -311,6 +311,8 @@ Note: Dual Stacking is only available on mainnet.`,
|
|
|
311
311
|
functionName: "enroll",
|
|
312
312
|
functionArgs: [rewardArg],
|
|
313
313
|
postConditionMode: PostConditionMode.Allow,
|
|
314
|
+
// Caller spend caps (enrolling qualifies by holding sBTC; nothing is transferred).
|
|
315
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
314
316
|
});
|
|
315
317
|
return createJsonResponse({
|
|
316
318
|
success: true,
|
|
@@ -350,6 +352,7 @@ Note: Dual Stacking is only available on mainnet.`,
|
|
|
350
352
|
functionName: "opt-out",
|
|
351
353
|
functionArgs: [],
|
|
352
354
|
postConditionMode: PostConditionMode.Allow,
|
|
355
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
353
356
|
});
|
|
354
357
|
return createJsonResponse({
|
|
355
358
|
success: true,
|
|
@@ -301,6 +301,8 @@ export function registerJingswapTools(server) {
|
|
|
301
301
|
functionName: m.cancelFn,
|
|
302
302
|
functionArgs: [],
|
|
303
303
|
postConditionMode: PostConditionMode.Allow,
|
|
304
|
+
// Caller spend caps (refund).
|
|
305
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
304
306
|
postConditions: [],
|
|
305
307
|
});
|
|
306
308
|
return createJsonResponse({
|
|
@@ -334,6 +336,8 @@ export function registerJingswapTools(server) {
|
|
|
334
336
|
functionName: "cancel-sbtc-deposit",
|
|
335
337
|
functionArgs: [],
|
|
336
338
|
postConditionMode: PostConditionMode.Allow,
|
|
339
|
+
// Caller spend caps (refund).
|
|
340
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
337
341
|
postConditions: [],
|
|
338
342
|
});
|
|
339
343
|
return createJsonResponse({
|
|
@@ -417,6 +421,8 @@ export function registerJingswapTools(server) {
|
|
|
417
421
|
functionName: "settle",
|
|
418
422
|
functionArgs: [],
|
|
419
423
|
postConditionMode: PostConditionMode.Allow,
|
|
424
|
+
// Caller spend caps (distributes to depositors).
|
|
425
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
420
426
|
postConditions: [],
|
|
421
427
|
});
|
|
422
428
|
return createJsonResponse({
|
|
@@ -479,6 +485,8 @@ export function registerJingswapTools(server) {
|
|
|
479
485
|
contractPrincipalCV(PYTH_CONTRACTS.wormhole.address, PYTH_CONTRACTS.wormhole.name),
|
|
480
486
|
],
|
|
481
487
|
postConditionMode: PostConditionMode.Allow,
|
|
488
|
+
// Caller spend caps (the Pyth update fee is ~2 uSTX; 0.001 STX bounds it).
|
|
489
|
+
callerSpendCaps: { ustx: 1000n, sats: 0n },
|
|
482
490
|
postConditions: [],
|
|
483
491
|
});
|
|
484
492
|
return createJsonResponse({
|
|
@@ -519,6 +527,8 @@ export function registerJingswapTools(server) {
|
|
|
519
527
|
functionName: "cancel-cycle",
|
|
520
528
|
functionArgs: [],
|
|
521
529
|
postConditionMode: PostConditionMode.Allow,
|
|
530
|
+
// Caller spend caps (refunds depositors).
|
|
531
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
522
532
|
postConditions: [],
|
|
523
533
|
});
|
|
524
534
|
return createJsonResponse({
|
|
@@ -34,6 +34,7 @@ import { buildCommitTransaction, buildRevealTransaction, deriveRevealScript, } f
|
|
|
34
34
|
import { buildChildCommitTransaction, buildChildRevealTransaction, deriveChildRevealScript, lookupParentInscription, } from "../transactions/child-inscription-builder.js";
|
|
35
35
|
import { signBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
36
36
|
import { createErrorResponse, createJsonResponse } from "../utils/index.js";
|
|
37
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
37
38
|
// Writes always target the live era. A retired contract still answers reads,
|
|
38
39
|
// but proposing or voting into one would govern a deployment nothing watches.
|
|
39
40
|
const [GOV_ADDRESS, GOV_NAME] = LIVE_ERA.gov.split(".");
|
|
@@ -1213,7 +1214,7 @@ export function registerLegionTools(server) {
|
|
|
1213
1214
|
`confirmMainnetSpend: true to proceed, or dryRun: true for the full breakdown.`));
|
|
1214
1215
|
}
|
|
1215
1216
|
const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
|
|
1216
|
-
const commitTxid = await mempoolApi
|
|
1217
|
+
const commitTxid = await meteredBtcBroadcast(mempoolApi, commitSigned.txHex, account, NETWORK);
|
|
1217
1218
|
return createJsonResponse({
|
|
1218
1219
|
status: "commit_broadcast",
|
|
1219
1220
|
bitcoinNetwork: NETWORK,
|
|
@@ -1342,7 +1343,7 @@ export function registerLegionTools(server) {
|
|
|
1342
1343
|
revealResult.tx.sign(account.btcPrivateKey);
|
|
1343
1344
|
revealResult.tx.sign(account.taprootPrivateKey);
|
|
1344
1345
|
revealResult.tx.finalize();
|
|
1345
|
-
revealTxid = await mempoolApi
|
|
1346
|
+
revealTxid = await meteredBtcBroadcast(mempoolApi, revealResult.tx.hex, account, NETWORK);
|
|
1346
1347
|
}
|
|
1347
1348
|
else {
|
|
1348
1349
|
const revealScript = deriveRevealScript({
|
|
@@ -1362,7 +1363,7 @@ export function registerLegionTools(server) {
|
|
|
1362
1363
|
network: NETWORK,
|
|
1363
1364
|
});
|
|
1364
1365
|
const revealSigned = signBtcTransaction(revealResult.tx, account.btcPrivateKey);
|
|
1365
|
-
revealTxid = await mempoolApi
|
|
1366
|
+
revealTxid = await meteredBtcBroadcast(mempoolApi, revealSigned.txHex, account, NETWORK);
|
|
1366
1367
|
}
|
|
1367
1368
|
const inscriptionId = `${revealTxid}i0`;
|
|
1368
1369
|
const link = `${ORDINALS_INSCRIPTION_BASE}${inscriptionId}`;
|
|
@@ -16,6 +16,7 @@ import { MempoolApi, getMempoolTxUrl, } from "../services/mempool-api.js";
|
|
|
16
16
|
import { buildAndSignBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
17
17
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
18
18
|
import { NETWORK } from "../config/networks.js";
|
|
19
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
19
20
|
export function registerLightningTools(server) {
|
|
20
21
|
// --- Wallet lifecycle -----------------------------------------------------
|
|
21
22
|
server.registerTool("lightning_create", {
|
|
@@ -207,7 +208,7 @@ export function registerLightningTools(server) {
|
|
|
207
208
|
senderAddress: account.btcAddress,
|
|
208
209
|
network: NETWORK,
|
|
209
210
|
}, account.btcPrivateKey);
|
|
210
|
-
const txid = await api
|
|
211
|
+
const txid = await meteredBtcBroadcast(api, txResult.txHex, account, NETWORK);
|
|
211
212
|
const response = {
|
|
212
213
|
success: true,
|
|
213
214
|
btcTxid: txid,
|
|
@@ -20,6 +20,7 @@ import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
|
|
|
20
20
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
21
21
|
import { buildCommitTransaction, buildRevealTransaction, deriveRevealScript, } from "../transactions/inscription-builder.js";
|
|
22
22
|
import { signBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
23
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
23
24
|
/**
|
|
24
25
|
* Format inscription data for display
|
|
25
26
|
*/
|
|
@@ -209,7 +210,7 @@ export function registerOrdinalsTools(server) {
|
|
|
209
210
|
network: NETWORK,
|
|
210
211
|
});
|
|
211
212
|
const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
|
|
212
|
-
const commitTxid = await mempoolApi
|
|
213
|
+
const commitTxid = await meteredBtcBroadcast(mempoolApi, commitSigned.txHex, account, NETWORK);
|
|
213
214
|
const commitExplorerUrl = getMempoolTxUrl(commitTxid, NETWORK);
|
|
214
215
|
// Return immediately with commit info
|
|
215
216
|
return createJsonResponse({
|
|
@@ -343,7 +344,7 @@ export function registerOrdinalsTools(server) {
|
|
|
343
344
|
network: NETWORK,
|
|
344
345
|
});
|
|
345
346
|
const revealSigned = signBtcTransaction(revealResult.tx, account.btcPrivateKey);
|
|
346
|
-
const revealTxid = await mempoolApi
|
|
347
|
+
const revealTxid = await meteredBtcBroadcast(mempoolApi, revealSigned.txHex, account, NETWORK);
|
|
347
348
|
// Inscription ID is reveal txid + output index (always 0 for first inscription)
|
|
348
349
|
const inscriptionId = `${revealTxid}i0`;
|
|
349
350
|
const revealExplorerUrl = getMempoolTxUrl(revealTxid, NETWORK);
|
package/dist/tools/profiles.d.ts
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* An unset AIBTC_TOOLS loads every tool: configs written before profiles
|
|
10
10
|
* existed have no AIBTC_TOOLS, and their users keep the tools they had.
|
|
11
11
|
*/
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Tools exposed by default, whatever groups are enabled. wallet_export is
|
|
14
|
+
* deliberately left out: it hands the mnemonic to the agent, which can then
|
|
15
|
+
* sign outside every spending limit (it stays in the `wallet` group).
|
|
16
|
+
*/
|
|
13
17
|
export declare const CORE_TOOLS: ReadonlySet<string>;
|
|
14
18
|
export declare const TOOL_GROUPS: {
|
|
15
19
|
readonly wallet: "Wallet management extras, encrypted credential store";
|
package/dist/tools/profiles.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* An unset AIBTC_TOOLS loads every tool: configs written before profiles
|
|
10
10
|
* existed have no AIBTC_TOOLS, and their users keep the tools they had.
|
|
11
11
|
*/
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Tools exposed by default, whatever groups are enabled. wallet_export is
|
|
14
|
+
* deliberately left out: it hands the mnemonic to the agent, which can then
|
|
15
|
+
* sign outside every spending limit (it stays in the `wallet` group).
|
|
16
|
+
*/
|
|
13
17
|
export const CORE_TOOLS = new Set([
|
|
14
18
|
// Wallet
|
|
15
19
|
"get_wallet_info",
|
|
@@ -18,7 +22,6 @@ export const CORE_TOOLS = new Set([
|
|
|
18
22
|
"wallet_import",
|
|
19
23
|
"wallet_unlock",
|
|
20
24
|
"wallet_lock",
|
|
21
|
-
"wallet_export",
|
|
22
25
|
"wallet_rotate_password",
|
|
23
26
|
// Balances
|
|
24
27
|
"get_stx_balance",
|
package/dist/tools/psbt.tools.js
CHANGED
|
@@ -4,6 +4,8 @@ import { NETWORK } from "../config/networks.js";
|
|
|
4
4
|
import { DUST_THRESHOLD, P2TR_INPUT_BASE_VBYTES, P2WPKH_INPUT_VBYTES, } from "../config/bitcoin-constants.js";
|
|
5
5
|
import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
|
|
6
6
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
7
|
+
import { psbtOutflowSats } from "../services/btc-spend.js";
|
|
8
|
+
import { getSpendLimiter } from "../services/spend-limiter.js";
|
|
7
9
|
import { getBtcNetwork } from "../transactions/bitcoin-builder.js";
|
|
8
10
|
import { createErrorResponse, createJsonResponse } from "../utils/index.js";
|
|
9
11
|
import { estimateBuyPsbtFeeSats, parseOutpoint } from "./psbt.helpers.js";
|
|
@@ -284,6 +286,11 @@ export function registerPsbtTools(server) {
|
|
|
284
286
|
}
|
|
285
287
|
}
|
|
286
288
|
}
|
|
289
|
+
// A signed PSBT can be broadcast by anyone (a marketplace, the
|
|
290
|
+
// counterparty), so the spend is metered here, before it is handed back.
|
|
291
|
+
const outflowSats = psbtOutflowSats(tx, signedInputs, account, NETWORK);
|
|
292
|
+
await getSpendLimiter().check("sats", outflowSats, account.address);
|
|
293
|
+
await getSpendLimiter().record("sats", outflowSats, account.address);
|
|
287
294
|
return createJsonResponse({
|
|
288
295
|
success: signedInputs.length > 0,
|
|
289
296
|
network: NETWORK,
|
|
@@ -28,6 +28,7 @@ import { getWalletManager } from "../services/wallet-manager.js";
|
|
|
28
28
|
import { MempoolApi, getMempoolAddressUrl, getMempoolTxUrl } from "../services/mempool-api.js";
|
|
29
29
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
30
30
|
import { buildRuneTransfer, signRuneTransfer } from "../transactions/rune-transfer-builder.js";
|
|
31
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
// Helpers
|
|
33
34
|
// ---------------------------------------------------------------------------
|
|
@@ -444,7 +445,7 @@ export function registerRunesTools(server) {
|
|
|
444
445
|
network: NETWORK,
|
|
445
446
|
});
|
|
446
447
|
const signed = signRuneTransfer(transferResult.tx, account.taprootPrivateKey, account.btcPrivateKey, transferResult.taprootInputIndices, transferResult.feeInputIndices);
|
|
447
|
-
const txid = await mempoolApi
|
|
448
|
+
const txid = await meteredBtcBroadcast(mempoolApi, signed.txHex, account, NETWORK);
|
|
448
449
|
return createJsonResponse({
|
|
449
450
|
success: true,
|
|
450
451
|
txid,
|
package/dist/tools/sbtc.tools.js
CHANGED
|
@@ -413,7 +413,7 @@ Set includeOrdinals=true to allow spending ordinal UTXOs (advanced users only).`
|
|
|
413
413
|
reclaimPublicKey, resolvedFeeRate, maxSignerFee, reclaimLockTime, account.btcPrivateKey, // Sign with P2WPKH key (inputs are from user's address)
|
|
414
414
|
includeOrdinals);
|
|
415
415
|
// Step 2: Broadcast signed transaction and notify Emily API
|
|
416
|
-
const result = await depositService.broadcastAndNotify(depositResult.txHex, depositResult.depositScript, depositResult.reclaimScript, depositResult.vout);
|
|
416
|
+
const result = await depositService.broadcastAndNotify(account, depositResult.txHex, depositResult.depositScript, depositResult.reclaimScript, depositResult.vout);
|
|
417
417
|
const btcAmount = (amount / 100_000_000).toFixed(8);
|
|
418
418
|
return createJsonResponse({
|
|
419
419
|
success: true,
|
|
@@ -1187,6 +1187,8 @@ export function registerSigningTools(server) {
|
|
|
1187
1187
|
"Use for Taproot script-path spending, multisig coordination, or any case where " +
|
|
1188
1188
|
"you need a BIP-340 Schnorr signature over a pre-computed hash (e.g., BIP-341 sighash). " +
|
|
1189
1189
|
"WARNING: This signs raw digests that cannot be human-verified — use confirmBlindSign=true after reviewing the digest. " +
|
|
1190
|
+
"A signed sighash can authorize a Bitcoin spend the spending limit never sees, so the user must also enable it " +
|
|
1191
|
+
"with AIBTC_ALLOW_BLIND_SIGN=true in this server's config. " +
|
|
1190
1192
|
"Returns a 64-byte signature and the x-only public key. Requires an unlocked wallet.",
|
|
1191
1193
|
inputSchema: z.object({
|
|
1192
1194
|
digest: z
|
|
@@ -1221,6 +1223,14 @@ export function registerSigningTools(server) {
|
|
|
1221
1223
|
"with the same parameters plus confirmBlindSign: true to proceed with signing.",
|
|
1222
1224
|
});
|
|
1223
1225
|
}
|
|
1226
|
+
// A signature over a BIP-341 sighash authorizes a spend that never
|
|
1227
|
+
// passes a metered broadcast, so the agent cannot enable this itself:
|
|
1228
|
+
// the user opts in in the server config.
|
|
1229
|
+
if (process.env.AIBTC_ALLOW_BLIND_SIGN !== "true") {
|
|
1230
|
+
throw new Error("Blind digest signing is disabled: a signed sighash can spend from this wallet outside " +
|
|
1231
|
+
"the spending limit. Ask the user to set AIBTC_ALLOW_BLIND_SIGN=true in this MCP " +
|
|
1232
|
+
"server's config and restart it if they want to allow it.");
|
|
1233
|
+
}
|
|
1224
1234
|
const account = requireUnlockedWallet();
|
|
1225
1235
|
if (!account.taprootPrivateKey || !account.taprootPublicKey) {
|
|
1226
1236
|
throw new Error("Taproot keys not available. Ensure the wallet has Taproot key derivation.");
|
|
@@ -20,6 +20,7 @@ import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
|
|
|
20
20
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
21
21
|
import { signBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
22
22
|
import { buildChildCommitTransaction, buildChildRevealTransaction, deriveChildRevealScript, lookupParentInscription, } from "../transactions/child-inscription-builder.js";
|
|
23
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Constants
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -263,7 +264,7 @@ export function registerSouldinalsTools(server) {
|
|
|
263
264
|
network: NETWORK,
|
|
264
265
|
});
|
|
265
266
|
const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
|
|
266
|
-
const commitTxid = await mempoolApi
|
|
267
|
+
const commitTxid = await meteredBtcBroadcast(mempoolApi, commitSigned.txHex, account, NETWORK);
|
|
267
268
|
const commitExplorerUrl = getMempoolTxUrl(commitTxid, NETWORK);
|
|
268
269
|
return createJsonResponse({
|
|
269
270
|
status: "commit_broadcast",
|
|
@@ -382,7 +383,7 @@ export function registerSouldinalsTools(server) {
|
|
|
382
383
|
revealResult.tx.sign(account.taprootPrivateKey);
|
|
383
384
|
revealResult.tx.finalize();
|
|
384
385
|
const revealTxHex = revealResult.tx.hex;
|
|
385
|
-
const revealTxid = await mempoolApi
|
|
386
|
+
const revealTxid = await meteredBtcBroadcast(mempoolApi, revealTxHex, account, NETWORK);
|
|
386
387
|
const inscriptionId = `${revealTxid}i0`;
|
|
387
388
|
const revealExplorerUrl = getMempoolTxUrl(revealTxid, NETWORK);
|
|
388
389
|
const commitExplorerUrl = getMempoolTxUrl(commitTxid, NETWORK);
|
|
@@ -247,6 +247,8 @@ Note: Stackspot is only available on mainnet.`,
|
|
|
247
247
|
functionName: "join-pot",
|
|
248
248
|
functionArgs: [uintCV(amountBigInt)],
|
|
249
249
|
postConditionMode: PostConditionMode.Allow,
|
|
250
|
+
// Caller spend caps (the pot entry).
|
|
251
|
+
callerSpendCaps: { ustx: amountBigInt, sats: 0n },
|
|
250
252
|
});
|
|
251
253
|
return createJsonResponse({
|
|
252
254
|
success: true,
|
|
@@ -301,6 +303,8 @@ Note: Stackspot is only available on mainnet.`,
|
|
|
301
303
|
contractPrincipalCV(parsed.deployer, parsed.contractName),
|
|
302
304
|
],
|
|
303
305
|
postConditionMode: PostConditionMode.Allow,
|
|
306
|
+
// Caller spend caps (stacks the pot's own STX).
|
|
307
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
304
308
|
});
|
|
305
309
|
return createJsonResponse({
|
|
306
310
|
success: true,
|
|
@@ -356,6 +360,8 @@ Note: Stackspot is only available on mainnet.`,
|
|
|
356
360
|
// are not known client-side until the contract executes, so strict
|
|
357
361
|
// post-conditions cannot be set without an additional read-only query.
|
|
358
362
|
postConditionMode: PostConditionMode.Allow,
|
|
363
|
+
// Caller spend caps (pays out to participants).
|
|
364
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
359
365
|
});
|
|
360
366
|
return createJsonResponse({
|
|
361
367
|
success: true,
|
|
@@ -409,6 +415,8 @@ Note: Stackspot is only available on mainnet.`,
|
|
|
409
415
|
// to contributors on cancel. The amount is not known client-side without
|
|
410
416
|
// an additional read-only query, so strict post-conditions are deferred.
|
|
411
417
|
postConditionMode: PostConditionMode.Allow,
|
|
418
|
+
// Caller spend caps (returns STX to participants).
|
|
419
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
412
420
|
});
|
|
413
421
|
return createJsonResponse({
|
|
414
422
|
success: true,
|
|
@@ -336,6 +336,8 @@ Requires an unlocked wallet. Only available on mainnet.`,
|
|
|
336
336
|
uintCV(BigInt(max_cost_ustx)),
|
|
337
337
|
],
|
|
338
338
|
postConditionMode: PostConditionMode.Allow,
|
|
339
|
+
// Caller spend caps (max_cost_ustx is the most the market may take).
|
|
340
|
+
callerSpendCaps: { ustx: BigInt(max_cost_ustx), sats: 0n },
|
|
339
341
|
});
|
|
340
342
|
return createJsonResponse({
|
|
341
343
|
success: true,
|
|
@@ -396,6 +398,8 @@ Requires an unlocked wallet. Only available on mainnet.`,
|
|
|
396
398
|
uintCV(BigInt(max_cost_ustx)),
|
|
397
399
|
],
|
|
398
400
|
postConditionMode: PostConditionMode.Allow,
|
|
401
|
+
// Caller spend caps (max_cost_ustx is the most the market may take).
|
|
402
|
+
callerSpendCaps: { ustx: BigInt(max_cost_ustx), sats: 0n },
|
|
399
403
|
});
|
|
400
404
|
return createJsonResponse({
|
|
401
405
|
success: true,
|
|
@@ -452,6 +456,8 @@ Requires an unlocked wallet. Only available on mainnet.`,
|
|
|
452
456
|
uintCV(BigInt(min_proceeds_ustx)),
|
|
453
457
|
],
|
|
454
458
|
postConditionMode: PostConditionMode.Allow,
|
|
459
|
+
// Caller spend caps (pays out to the seller).
|
|
460
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
455
461
|
});
|
|
456
462
|
return createJsonResponse({
|
|
457
463
|
success: true,
|
|
@@ -508,6 +514,8 @@ Requires an unlocked wallet. Only available on mainnet.`,
|
|
|
508
514
|
uintCV(BigInt(min_proceeds_ustx)),
|
|
509
515
|
],
|
|
510
516
|
postConditionMode: PostConditionMode.Allow,
|
|
517
|
+
// Caller spend caps (pays out to the seller).
|
|
518
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
511
519
|
});
|
|
512
520
|
return createJsonResponse({
|
|
513
521
|
success: true,
|
|
@@ -553,6 +561,8 @@ Requires an unlocked wallet. Only available on mainnet.`,
|
|
|
553
561
|
functionName: "redeem",
|
|
554
562
|
functionArgs: [uintCV(BigInt(market_id))],
|
|
555
563
|
postConditionMode: PostConditionMode.Allow,
|
|
564
|
+
// Caller spend caps (pays out winnings).
|
|
565
|
+
callerSpendCaps: { ustx: 0n, sats: 0n },
|
|
556
566
|
});
|
|
557
567
|
return createJsonResponse({
|
|
558
568
|
success: true,
|
package/dist/tools/styx.tools.js
CHANGED
|
@@ -25,6 +25,7 @@ import { getWalletManager } from "../services/wallet-manager.js";
|
|
|
25
25
|
import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
|
|
26
26
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
27
27
|
import { getBtcNetwork } from "../transactions/bitcoin-builder.js";
|
|
28
|
+
import { meteredBtcBroadcast } from "../services/btc-spend.js";
|
|
28
29
|
const FEE_PRIORITIES = ["low", "medium", "high"];
|
|
29
30
|
export function registerStyxTools(server) {
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -250,7 +251,7 @@ export function registerStyxTools(server) {
|
|
|
250
251
|
tx.finalize();
|
|
251
252
|
// Step 6: Broadcast
|
|
252
253
|
const mempoolApi = new MempoolApi(NETWORK);
|
|
253
|
-
broadcastTxid = await mempoolApi
|
|
254
|
+
broadcastTxid = await meteredBtcBroadcast(mempoolApi, tx.hex, account, NETWORK);
|
|
254
255
|
// Step 7: Update deposit status (retry once on failure)
|
|
255
256
|
let statusUpdateWarning;
|
|
256
257
|
const statusPayload = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ClarityValue, PostConditionMode, PostCondition } from "@stacks/transactions";
|
|
2
2
|
import { type Network } from "../config/networks.js";
|
|
3
|
+
import { type CallerSpendCaps, type SpendUnit } from "../services/spend-limiter.js";
|
|
3
4
|
import type { WalletAddresses } from "../utils/storage.js";
|
|
4
5
|
/**
|
|
5
6
|
* Reset the pending nonce for an address (called on wallet unlock/lock/switch
|
|
@@ -69,6 +70,12 @@ export interface ContractCallOptions {
|
|
|
69
70
|
functionArgs: ClarityValue[];
|
|
70
71
|
postConditionMode?: PostConditionMode;
|
|
71
72
|
postConditions?: PostCondition[];
|
|
73
|
+
/**
|
|
74
|
+
* Required in post condition mode Allow: the most STX (micro-STX) and sBTC
|
|
75
|
+
* (sats) this call may take from the caller. Enforced on chain as `lte`
|
|
76
|
+
* post conditions on the caller and metered by the spending limit.
|
|
77
|
+
*/
|
|
78
|
+
callerSpendCaps?: CallerSpendCaps;
|
|
72
79
|
/** Optional fee in micro-STX. If omitted, fee is auto-estimated. */
|
|
73
80
|
fee?: bigint;
|
|
74
81
|
}
|
|
@@ -78,6 +85,16 @@ export interface ContractDeployOptions {
|
|
|
78
85
|
/** Optional fee in micro-STX. If omitted, fee is auto-estimated. */
|
|
79
86
|
fee?: bigint;
|
|
80
87
|
}
|
|
88
|
+
/** sBTC token contract id for a network, for caller spend post conditions. */
|
|
89
|
+
export declare function sbtcContract(network: Network): `${string}.${string}`;
|
|
90
|
+
/** Add the transaction fee (micro-STX) to the STX ledger of a spend plan. */
|
|
91
|
+
export declare function withFee(spends: Array<{
|
|
92
|
+
unit: SpendUnit;
|
|
93
|
+
amount: bigint;
|
|
94
|
+
}>, fee: bigint): Array<{
|
|
95
|
+
unit: SpendUnit;
|
|
96
|
+
amount: bigint;
|
|
97
|
+
}>;
|
|
81
98
|
/**
|
|
82
99
|
* Transfer STX tokens to a recipient
|
|
83
100
|
* @param fee Optional fee in micro-STX. If omitted, a medium-priority clamped fee is resolved.
|
|
@@ -3,7 +3,8 @@ 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,
|
|
6
|
+
import { getSpendLimiter, planContractCallSpends, } from "../services/spend-limiter.js";
|
|
7
|
+
import { getContracts } from "../config/contracts.js";
|
|
7
8
|
import { getTrackedNonce, recordNonceUsed, resetTrackedNonce, reconcileWithChain, } from "../services/nonce-tracker.js";
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Pending nonce tracking (unified via SharedNonceTracker, issue #413)
|
|
@@ -78,18 +79,30 @@ export function advancePendingNonce(address, nonce, txid = "") {
|
|
|
78
79
|
console.error(`[nonce] Failed to record nonce ${nonce} for ${address}:`, err);
|
|
79
80
|
});
|
|
80
81
|
}
|
|
82
|
+
/** sBTC token contract id for a network, for caller spend post conditions. */
|
|
83
|
+
export function sbtcContract(network) {
|
|
84
|
+
return getContracts(network).SBTC_TOKEN;
|
|
85
|
+
}
|
|
86
|
+
/** Add the transaction fee (micro-STX) to the STX ledger of a spend plan. */
|
|
87
|
+
export function withFee(spends, fee) {
|
|
88
|
+
const ustx = spends.find((s) => s.unit === "ustx")?.amount ?? 0n;
|
|
89
|
+
return [
|
|
90
|
+
...spends.filter((s) => s.unit !== "ustx"),
|
|
91
|
+
...(ustx + fee > 0n ? [{ unit: "ustx", amount: ustx + fee }] : []),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
81
94
|
/**
|
|
82
95
|
* Transfer STX tokens to a recipient
|
|
83
96
|
* @param fee Optional fee in micro-STX. If omitted, a medium-priority clamped fee is resolved.
|
|
84
97
|
*/
|
|
85
98
|
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);
|
|
89
|
-
const networkName = getStacksNetwork(account.network);
|
|
90
|
-
const nonce = await getNextNonce(account.address, account.network);
|
|
91
99
|
// Always resolve a clamped fee — prevents @stacks/transactions from over-estimating.
|
|
92
100
|
const resolvedFee = fee ?? await resolveDefaultFee(account.network, "token_transfer");
|
|
101
|
+
// Safety rail: block before signing if the amount plus fee would exceed the
|
|
102
|
+
// wallet's cumulative spending limit (per-session or per-day).
|
|
103
|
+
await getSpendLimiter().check("ustx", amount + resolvedFee, account.address);
|
|
104
|
+
const networkName = getStacksNetwork(account.network);
|
|
105
|
+
const nonce = await getNextNonce(account.address, account.network);
|
|
93
106
|
const transaction = await makeSTXTokenTransfer({
|
|
94
107
|
recipient,
|
|
95
108
|
amount,
|
|
@@ -107,7 +120,7 @@ export async function transferStx(account, recipient, amount, memo, fee) {
|
|
|
107
120
|
throw new Error(`Broadcast failed: ${broadcastResponse.error} - ${broadcastResponse.reason}`);
|
|
108
121
|
}
|
|
109
122
|
advancePendingNonce(account.address, nonce, broadcastResponse.txid);
|
|
110
|
-
await getSpendLimiter().record("ustx", amount, account.address);
|
|
123
|
+
await getSpendLimiter().record("ustx", amount + resolvedFee, account.address);
|
|
111
124
|
return {
|
|
112
125
|
txid: broadcastResponse.txid,
|
|
113
126
|
rawTx: transaction.serialize(),
|
|
@@ -117,20 +130,20 @@ export async function transferStx(account, recipient, amount, memo, fee) {
|
|
|
117
130
|
* Call a smart contract function
|
|
118
131
|
*/
|
|
119
132
|
export async function callContract(account, options) {
|
|
133
|
+
// Always resolve a clamped fee — prevents @stacks/transactions from over-estimating.
|
|
134
|
+
const resolvedFee = options.fee ?? await resolveDefaultFee(account.network, "contract_call");
|
|
120
135
|
// Safety rail. Swaps, lending and pot entries all funnel through here, and
|
|
121
136
|
// their amounts are denominated in whatever token the call moves — there is no
|
|
122
137
|
// ledger to bill 1,000 ALEX against. The post conditions are the way in: they
|
|
123
138
|
// already bound, in native units, how much of each asset may leave this
|
|
124
|
-
// address.
|
|
125
|
-
|
|
126
|
-
const meteredSpends =
|
|
139
|
+
// address. Deliberately before makeContractCall, so an over-cap call never signs.
|
|
140
|
+
const { postConditions, spends } = planContractCallSpends({ ...options, sbtcContract: sbtcContract(account.network) }, account.address);
|
|
141
|
+
const meteredSpends = withFee(spends, resolvedFee);
|
|
127
142
|
for (const spend of meteredSpends) {
|
|
128
143
|
await getSpendLimiter().check(spend.unit, spend.amount, account.address);
|
|
129
144
|
}
|
|
130
145
|
const networkName = getStacksNetwork(account.network);
|
|
131
146
|
const nonce = await getNextNonce(account.address, account.network);
|
|
132
|
-
// Always resolve a clamped fee — prevents @stacks/transactions from over-estimating.
|
|
133
|
-
const resolvedFee = options.fee ?? await resolveDefaultFee(account.network, "contract_call");
|
|
134
147
|
const transaction = await makeContractCall({
|
|
135
148
|
contractAddress: options.contractAddress,
|
|
136
149
|
contractName: options.contractName,
|
|
@@ -140,7 +153,7 @@ export async function callContract(account, options) {
|
|
|
140
153
|
network: networkName,
|
|
141
154
|
nonce,
|
|
142
155
|
postConditionMode: options.postConditionMode || PostConditionMode.Deny,
|
|
143
|
-
postConditions
|
|
156
|
+
postConditions,
|
|
144
157
|
fee: resolvedFee,
|
|
145
158
|
});
|
|
146
159
|
const broadcastResponse = await broadcastTransaction({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { makeSTXTokenTransfer, makeContractCall, makeContractDeploy, PostConditionMode, } from "@stacks/transactions";
|
|
2
2
|
import { getStacksNetwork } from "../config/networks.js";
|
|
3
3
|
import { getSponsorRelayUrl, getSponsorApiKey, isFallbackEnabled } from "../config/sponsor.js";
|
|
4
|
-
import { callContract, transferStx, deployContract } from "./builder.js";
|
|
4
|
+
import { callContract, transferStx, deployContract, sbtcContract } from "./builder.js";
|
|
5
|
+
import { getSpendLimiter, planContractCallSpends } from "../services/spend-limiter.js";
|
|
5
6
|
import { recordNonceUsed } from "../services/nonce-tracker.js";
|
|
6
7
|
import { isRelayHealthy } from "../utils/relay-health.js";
|
|
7
8
|
/**
|
|
@@ -99,6 +100,13 @@ async function submitSponsoredTransaction(account, transaction, network, directF
|
|
|
99
100
|
* Falls back to direct submission (sender pays fee) when the relay is unavailable.
|
|
100
101
|
*/
|
|
101
102
|
export async function sponsoredContractCall(account, options, network) {
|
|
103
|
+
// Same rail as callContract: the relay pays the fee, the caller's assets
|
|
104
|
+
// still leave. Checked before signing; recorded only when the relay takes it,
|
|
105
|
+
// because the direct fallback (callContract) meters itself.
|
|
106
|
+
const { postConditions, spends } = planContractCallSpends({ ...options, sbtcContract: sbtcContract(network) }, account.address);
|
|
107
|
+
for (const spend of spends) {
|
|
108
|
+
await getSpendLimiter().check(spend.unit, spend.amount, account.address);
|
|
109
|
+
}
|
|
102
110
|
const transaction = await makeContractCall({
|
|
103
111
|
contractAddress: options.contractAddress,
|
|
104
112
|
contractName: options.contractName,
|
|
@@ -107,17 +115,26 @@ export async function sponsoredContractCall(account, options, network) {
|
|
|
107
115
|
senderKey: account.privateKey,
|
|
108
116
|
network: getStacksNetwork(network),
|
|
109
117
|
postConditionMode: options.postConditionMode || PostConditionMode.Deny,
|
|
110
|
-
postConditions
|
|
118
|
+
postConditions,
|
|
111
119
|
sponsored: true,
|
|
112
120
|
fee: 0n,
|
|
113
121
|
});
|
|
114
|
-
|
|
122
|
+
const result = await submitSponsoredTransaction(account, transaction, network, () => callContract(account, options));
|
|
123
|
+
if (!result.fallback) {
|
|
124
|
+
for (const spend of spends) {
|
|
125
|
+
await getSpendLimiter().record(spend.unit, spend.amount, account.address);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
115
129
|
}
|
|
116
130
|
/**
|
|
117
131
|
* Build and submit a sponsored STX transfer via the relay.
|
|
118
132
|
* Falls back to direct submission (sender pays fee) when the relay is unavailable.
|
|
119
133
|
*/
|
|
120
134
|
export async function sponsoredStxTransfer(account, recipient, amount, memo, network) {
|
|
135
|
+
// Checked before signing; recorded only when the relay takes it, because
|
|
136
|
+
// the direct fallback (transferStx) meters itself.
|
|
137
|
+
await getSpendLimiter().check("ustx", amount, account.address);
|
|
121
138
|
const transaction = await makeSTXTokenTransfer({
|
|
122
139
|
recipient,
|
|
123
140
|
amount,
|
|
@@ -127,7 +144,11 @@ export async function sponsoredStxTransfer(account, recipient, amount, memo, net
|
|
|
127
144
|
sponsored: true,
|
|
128
145
|
fee: 0n,
|
|
129
146
|
});
|
|
130
|
-
|
|
147
|
+
const result = await submitSponsoredTransaction(account, transaction, network, () => transferStx(account, recipient, amount, memo));
|
|
148
|
+
if (!result.fallback) {
|
|
149
|
+
await getSpendLimiter().record("ustx", amount, account.address);
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
131
152
|
}
|
|
132
153
|
/**
|
|
133
154
|
* Build and submit a sponsored contract deploy via the relay.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibtc/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.73.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",
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
-
"start": "node dist/index.js",
|
|
14
|
-
"dev": "tsx src/index.ts",
|
|
13
|
+
"start": "node --env-file-if-exists=.env dist/index.js",
|
|
14
|
+
"dev": "tsx --env-file-if-exists=.env src/index.ts",
|
|
15
15
|
"test": "vitest run",
|
|
16
16
|
"test:watch": "vitest",
|
|
17
17
|
"prepare": "git config core.hooksPath >/dev/null 2>&1 || git config core.hooksPath .githooks 2>/dev/null || true",
|
|
@@ -67,7 +67,6 @@
|
|
|
67
67
|
"@stacks/wallet-sdk": "^7.2.0",
|
|
68
68
|
"alex-sdk": "^3.2.1",
|
|
69
69
|
"axios": "^1.17.0",
|
|
70
|
-
"dotenv": "^17.3.1",
|
|
71
70
|
"light-bolt11-decoder": "^3.2.0",
|
|
72
71
|
"micro-ordinals": "^0.3.0",
|
|
73
72
|
"nostr-tools": "^2.23.3",
|
|
@@ -81,6 +80,7 @@
|
|
|
81
80
|
"devDependencies": {
|
|
82
81
|
"@types/node": "^22.19.13",
|
|
83
82
|
"@types/ws": "^8.18.1",
|
|
83
|
+
"dotenv": "^17.4.2",
|
|
84
84
|
"postcss": "^8.5.18",
|
|
85
85
|
"tsx": "^4.21.0",
|
|
86
86
|
"typescript": "^5.9.3",
|
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.73.0 # x-release-please-version
|
|
8
8
|
npm: "@aibtc/mcp-server"
|
|
9
9
|
github: https://github.com/aibtcdev/aibtc-mcp-server
|
|
10
10
|
---
|
|
@@ -121,7 +121,7 @@ Wallets are stored encrypted at `~/.aibtc/`.
|
|
|
121
121
|
| `btc_sign_message` | Sign plain text with Bitcoin key (BIP-137/BIP-322) | `message`, `addressType` (optional) |
|
|
122
122
|
| `btc_verify_message` | Verify Bitcoin message signature (auto-detects BIP-137/BIP-322) | `message`, `signature`, `address` (optional) |
|
|
123
123
|
| `nostr_sign_event` | Sign a Nostr event (NIP-01) with BIP-340 Schnorr using NIP-06 derived key by default (m/44'/1237'/0'/0/0) | `kind`, `content`, `tags` (optional), `created_at` (optional), `keySource` (`"nostr"` default via NIP-06 path, `"taproot"` for P2TR key, `"segwit"` for P2WPKH key) |
|
|
124
|
-
| `schnorr_sign_digest` | Sign a raw 32-byte digest with Schnorr/BIP-340 | `digest`, `confirmBlindSign`, `auxRand` (optional) |
|
|
124
|
+
| `schnorr_sign_digest` | Sign a raw 32-byte digest with Schnorr/BIP-340 (off unless the user sets `AIBTC_ALLOW_BLIND_SIGN=true`) | `digest`, `confirmBlindSign`, `auxRand` (optional) |
|
|
125
125
|
| `schnorr_verify_digest` | Verify a BIP-340 Schnorr signature over a digest | `digest`, `signature`, `publicKey` |
|
|
126
126
|
|
|
127
127
|
## Units and Addresses
|