@aibtc/mcp-server 1.74.0 → 1.76.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.
@@ -13,7 +13,7 @@
13
13
  * Mirrors the yield-dashboard skill (aibtcdev/skills/yield-dashboard/).
14
14
  */
15
15
  import { z } from "zod";
16
- import { contractPrincipalCV, standardPrincipalCV, uintCV, hexToCV, cvToValue, } from "@stacks/transactions";
16
+ import { contractPrincipalCV, standardPrincipalCV, uintCV, hexToCV, ClarityType, } from "@stacks/transactions";
17
17
  import { NETWORK } from "../services/x402.service.js";
18
18
  import { getWalletAddress } from "../services/x402.service.js";
19
19
  import { getHiroApi } from "../services/hiro-api.js";
@@ -40,30 +40,30 @@ const ALEX_TOKEN_X_NAME = "token-wstx-v2";
40
40
  const ALEX_TOKEN_Y_ADDRESS = "SP2XD7417HGPRTREMKF748VNEQPDRR0RMANB7X1NK";
41
41
  const ALEX_TOKEN_Y_NAME = "token-abtc";
42
42
  const ALEX_FACTOR = 100_000_000;
43
- // Bitflow public API
44
- const BITFLOW_API = "https://app.bitflow.finance/api";
45
43
  // Mainnet Hiro API base URL (direct — not network-switched, this skill is mainnet-only)
46
44
  const MAINNET_HIRO_API = "https://api.hiro.so";
47
45
  // ============================================================================
48
46
  // Helpers
49
47
  // ============================================================================
50
- function decodeTupleField(result, field) {
51
- try {
52
- const hex = result.startsWith("0x") ? result.slice(2) : result;
53
- const cv = hexToCV(hex);
54
- const decoded = cvToValue(cv, true);
55
- const val = decoded[field];
56
- if (val === undefined || val === null)
57
- return null;
58
- if (typeof val === "bigint")
59
- return val;
60
- if (typeof val === "number")
61
- return BigInt(val);
62
- return null;
48
+ /**
49
+ * Decode a read-only result that is `(ok (tuple ...))` or a bare tuple and
50
+ * return its fields as ClarityValues.
51
+ */
52
+ function decodeTuple(result) {
53
+ let cv = hexToCV(result.startsWith("0x") ? result.slice(2) : result);
54
+ if (cv.type === ClarityType.ResponseOk)
55
+ cv = cv.value;
56
+ if (cv.type !== ClarityType.Tuple) {
57
+ throw new Error(`Expected a tuple result, got ${cv.type}`);
63
58
  }
64
- catch {
65
- return null;
59
+ return cv.value;
60
+ }
61
+ function uintField(fields, name) {
62
+ const cv = fields[name];
63
+ if (!cv || cv.type !== ClarityType.UInt) {
64
+ throw new Error(`Missing uint field "${name}"`);
66
65
  }
66
+ return BigInt(cv.value);
67
67
  }
68
68
  function formatBtc(sats) {
69
69
  return (sats / 1e8).toFixed(8);
@@ -80,7 +80,7 @@ async function readZestPosition(walletAddress) {
80
80
  asset: "sBTC",
81
81
  valueSats: 0,
82
82
  valueUnit: "sats",
83
- apyPct: 0,
83
+ apyPct: null,
84
84
  riskScore: 20,
85
85
  details: {},
86
86
  };
@@ -88,43 +88,28 @@ async function readZestPosition(walletAddress) {
88
88
  const hiro = getHiroApi("mainnet");
89
89
  const sbtcPrincipal = contractPrincipalCV(SBTC_TOKEN_ADDRESS, SBTC_TOKEN_NAME);
90
90
  const res = await hiro.callReadOnlyFunction(ZEST_CONTRACT_ID, "get-reserve-state", [sbtcPrincipal], ZEST_POOL_CONTRACT);
91
- if (res.okay && res.result) {
92
- const liquidityRate = decodeTupleField(res.result, "current-liquidity-rate");
93
- if (liquidityRate !== null && liquidityRate > 0n) {
94
- // Ray units: 1e27 = 100%
95
- pos.apyPct = Number(liquidityRate) / 1e25;
96
- }
97
- const borrowsStable = decodeTupleField(res.result, "total-borrows-stable") ?? 0n;
98
- const borrowsVariable = decodeTupleField(res.result, "total-borrows-variable") ?? 0n;
99
- pos.details.totalBorrows = (borrowsStable + borrowsVariable).toString();
100
- // Try to get user a-token balance
101
- try {
102
- const hex = res.result.startsWith("0x") ? res.result.slice(2) : res.result;
103
- const cv = hexToCV(hex);
104
- const decoded = cvToValue(cv, true);
105
- const aTokenAddr = decoded["a-token-address"];
106
- if (aTokenAddr && typeof aTokenAddr === "string" && aTokenAddr.includes(".")) {
107
- const [aTokContract, aTokName] = aTokenAddr.split(".");
108
- const balRes = await hiro.callReadOnlyFunction(`${aTokContract}.${aTokName}`, "ft-get-balance", [standardPrincipalCV(walletAddress)], aTokContract);
109
- if (balRes.okay && balRes.result) {
110
- const balHex = balRes.result.startsWith("0x")
111
- ? balRes.result.slice(2)
112
- : balRes.result;
113
- const balCv = hexToCV(balHex);
114
- const balance = cvToValue(balCv, true);
115
- pos.valueSats =
116
- typeof balance === "bigint"
117
- ? Number(balance)
118
- : typeof balance === "number"
119
- ? balance
120
- : 0;
121
- }
122
- }
123
- }
124
- catch {
125
- // Position read failed — APY still valid
126
- }
91
+ if (!res.okay || !res.result) {
92
+ throw new Error(`get-reserve-state failed: ${res.cause ?? "no result"}`);
93
+ }
94
+ const reserve = decodeTuple(res.result);
95
+ // Zest v1 rates are 8-decimal fixed point (one-8 = 1e8 = 100%)
96
+ pos.apyPct = Number(uintField(reserve, "current-liquidity-rate")) / 1e6;
97
+ pos.details.totalBorrows = (uintField(reserve, "total-borrows-stable") +
98
+ uintField(reserve, "total-borrows-variable")).toString();
99
+ const aToken = reserve["a-token-address"];
100
+ if (aToken?.type !== ClarityType.PrincipalContract) {
101
+ throw new Error("get-reserve-state returned no a-token contract");
127
102
  }
103
+ const [aTokContract] = aToken.value.split(".");
104
+ const balRes = await hiro.callReadOnlyFunction(aToken.value, "get-balance", [standardPrincipalCV(walletAddress)], aTokContract);
105
+ if (!balRes.okay || !balRes.result) {
106
+ throw new Error(`${aToken.value} get-balance failed: ${balRes.cause ?? "no result"}`);
107
+ }
108
+ const balCv = hexToCV(balRes.result.slice(2));
109
+ if (balCv.type !== ClarityType.ResponseOk || balCv.value.type !== ClarityType.UInt) {
110
+ throw new Error(`${aToken.value} get-balance returned an unexpected value`);
111
+ }
112
+ pos.valueSats = Number(balCv.value.value);
128
113
  }
129
114
  catch (e) {
130
115
  pos.details.error = String(e);
@@ -137,7 +122,7 @@ async function readAlexPosition(_walletAddress) {
137
122
  asset: "aBTC/STX LP",
138
123
  valueSats: 0,
139
124
  valueUnit: "sats",
140
- apyPct: 0,
125
+ apyPct: null,
141
126
  riskScore: 50,
142
127
  details: {},
143
128
  };
@@ -149,15 +134,14 @@ async function readAlexPosition(_walletAddress) {
149
134
  uintCV(ALEX_FACTOR),
150
135
  ], ALEX_CONTRACT);
151
136
  if (res.okay && res.result) {
152
- const balX = decodeTupleField(res.result, "balance-x") ?? 0n;
153
- const balY = decodeTupleField(res.result, "balance-y") ?? 0n;
154
- const totalSupply = decodeTupleField(res.result, "total-supply") ?? 0n;
137
+ const pool = decodeTuple(res.result);
138
+ const balX = uintField(pool, "balance-x");
139
+ const balY = uintField(pool, "balance-y");
140
+ const totalSupply = uintField(pool, "total-supply");
155
141
  pos.details.poolBalanceX = balX.toString();
156
142
  pos.details.poolBalanceY = balY.toString();
157
143
  pos.details.poolTotalSupply = totalSupply.toString();
158
- // ALEX typical LP APY estimate from fee revenue
159
- pos.apyPct = 3.5;
160
- pos.details.apySource = "static estimate, not live";
144
+ pos.details.apySource = "unavailable: no live APY source for ALEX AMM v2";
161
145
  pos.details.note =
162
146
  "ALEX AMM v2 does not expose user LP positions via read-only calls. " +
163
147
  `Pool total supply: ${totalSupply.toString()} units. ` +
@@ -176,39 +160,13 @@ async function readBitflowPosition(_walletAddress) {
176
160
  asset: "sBTC",
177
161
  valueSats: 0,
178
162
  valueUnit: "sats",
179
- apyPct: 0,
163
+ apyPct: null,
180
164
  riskScore: 35,
181
165
  details: {},
182
166
  };
183
- try {
184
- const res = await fetch(`${BITFLOW_API}/pools`, {
185
- headers: { Accept: "application/json" },
186
- });
187
- if (res.ok) {
188
- const pools = (await res.json());
189
- const sbtcPool = pools.find((p) => (p.token0 && p.token0.toLowerCase().includes("sbtc")) ||
190
- (p.token1 && p.token1.toLowerCase().includes("sbtc")));
191
- if (sbtcPool) {
192
- pos.apyPct = sbtcPool.apy ?? 2.8;
193
- pos.details.tvl = sbtcPool.tvl;
194
- pos.details.pool = sbtcPool;
195
- }
196
- else {
197
- pos.apyPct = 2.8;
198
- pos.details.apySource = "fallback estimate";
199
- }
200
- }
201
- else {
202
- pos.apyPct = 2.8;
203
- pos.details.apySource = "fallback estimate (API unavailable)";
204
- }
205
- }
206
- catch (e) {
207
- pos.apyPct = 2.8;
208
- pos.details.error = String(e);
209
- pos.details.apySource = "fallback estimate (API unavailable)";
210
- }
211
- // Bitflow LP position reading requires on-chain query (not yet implemented)
167
+ // Bitflow's public pools API (app.bitflow.finance/api/pools) is gone and the
168
+ // SDK gateway exposes no APY/TVL, so there is no live rate to report.
169
+ pos.details.apySource = "unavailable: Bitflow publishes no pool APY endpoint";
212
170
  return pos;
213
171
  }
214
172
  async function readStackingPosition(walletAddress) {
@@ -217,7 +175,7 @@ async function readStackingPosition(walletAddress) {
217
175
  asset: "STX",
218
176
  valueSats: 0,
219
177
  valueUnit: "microSTX",
220
- apyPct: 0,
178
+ apyPct: null,
221
179
  riskScore: 10,
222
180
  details: {},
223
181
  };
@@ -225,8 +183,7 @@ async function readStackingPosition(walletAddress) {
225
183
  const info = await getStackingService("mainnet").getStakerInfo(walletAddress);
226
184
  if (info) {
227
185
  pos.valueSats = Number(info.amountUstx);
228
- pos.apyPct = 8.0;
229
- pos.details.apySource = "static estimate, not live";
186
+ pos.details.apySource = "unavailable: pox-5 rewards are sBTC paid per signer manager";
230
187
  pos.details.stakerInfo = {
231
188
  signerManager: info.signerManager,
232
189
  firstRewardCycle: info.firstRewardCycle,
@@ -278,7 +235,8 @@ per-protocol breakdown.
278
235
  Read-only. Mainnet-only. Requires an unlocked wallet for address context.
279
236
 
280
237
  Note: ALEX LP and Bitflow LP position values are 0 — these protocols do not
281
- expose user LP positions via read-only calls. APY figures are still returned.`,
238
+ expose user LP positions via read-only calls. Only Zest has a live APY; ALEX,
239
+ Bitflow and Stacking report apyPct: null.`,
282
240
  inputSchema: z.object({}),
283
241
  }, async () => {
284
242
  try {
@@ -302,17 +260,24 @@ expose user LP positions via read-only calls. APY figures are still returned.`,
302
260
  const stxPositions = positions.filter((p) => p.valueUnit === "microSTX");
303
261
  const totalValueSats = satsPositions.reduce((sum, p) => sum + p.valueSats, 0);
304
262
  const totalValueMicroStx = stxPositions.reduce((sum, p) => sum + p.valueSats, 0);
305
- const weightedApyPct = totalValueSats > 0
306
- ? satsPositions.reduce((sum, p) => sum + p.apyPct * (p.valueSats / totalValueSats), 0)
307
- : 0;
263
+ // Weighted only over value whose APY is known; value without a live APY
264
+ // is reported separately rather than counted at 0% or an estimate.
265
+ const ratedPositions = satsPositions.filter((p) => p.apyPct !== null);
266
+ const ratedValueSats = ratedPositions.reduce((sum, p) => sum + p.valueSats, 0);
267
+ const weightedApyPct = ratedValueSats > 0
268
+ ? ratedPositions.reduce((sum, p) => sum + p.apyPct * (p.valueSats / ratedValueSats), 0)
269
+ : null;
308
270
  return createJsonResponse({
309
271
  walletAddress,
310
272
  totalValueSats,
311
273
  totalValueBtc: formatBtc(totalValueSats),
312
274
  totalValueMicroStx,
313
275
  totalValueStx: totalValueMicroStx / 1_000_000,
314
- weightedApyPct: Math.round(weightedApyPct * 100) / 100,
315
- note: "totalValueSats excludes STX stacking (different unit). See totalValueStx separately.",
276
+ weightedApyPct: weightedApyPct === null ? null : Math.round(weightedApyPct * 100) / 100,
277
+ unratedValueSats: totalValueSats - ratedValueSats,
278
+ note: "totalValueSats excludes STX stacking (different unit). See totalValueStx separately. " +
279
+ "weightedApyPct covers only positions with a live APY; unratedValueSats is excluded. " +
280
+ "apyPct is null where no live source exists.",
316
281
  protocols: {
317
282
  zest: {
318
283
  valueSats: zest.valueSats,
@@ -353,7 +318,8 @@ Read-only. Mainnet-only. Requires an unlocked wallet for address context.
353
318
 
354
319
  Known limitations:
355
320
  - ALEX LP and Bitflow LP: valueSats shows 0 (protocol does not expose user
356
- LP balances via read-only calls). APY is still returned.
321
+ LP balances via read-only calls).
322
+ - apyPct is null for ALEX, Bitflow and Stacking (no live source).
357
323
  - Stacking: denominated in microSTX, not sats.`,
358
324
  inputSchema: z.object({}),
359
325
  }, async () => {
@@ -401,9 +367,9 @@ Bitflow (sBTC LP), and STX Stacking. No wallet required — pure market data.
401
367
 
402
368
  Data sources:
403
369
  - Zest Protocol: on-chain reserve state (current-liquidity-rate, Ray units)
404
- - ALEX DEX: static 3.5% estimate (pool data available but per-user APY not live)
405
- - Bitflow: public API at app.bitflow.finance/api/pools (fallback: 2.8% estimate)
406
- - STX Stacking: static 8.0% estimate
370
+ - ALEX DEX: null — no live APY source
371
+ - Bitflow: null — no public pool APY endpoint
372
+ - STX Stacking: null — pox-5 rewards are sBTC, paid per signer manager
407
373
 
408
374
  Mainnet data only (contract addresses are mainnet-specific).`,
409
375
  inputSchema: z.object({}),
@@ -441,7 +407,7 @@ Mainnet data only (contract addresses are mainnet-specific).`,
441
407
  {
442
408
  protocol: "STX Stacking",
443
409
  asset: "STX",
444
- apyPct: 8.0,
410
+ apyPct: null,
445
411
  riskScore: 10,
446
412
  },
447
413
  ],
@@ -518,7 +484,7 @@ Read-only. Mainnet-only. Requires an unlocked wallet for address context.`,
518
484
  suggestions.push("Current allocation is close to optimal for your risk tolerance.");
519
485
  }
520
486
  const zestApy = positions[0].apyPct;
521
- if (zestApy > 6) {
487
+ if (zestApy !== null && zestApy > 6) {
522
488
  suggestions.push(`Zest APY is elevated at ${zestApy.toFixed(1)}% — good time to increase lending allocation`);
523
489
  }
524
490
  if (riskLevel !== "high") {
@@ -98,29 +98,40 @@ export function withFee(spends, fee) {
98
98
  export async function transferStx(account, recipient, amount, memo, fee) {
99
99
  // Always resolve a clamped fee — prevents @stacks/transactions from over-estimating.
100
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);
101
+ // Safety rail: book the amount plus fee against the wallet's cumulative
102
+ // spending limit (per-session and per-day) before signing; refused if over.
103
+ const limiter = getSpendLimiter();
104
+ const reservation = await limiter.reserve([{ unit: "ustx", amount: amount + resolvedFee }], account.address);
104
105
  const networkName = getStacksNetwork(account.network);
105
- const nonce = await getNextNonce(account.address, account.network);
106
- const transaction = await makeSTXTokenTransfer({
107
- recipient,
108
- amount,
109
- senderKey: account.privateKey,
110
- network: networkName,
111
- memo: memo || "",
112
- nonce,
113
- fee: resolvedFee,
114
- });
106
+ let transaction;
107
+ let nonce;
108
+ try {
109
+ nonce = await getNextNonce(account.address, account.network);
110
+ transaction = await makeSTXTokenTransfer({
111
+ recipient,
112
+ amount,
113
+ senderKey: account.privateKey,
114
+ network: networkName,
115
+ memo: memo || "",
116
+ nonce,
117
+ fee: resolvedFee,
118
+ });
119
+ }
120
+ catch (error) {
121
+ await limiter.release(reservation);
122
+ throw error;
123
+ }
124
+ // A throw here (timeout, reset) leaves it unknown whether the node took the
125
+ // transaction, so the booking stands. Only an explicit rejection gives it back.
115
126
  const broadcastResponse = await broadcastTransaction({
116
127
  transaction,
117
128
  network: networkName,
118
129
  });
119
130
  if ("error" in broadcastResponse) {
131
+ await limiter.release(reservation);
120
132
  throw new Error(`Broadcast failed: ${broadcastResponse.error} - ${broadcastResponse.reason}`);
121
133
  }
122
134
  advancePendingNonce(account.address, nonce, broadcastResponse.txid);
123
- await getSpendLimiter().record("ustx", amount + resolvedFee, account.address);
124
135
  return {
125
136
  txid: broadcastResponse.txid,
126
137
  rawTx: transaction.serialize(),
@@ -138,35 +149,41 @@ export async function callContract(account, options) {
138
149
  // already bound, in native units, how much of each asset may leave this
139
150
  // address. Deliberately before makeContractCall, so an over-cap call never signs.
140
151
  const { postConditions, spends } = planContractCallSpends({ ...options, sbtcContract: sbtcContract(account.network) }, account.address);
141
- const meteredSpends = withFee(spends, resolvedFee);
142
- for (const spend of meteredSpends) {
143
- await getSpendLimiter().check(spend.unit, spend.amount, account.address);
144
- }
152
+ const limiter = getSpendLimiter();
153
+ const reservation = await limiter.reserve(withFee(spends, resolvedFee), account.address);
145
154
  const networkName = getStacksNetwork(account.network);
146
- const nonce = await getNextNonce(account.address, account.network);
147
- const transaction = await makeContractCall({
148
- contractAddress: options.contractAddress,
149
- contractName: options.contractName,
150
- functionName: options.functionName,
151
- functionArgs: options.functionArgs,
152
- senderKey: account.privateKey,
153
- network: networkName,
154
- nonce,
155
- postConditionMode: options.postConditionMode || PostConditionMode.Deny,
156
- postConditions,
157
- fee: resolvedFee,
158
- });
155
+ let transaction;
156
+ let nonce;
157
+ try {
158
+ nonce = await getNextNonce(account.address, account.network);
159
+ transaction = await makeContractCall({
160
+ contractAddress: options.contractAddress,
161
+ contractName: options.contractName,
162
+ functionName: options.functionName,
163
+ functionArgs: options.functionArgs,
164
+ senderKey: account.privateKey,
165
+ network: networkName,
166
+ nonce,
167
+ postConditionMode: options.postConditionMode || PostConditionMode.Deny,
168
+ postConditions,
169
+ fee: resolvedFee,
170
+ });
171
+ }
172
+ catch (error) {
173
+ await limiter.release(reservation);
174
+ throw error;
175
+ }
176
+ // A throw here (timeout, reset) leaves it unknown whether the node took the
177
+ // transaction, so the booking stands. Only an explicit rejection gives it back.
159
178
  const broadcastResponse = await broadcastTransaction({
160
179
  transaction,
161
180
  network: networkName,
162
181
  });
163
182
  if ("error" in broadcastResponse) {
183
+ await limiter.release(reservation);
164
184
  throw new Error(`Broadcast failed: ${broadcastResponse.error} - ${broadcastResponse.reason}`);
165
185
  }
166
186
  advancePendingNonce(account.address, nonce, broadcastResponse.txid);
167
- for (const spend of meteredSpends) {
168
- await getSpendLimiter().record(spend.unit, spend.amount, account.address);
169
- }
170
187
  return {
171
188
  txid: broadcastResponse.txid,
172
189
  rawTx: transaction.serialize(),
@@ -77,14 +77,25 @@ function resolveSponsorApiKey(account) {
77
77
  * Shared logic for all three sponsored helpers (contract call, STX transfer,
78
78
  * contract deploy). Each caller builds its own transaction and provides a
79
79
  * fallback function for direct submission when the relay is unavailable.
80
+ * `onNotSent` runs when the transaction provably never left: no API key, or
81
+ * the relay refused it (before any fallback).
80
82
  */
81
- async function submitSponsoredTransaction(account, transaction, network, directFallback) {
82
- const apiKey = resolveSponsorApiKey(account);
83
+ async function submitSponsoredTransaction(account, transaction, network, directFallback, onNotSent = async () => { }) {
84
+ let apiKey;
85
+ try {
86
+ apiKey = resolveSponsorApiKey(account);
87
+ }
88
+ catch (error) {
89
+ await onNotSent();
90
+ throw error;
91
+ }
83
92
  const senderNonce = Number(transaction.auth.spendingCondition.nonce);
84
93
  const serializedTx = transaction.serialize();
85
94
  const response = await submitToSponsorRelay(serializedTx, network, apiKey);
86
95
  if (!response.success) {
87
96
  const { shouldFallback, reason } = await evaluateFallback(response, network);
97
+ // The relay refused the transaction, so it was not broadcast.
98
+ await onNotSent();
88
99
  if (shouldFallback) {
89
100
  console.warn(`[sponsor] Relay unavailable or nonce error (${reason}), falling back to direct submission (sender pays fee)`);
90
101
  const result = await directFallback();
@@ -101,54 +112,62 @@ async function submitSponsoredTransaction(account, transaction, network, directF
101
112
  */
102
113
  export async function sponsoredContractCall(account, options, network) {
103
114
  // 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.
115
+ // still leave. Booked before signing.
106
116
  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);
117
+ const limiter = getSpendLimiter();
118
+ const reservation = await limiter.reserve(spends, account.address);
119
+ let transaction;
120
+ try {
121
+ transaction = await makeContractCall({
122
+ contractAddress: options.contractAddress,
123
+ contractName: options.contractName,
124
+ functionName: options.functionName,
125
+ functionArgs: options.functionArgs,
126
+ senderKey: account.privateKey,
127
+ network: getStacksNetwork(network),
128
+ postConditionMode: options.postConditionMode || PostConditionMode.Deny,
129
+ postConditions,
130
+ sponsored: true,
131
+ fee: 0n,
132
+ });
109
133
  }
110
- const transaction = await makeContractCall({
111
- contractAddress: options.contractAddress,
112
- contractName: options.contractName,
113
- functionName: options.functionName,
114
- functionArgs: options.functionArgs,
115
- senderKey: account.privateKey,
116
- network: getStacksNetwork(network),
117
- postConditionMode: options.postConditionMode || PostConditionMode.Deny,
118
- postConditions,
119
- sponsored: true,
120
- fee: 0n,
121
- });
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
- }
134
+ catch (error) {
135
+ await limiter.release(reservation);
136
+ throw error;
127
137
  }
128
- return result;
138
+ // Released only when the relay refuses the transaction (before the direct
139
+ // fallback, which books its own spend). A relay call that throws, or a
140
+ // failure after the relay accepted it, leaves the booking in place.
141
+ return submitSponsoredTransaction(account, transaction, network, () => callContract(account, options), () => limiter.release(reservation));
129
142
  }
130
143
  /**
131
144
  * Build and submit a sponsored STX transfer via the relay.
132
145
  * Falls back to direct submission (sender pays fee) when the relay is unavailable.
133
146
  */
134
147
  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);
138
- const transaction = await makeSTXTokenTransfer({
139
- recipient,
140
- amount,
141
- senderKey: account.privateKey,
142
- network: getStacksNetwork(network),
143
- memo: memo || "",
144
- sponsored: true,
145
- fee: 0n,
146
- });
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);
148
+ // Booked before signing.
149
+ const limiter = getSpendLimiter();
150
+ const reservation = await limiter.reserve([{ unit: "ustx", amount }], account.address);
151
+ let transaction;
152
+ try {
153
+ transaction = await makeSTXTokenTransfer({
154
+ recipient,
155
+ amount,
156
+ senderKey: account.privateKey,
157
+ network: getStacksNetwork(network),
158
+ memo: memo || "",
159
+ sponsored: true,
160
+ fee: 0n,
161
+ });
162
+ }
163
+ catch (error) {
164
+ await limiter.release(reservation);
165
+ throw error;
150
166
  }
151
- return result;
167
+ // Released only when the relay refuses the transaction (before the direct
168
+ // fallback, which books its own spend). A relay call that throws, or a
169
+ // failure after the relay accepted it, leaves the booking in place.
170
+ return submitSponsoredTransaction(account, transaction, network, () => transferStx(account, recipient, amount, memo), () => limiter.release(reservation));
152
171
  }
153
172
  /**
154
173
  * 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.74.0",
3
+ "version": "1.76.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",
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.74.0 # x-release-please-version
7
+ version: 1.76.0 # x-release-please-version
8
8
  npm: "@aibtc/mcp-server"
9
9
  github: https://github.com/aibtcdev/aibtc-mcp-server
10
10
  ---
@@ -206,8 +206,9 @@ Always probe before executing paid endpoints. Never call `execute_x402_endpoint`
206
206
 
207
207
  **send_inbox_message_direct** — dedicated tool for aibtc.com inbox messages:
208
208
  - Parameters: `recipientBtcAddress` (bc1...), `recipientStxAddress` (SP...), `content` (max 500 chars)
209
- - Direct (non-sponsored) payment: signs a standard sBTC transfer and settles through the x402 facilitator — no relay in the middle
210
- - Sender pays BOTH the sBTC message cost AND its own STX gas; requires an unlocked wallet holding sBTC and STX (mainnet only)
209
+ - Gasless: the inbox advertises a fee payer, so the tool signs a sponsored sBTC transfer (fee 0) and the relay pays the STX gas
210
+ - Requires an unlocked wallet holding the sBTC message cost (100 sats) — no STX needed (mainnet only)
211
+ - One pending payment per sender: wait for the previous message's payment to confirm before sending the next
211
212
  - Implements the full x402 v2 payment flow with a balance pre-check
212
213
  - Note: the older sponsored `send_inbox_message` tool is deprecated and no longer sends — use this tool instead
213
214
 
@@ -115,15 +115,20 @@ send_inbox_message_direct({
115
115
  })
116
116
  ```
117
117
 
118
- It signs a standard sBTC transfer with the x402-stacks client interceptor and settles the
119
- payment directly through the x402 facilitator — there is no relay in the middle. The sender
120
- pays BOTH the sBTC message cost AND its own STX gas, so the wallet must be unlocked and hold
121
- sBTC and STX. Mainnet only. The tool runs a balance pre-check before signing and fails clearly
122
- if STX gas is short.
123
-
124
- > **Deprecated:** the older sponsored `send_inbox_message` tool no longer sends. It returns a
125
- > message directing callers to `send_inbox_message_direct`. The relay-sponsored path was removed
126
- > because sponsored transactions were unstable.
118
+ Inbox sends are **gasless**. The inbox's 402 advertises `extra.feePayer` (the relay's sponsor
119
+ address), so the x402-stacks interceptor signs a sponsored sBTC transfer with fee 0 and the relay
120
+ pays the STX gas. The wallet must be unlocked and hold the sBTC message cost (100 sats) — no STX
121
+ needed. Mainnet only. The tool runs a balance pre-check before signing. If the inbox ever stops
122
+ advertising `feePayer`, the same tool signs a standard transfer and the sender pays its own gas.
123
+
124
+ Sponsored sends follow a few rules (the inbox returns a clear error otherwise):
125
+ - **One pending payment per sender** — wait for the previous inbox payment to confirm (seconds)
126
+ before sending the next; a second one while it's pending gets `SENDER_NONCE_DUPLICATE` (409).
127
+ - **Up to 10 sponsored payments per sender per minute** (`RATE_LIMITED`, 429).
128
+ - **No messages to yourself** — a transfer to your own address fails on-chain, so it's refused.
129
+
130
+ > **Deprecated:** the older `send_inbox_message` tool no longer sends. It returns a message
131
+ > directing callers to `send_inbox_message_direct`, which is now the gasless path.
127
132
 
128
133
  ## More Information
129
134