@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.
Files changed (39) hide show
  1. package/README.md +4 -3
  2. package/dist/services/bitflow.service.d.ts +1 -1
  3. package/dist/services/bitflow.service.js +5 -17
  4. package/dist/services/btc-spend.d.ts +39 -0
  5. package/dist/services/btc-spend.js +99 -0
  6. package/dist/services/sbtc-deposit.service.d.ts +4 -1
  7. package/dist/services/sbtc-deposit.service.js +7 -4
  8. package/dist/services/sbtc.service.d.ts +2 -1
  9. package/dist/services/sbtc.service.js +7 -2
  10. package/dist/services/spend-limiter.d.ts +35 -3
  11. package/dist/services/spend-limiter.js +70 -9
  12. package/dist/services/tokens.service.js +9 -2
  13. package/dist/services/x402.service.js +5 -5
  14. package/dist/tools/bitcoin.tools.js +2 -6
  15. package/dist/tools/child-inscription.tools.js +3 -2
  16. package/dist/tools/contract.tools.js +18 -3
  17. package/dist/tools/dual-stacking.tools.js +3 -0
  18. package/dist/tools/inbox-x402.tools.js +1 -1
  19. package/dist/tools/jingswap.tools.js +10 -0
  20. package/dist/tools/legion.tools.js +4 -3
  21. package/dist/tools/lightning.tools.js +2 -1
  22. package/dist/tools/ordinals.tools.js +3 -2
  23. package/dist/tools/profiles.d.ts +5 -1
  24. package/dist/tools/profiles.js +5 -2
  25. package/dist/tools/psbt.tools.js +7 -0
  26. package/dist/tools/runes.tools.js +2 -1
  27. package/dist/tools/sbtc.tools.js +1 -1
  28. package/dist/tools/signing.tools.js +10 -0
  29. package/dist/tools/souldinals.tools.js +3 -2
  30. package/dist/tools/stacking-lottery.tools.js +8 -0
  31. package/dist/tools/stacks-market.tools.js +10 -0
  32. package/dist/tools/styx.tools.js +2 -1
  33. package/dist/transactions/builder.d.ts +17 -0
  34. package/dist/transactions/builder.js +26 -13
  35. package/dist/transactions/sponsor-builder.js +25 -4
  36. package/dist/utils/fee.d.ts +41 -2
  37. package/dist/utils/fee.js +15 -1
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +2 -2
@@ -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 any transfers."),
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,
@@ -18,7 +18,7 @@ const INBOX_BASE = "https://aibtc.com/api/inbox";
18
18
  * We budget against `medium_priority`, capped at this ceiling, so the pre-flight
19
19
  * reflects what the transaction will actually cost.
20
20
  */
21
- const MAX_REALISTIC_FEE_USTX = 50000n; // 0.05 STX — generous headroom
21
+ const MAX_REALISTIC_FEE_USTX = 3000n; // 0.003 STX — the sbtc_transfer fee ceiling
22
22
  /**
23
23
  * Pre-flight balance check for a non-sponsored sBTC inbox payment.
24
24
  * Verifies the wallet holds enough sBTC for the message AND enough STX for a
@@ -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.broadcastTransaction(commitSigned.txHex);
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.broadcastTransaction(revealResult.tx.hex);
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.broadcastTransaction(revealSigned.txHex);
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.broadcastTransaction(txResult.txHex);
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.broadcastTransaction(commitSigned.txHex);
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.broadcastTransaction(revealSigned.txHex);
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);
@@ -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
- /** Tools exposed by default, whatever groups are enabled. */
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";
@@ -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
- /** Tools exposed by default, whatever groups are enabled. */
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",
@@ -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.broadcastTransaction(signed.txHex);
448
+ const txid = await meteredBtcBroadcast(mempoolApi, signed.txHex, account, NETWORK);
448
449
  return createJsonResponse({
449
450
  success: true,
450
451
  txid,
@@ -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.broadcastTransaction(commitSigned.txHex);
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.broadcastTransaction(revealTxHex);
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,
@@ -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.broadcastTransaction(tx.hex);
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, totalBoundedSpends } from "../services/spend-limiter.js";
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. Meter the ones this rail has a ledger for and ignore the rest.
125
- // Deliberately before makeContractCall, so an over-cap call never signs.
126
- const meteredSpends = totalBoundedSpends(options.postConditions, account.address);
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: options.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: options.postConditions || [],
118
+ postConditions,
111
119
  sponsored: true,
112
120
  fee: 0n,
113
121
  });
114
- return submitSponsoredTransaction(account, transaction, network, () => callContract(account, options));
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
- return submitSponsoredTransaction(account, transaction, network, () => transferStx(account, recipient, amount, memo));
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.