@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.
- package/README.md +2 -2
- package/dist/services/btc-spend.d.ts +4 -3
- package/dist/services/btc-spend.js +14 -6
- package/dist/services/mempool-api.d.ts +4 -0
- package/dist/services/mempool-api.js +15 -1
- package/dist/services/spend-limiter.d.ts +49 -5
- package/dist/services/spend-limiter.js +132 -68
- package/dist/services/state-lock.d.ts +22 -21
- package/dist/services/state-lock.js +106 -24
- package/dist/services/x402.service.d.ts +8 -0
- package/dist/services/x402.service.js +63 -28
- package/dist/tools/endpoint.tools.js +2 -3
- package/dist/tools/inbox-x402.tools.d.ts +7 -5
- package/dist/tools/inbox-x402.tools.js +60 -25
- package/dist/tools/inbox.tools.js +7 -11
- package/dist/tools/index.js +1 -1
- package/dist/tools/lightning.tools.js +4 -2
- package/dist/tools/profiles.d.ts +2 -2
- package/dist/tools/profiles.js +2 -2
- package/dist/tools/psbt.tools.js +1 -2
- package/dist/tools/sbtc.tools.js +1 -1
- package/dist/tools/yield-dashboard.tools.js +73 -107
- package/dist/transactions/builder.js +51 -34
- package/dist/transactions/sponsor-builder.js +59 -40
- package/package.json +1 -1
- package/skill/SKILL.md +4 -3
- package/skill/references/x402-inbox.md +14 -9
|
@@ -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,
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
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
|
|
153
|
-
const
|
|
154
|
-
const
|
|
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
|
-
|
|
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:
|
|
163
|
+
apyPct: null,
|
|
180
164
|
riskScore: 35,
|
|
181
165
|
details: {},
|
|
182
166
|
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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).
|
|
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:
|
|
405
|
-
- Bitflow:
|
|
406
|
-
- STX Stacking:
|
|
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:
|
|
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:
|
|
102
|
-
//
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
136
|
-
|
|
137
|
-
await
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
-
|
|
210
|
-
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
pays
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|