@aibtc/mcp-server 1.65.0 → 1.66.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/dist/config/legion.d.ts +58 -0
- package/dist/config/legion.js +100 -0
- package/dist/services/legion.service.d.ts +188 -0
- package/dist/services/legion.service.js +410 -0
- package/dist/services/x402.service.d.ts +33 -4
- package/dist/services/x402.service.js +173 -6
- package/dist/tools/endpoint.tools.js +35 -9
- package/dist/tools/index.js +3 -0
- package/dist/tools/legion.tools.d.ts +16 -0
- package/dist/tools/legion.tools.js +1373 -0
- package/package.json +2 -1
- package/skill/SKILL.md +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIBTC News Legion (news-gov-v5) chain reads.
|
|
3
|
+
*
|
|
4
|
+
* Everything here reads the deployed contracts rather than trusting constants:
|
|
5
|
+
* governance parameters, the timing mode (which clock the windows count on),
|
|
6
|
+
* and the sBTC token the treasury actually holds all come off chain and are
|
|
7
|
+
* cached per process. A doc that drifts from the contract is a doc; a read that
|
|
8
|
+
* drifts from the contract is a bug, so there is nothing to drift.
|
|
9
|
+
*/
|
|
10
|
+
import { ClarityType, cvToJSON, deserializeCV, getAddressFromPrivateKey, principalCV, uintCV, } from "@stacks/transactions";
|
|
11
|
+
import { LEGION_GOV_CONTRACT, LEGION_NETWORK, LEGION_TREASURY_CONTRACT, ORDINALS_CONTENT_BASE, ORDINALS_INSCRIPTION_BASE, } from "../config/legion.js";
|
|
12
|
+
import { getHiroApi } from "./hiro-api.js";
|
|
13
|
+
import { getAccount } from "./x402.service.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Clarity value → plain JS
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/**
|
|
18
|
+
* Deep-unwrap a ClarityValue into plain JS. `cvToValue` only unwraps the top
|
|
19
|
+
* level — nested tuples and optionals come back as `{type, value}` envelopes —
|
|
20
|
+
* so the legion's tuple-heavy views need this instead.
|
|
21
|
+
*
|
|
22
|
+
* uints/ints become strings to stay exact; callers narrow with `num()`.
|
|
23
|
+
*/
|
|
24
|
+
export function simplifyCV(cv) {
|
|
25
|
+
switch (cv.type) {
|
|
26
|
+
case ClarityType.BoolTrue:
|
|
27
|
+
return true;
|
|
28
|
+
case ClarityType.BoolFalse:
|
|
29
|
+
return false;
|
|
30
|
+
case ClarityType.Int:
|
|
31
|
+
case ClarityType.UInt:
|
|
32
|
+
return cv.value.toString();
|
|
33
|
+
case ClarityType.Buffer:
|
|
34
|
+
return `0x${cv.value}`;
|
|
35
|
+
case ClarityType.StringASCII:
|
|
36
|
+
case ClarityType.StringUTF8:
|
|
37
|
+
case ClarityType.PrincipalStandard:
|
|
38
|
+
case ClarityType.PrincipalContract:
|
|
39
|
+
return cv.value;
|
|
40
|
+
case ClarityType.OptionalNone:
|
|
41
|
+
return null;
|
|
42
|
+
case ClarityType.OptionalSome:
|
|
43
|
+
case ClarityType.ResponseOk:
|
|
44
|
+
case ClarityType.ResponseErr:
|
|
45
|
+
return simplifyCV(cv.value);
|
|
46
|
+
case ClarityType.List:
|
|
47
|
+
return cv.value.map(simplifyCV);
|
|
48
|
+
case ClarityType.Tuple: {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [key, value] of Object.entries(cv.value)) {
|
|
51
|
+
out[key] = simplifyCV(value);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
return cvToJSON(cv).value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** A uint field from a simplified tuple, as a JS number. */
|
|
60
|
+
export function num(value) {
|
|
61
|
+
return Number(value);
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Contract reads
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* Call a read-only function on a legion contract and return it deep-unwrapped.
|
|
68
|
+
* Always reads the legion's own chain, never the globally configured one.
|
|
69
|
+
*/
|
|
70
|
+
export async function readLegionContract(contractId, functionName, functionArgs = []) {
|
|
71
|
+
const hiro = getHiroApi(LEGION_NETWORK);
|
|
72
|
+
const sender = contractId.split(".")[0];
|
|
73
|
+
const result = await hiro.callReadOnlyFunction(contractId, functionName, functionArgs, sender);
|
|
74
|
+
if (!result.okay || !result.result) {
|
|
75
|
+
throw new Error(`${contractId}::${functionName} failed: ${result.cause ?? "no result"}`);
|
|
76
|
+
}
|
|
77
|
+
const hex = result.result.startsWith("0x")
|
|
78
|
+
? result.result.slice(2)
|
|
79
|
+
: result.result;
|
|
80
|
+
return simplifyCV(deserializeCV(Buffer.from(hex, "hex")));
|
|
81
|
+
}
|
|
82
|
+
export function readGov(functionName, functionArgs = []) {
|
|
83
|
+
return readLegionContract(LEGION_GOV_CONTRACT, functionName, functionArgs);
|
|
84
|
+
}
|
|
85
|
+
export function readTreasury(functionName, functionArgs = []) {
|
|
86
|
+
return readLegionContract(LEGION_TREASURY_CONTRACT, functionName, functionArgs);
|
|
87
|
+
}
|
|
88
|
+
let paramsCache = null;
|
|
89
|
+
let timingModeCache = null;
|
|
90
|
+
let tokenCache = null;
|
|
91
|
+
/** Every governance parameter, straight from `get-params`. */
|
|
92
|
+
export async function getParams() {
|
|
93
|
+
if (paramsCache)
|
|
94
|
+
return paramsCache;
|
|
95
|
+
const raw = (await readGov("get-params"));
|
|
96
|
+
paramsCache = {
|
|
97
|
+
votingQuorum: num(raw.votingQuorum),
|
|
98
|
+
votingThreshold: num(raw.votingThreshold),
|
|
99
|
+
vetoQuorum: num(raw.vetoQuorum),
|
|
100
|
+
minParticipants: num(raw.minParticipants),
|
|
101
|
+
minWeight: num(raw.minWeight),
|
|
102
|
+
minContribution: num(raw.minContribution),
|
|
103
|
+
drawBps: num(raw.drawBps),
|
|
104
|
+
votingDelay: num(raw.votingDelay),
|
|
105
|
+
voteWindow: num(raw.voteWindow),
|
|
106
|
+
vetoWindow: num(raw.vetoWindow),
|
|
107
|
+
concludeWindow: num(raw.concludeWindow),
|
|
108
|
+
proposeInterval: num(raw.proposeInterval),
|
|
109
|
+
};
|
|
110
|
+
return paramsCache;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Which clock the windows count on. `"TEST-STACKS-BLOCKS"` counts Stacks
|
|
114
|
+
* blocks; `"PROD-BURN"` counts Bitcoin burn blocks. Never assume — a window
|
|
115
|
+
* measured against the wrong tip is off by a factor of ten.
|
|
116
|
+
*/
|
|
117
|
+
export async function getTimingMode() {
|
|
118
|
+
if (timingModeCache)
|
|
119
|
+
return timingModeCache;
|
|
120
|
+
timingModeCache = (await readGov("get-timing-mode"));
|
|
121
|
+
return timingModeCache;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The sBTC token the treasury actually moves, plus its fungible-token asset
|
|
125
|
+
* name — both needed to write an exact post-condition. Read from the treasury
|
|
126
|
+
* and its contract interface so a post-condition can never name the wrong asset.
|
|
127
|
+
*/
|
|
128
|
+
export async function getLegionToken() {
|
|
129
|
+
if (tokenCache)
|
|
130
|
+
return tokenCache;
|
|
131
|
+
const contract = (await readTreasury("get-token"));
|
|
132
|
+
const iface = await getHiroApi(LEGION_NETWORK).getContractInterface(contract);
|
|
133
|
+
const assetName = iface.fungible_tokens?.[0]?.name;
|
|
134
|
+
if (!assetName) {
|
|
135
|
+
throw new Error(`${contract} declares no fungible token — cannot build a post-condition for it.`);
|
|
136
|
+
}
|
|
137
|
+
tokenCache = { contract, assetName };
|
|
138
|
+
return tokenCache;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The height the contract's windows are measured against, on the clock
|
|
142
|
+
* `get-timing-mode` names.
|
|
143
|
+
*/
|
|
144
|
+
export async function getClockHeight() {
|
|
145
|
+
const [timingMode, info] = await Promise.all([
|
|
146
|
+
getTimingMode(),
|
|
147
|
+
getHiroApi(LEGION_NETWORK).getCoreApiInfo(),
|
|
148
|
+
]);
|
|
149
|
+
const clock = timingMode === "PROD-BURN" ? "burn" : "stacks";
|
|
150
|
+
return {
|
|
151
|
+
height: clock === "burn" ? info.burn_block_height : info.stacks_tip_height,
|
|
152
|
+
clock,
|
|
153
|
+
timingMode,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// The signing account, pinned to the legion's chain
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
/**
|
|
160
|
+
* The unlocked wallet, re-derived for the legion's network.
|
|
161
|
+
*
|
|
162
|
+
* The same private key yields a different address per chain, so a mainnet
|
|
163
|
+
* session still signs legion calls from its testnet address against the testnet
|
|
164
|
+
* node. This is the guard that makes a mainnet-configured server safe here.
|
|
165
|
+
*/
|
|
166
|
+
export async function getLegionAccount() {
|
|
167
|
+
const base = await getAccount();
|
|
168
|
+
if (base.network === LEGION_NETWORK)
|
|
169
|
+
return base;
|
|
170
|
+
return {
|
|
171
|
+
...base,
|
|
172
|
+
address: getAddressFromPrivateKey(base.privateKey, LEGION_NETWORK),
|
|
173
|
+
network: LEGION_NETWORK,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export async function getStory(proposalId) {
|
|
177
|
+
const raw = (await readGov("get-story", [uintCV(proposalId)]));
|
|
178
|
+
if (!raw)
|
|
179
|
+
return null;
|
|
180
|
+
return {
|
|
181
|
+
proposer: String(raw.proposer),
|
|
182
|
+
bond: num(raw.bond),
|
|
183
|
+
draw: num(raw.draw),
|
|
184
|
+
createdAt: num(raw.createdAt),
|
|
185
|
+
voteEnd: num(raw.voteEnd),
|
|
186
|
+
eligibleSnapshot: num(raw.eligibleSnapshot),
|
|
187
|
+
yesWeight: num(raw.yesWeight),
|
|
188
|
+
noWeight: num(raw.noWeight),
|
|
189
|
+
vetoWeight: num(raw.vetoWeight),
|
|
190
|
+
voterCount: num(raw.voterCount),
|
|
191
|
+
status: num(raw.status),
|
|
192
|
+
reason: String(raw.reason ?? ""),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export async function getStoryMeta(proposalId) {
|
|
196
|
+
const raw = (await readGov("get-story-meta", [uintCV(proposalId)]));
|
|
197
|
+
if (!raw)
|
|
198
|
+
return null;
|
|
199
|
+
return {
|
|
200
|
+
title: String(raw.title ?? ""),
|
|
201
|
+
description: String(raw.description ?? ""),
|
|
202
|
+
link: String(raw.link ?? ""),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* `"none" | "pending" | "voting" | "veto" | "concludable" | "expired" |
|
|
207
|
+
* "passed" | "failed"` — the contract's own view of where a piece stands.
|
|
208
|
+
*/
|
|
209
|
+
export async function getPhase(proposalId) {
|
|
210
|
+
return (await readGov("get-phase", [uintCV(proposalId)]));
|
|
211
|
+
}
|
|
212
|
+
export async function getLastProposalId() {
|
|
213
|
+
return num(await readGov("get-last-proposal-id"));
|
|
214
|
+
}
|
|
215
|
+
export async function getVoteRecord(proposalId, voter) {
|
|
216
|
+
const raw = (await readGov("get-vote-record", [
|
|
217
|
+
uintCV(proposalId),
|
|
218
|
+
principalCV(voter),
|
|
219
|
+
]));
|
|
220
|
+
if (!raw)
|
|
221
|
+
return null;
|
|
222
|
+
return { support: Boolean(raw.support), weight: num(raw.weight) };
|
|
223
|
+
}
|
|
224
|
+
export async function getVetoRecord(proposalId, voter) {
|
|
225
|
+
const raw = (await readGov("get-veto-record", [
|
|
226
|
+
uintCV(proposalId),
|
|
227
|
+
principalCV(voter),
|
|
228
|
+
]));
|
|
229
|
+
if (!raw)
|
|
230
|
+
return null;
|
|
231
|
+
return { weight: num(raw.weight) };
|
|
232
|
+
}
|
|
233
|
+
/** Every propose precondition folded into one read, with the individual gates. */
|
|
234
|
+
export async function getProposeStatus(who) {
|
|
235
|
+
const raw = (await readGov("propose-status", [principalCV(who)]));
|
|
236
|
+
return {
|
|
237
|
+
canPropose: Boolean(raw.canPropose),
|
|
238
|
+
eligible: Boolean(raw.eligible),
|
|
239
|
+
slotOpen: Boolean(raw.slotOpen),
|
|
240
|
+
noLiveProposal: Boolean(raw.noLiveProposal),
|
|
241
|
+
poolOk: Boolean(raw.poolOk),
|
|
242
|
+
nextProposeHeight: num(raw.nextProposeHeight),
|
|
243
|
+
lockOnPropose: num(raw.lockOnPropose),
|
|
244
|
+
draw: num(raw.draw),
|
|
245
|
+
freeWeight: num(raw.freeWeight),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Why a propose would revert right now, in the caller's words. */
|
|
249
|
+
export function proposeBlockers(status, minWeight) {
|
|
250
|
+
const blockers = [];
|
|
251
|
+
if (!status.eligible) {
|
|
252
|
+
blockers.push(`your weight is below minWeight (${minWeight}) — call legion_contribute first`);
|
|
253
|
+
}
|
|
254
|
+
if (!status.noLiveProposal) {
|
|
255
|
+
blockers.push("you already have a live proposal — one per principal until it concludes or lapses");
|
|
256
|
+
}
|
|
257
|
+
if (!status.slotOpen) {
|
|
258
|
+
blockers.push(`the contract-wide propose interval has not elapsed — next open at height ${status.nextProposeHeight}`);
|
|
259
|
+
}
|
|
260
|
+
if (!status.poolOk) {
|
|
261
|
+
blockers.push("the pool is empty or the draw would round to zero sats");
|
|
262
|
+
}
|
|
263
|
+
return blockers;
|
|
264
|
+
}
|
|
265
|
+
/** When each window opens and closes, on the clock the contract counts. */
|
|
266
|
+
export function buildTimeline(story, params, height, clock) {
|
|
267
|
+
const votingOpensAt = story.createdAt + params.votingDelay;
|
|
268
|
+
const vetoEnd = story.voteEnd + params.vetoWindow;
|
|
269
|
+
const concludeDeadline = vetoEnd + params.concludeWindow;
|
|
270
|
+
const nextBoundary = [votingOpensAt, story.voteEnd, vetoEnd, concludeDeadline].find((boundary) => boundary > height);
|
|
271
|
+
return {
|
|
272
|
+
createdAt: story.createdAt,
|
|
273
|
+
votingOpensAt,
|
|
274
|
+
voteEnd: story.voteEnd,
|
|
275
|
+
vetoEnd,
|
|
276
|
+
concludeDeadline,
|
|
277
|
+
currentHeight: height,
|
|
278
|
+
clock,
|
|
279
|
+
blocksUntilNextTransition: nextBoundary === undefined ? null : nextBoundary - height,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* What `conclude` would decide if it ran at this height, following the
|
|
284
|
+
* contract's decision order exactly (integer division included, since Clarity
|
|
285
|
+
* floors every percentage).
|
|
286
|
+
*
|
|
287
|
+
* A story that is already terminal reports what the contract RECORDED, not a
|
|
288
|
+
* forecast. Forecasting a settled piece would be worse than useless: a passed
|
|
289
|
+
* proposal is necessarily past its conclude window, so the forecast branch
|
|
290
|
+
* would call every settled piece "expired".
|
|
291
|
+
*/
|
|
292
|
+
export function predictOutcome(story, params, poolBalance, height) {
|
|
293
|
+
const cast = story.yesWeight + story.noWeight;
|
|
294
|
+
const eligible = story.eligibleSnapshot;
|
|
295
|
+
const quorumPct = eligible > 0 ? Math.floor((cast * 100) / eligible) : 0;
|
|
296
|
+
const yesPct = cast > 0 ? Math.floor((story.yesWeight * 100) / cast) : 0;
|
|
297
|
+
const vetoPct = eligible > 0 ? Math.floor((story.vetoWeight * 100) / eligible) : 0;
|
|
298
|
+
const participantsMet = story.voterCount >= params.minParticipants;
|
|
299
|
+
const quorumMet = eligible > 0 && participantsMet && quorumPct >= params.votingQuorum;
|
|
300
|
+
const thresholdMet = cast > 0 && yesPct >= params.votingThreshold;
|
|
301
|
+
const vetoed = eligible > 0 && vetoPct >= params.vetoQuorum;
|
|
302
|
+
const base = {
|
|
303
|
+
cast,
|
|
304
|
+
quorumPct,
|
|
305
|
+
yesPct,
|
|
306
|
+
vetoPct,
|
|
307
|
+
quorumMet,
|
|
308
|
+
thresholdMet,
|
|
309
|
+
vetoed,
|
|
310
|
+
participantsMet,
|
|
311
|
+
};
|
|
312
|
+
// Already terminal: report the record.
|
|
313
|
+
if (story.status !== 0) {
|
|
314
|
+
const settledOutcome = story.status === 1 ? "passed" : story.status === 3 ? "expired" : "failed";
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
settled: true,
|
|
318
|
+
outcome: settledOutcome,
|
|
319
|
+
reason: story.reason || settledOutcome,
|
|
320
|
+
payout: story.status === 1 ? story.draw : 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const concludeDeadline = story.voteEnd + params.vetoWindow + params.concludeWindow;
|
|
324
|
+
if (height >= concludeDeadline) {
|
|
325
|
+
return {
|
|
326
|
+
...base,
|
|
327
|
+
settled: false,
|
|
328
|
+
outcome: "expired",
|
|
329
|
+
reason: "not-concluded — the conclude window closed with nobody calling it",
|
|
330
|
+
payout: 0,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (vetoed) {
|
|
334
|
+
return { ...base, settled: false, outcome: "failed", reason: "vetoed", payout: 0 };
|
|
335
|
+
}
|
|
336
|
+
if (!quorumMet) {
|
|
337
|
+
return { ...base, settled: false, outcome: "failed", reason: "no-quorum", payout: 0 };
|
|
338
|
+
}
|
|
339
|
+
if (!thresholdMet) {
|
|
340
|
+
return { ...base, settled: false, outcome: "failed", reason: "voted-down", payout: 0 };
|
|
341
|
+
}
|
|
342
|
+
if (story.draw > poolBalance) {
|
|
343
|
+
return { ...base, settled: false, outcome: "failed", reason: "pool-short", payout: 0 };
|
|
344
|
+
}
|
|
345
|
+
return { ...base, settled: false, outcome: "passed", reason: "paid", payout: story.draw };
|
|
346
|
+
}
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Links and validation
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
/**
|
|
351
|
+
* Accept either a bare inscription id (`<txid>i0`) or a full URL, and return
|
|
352
|
+
* the link the contract should store.
|
|
353
|
+
*
|
|
354
|
+
* A bare id becomes the VIEWER url, not the content url: that is what every
|
|
355
|
+
* proposal already on chain uses, and it is what a voter wants from a click.
|
|
356
|
+
* The reader at legions.aibtc.news accepts either form and both bare ids.
|
|
357
|
+
*/
|
|
358
|
+
export function normalizeOrdinalsLink(input) {
|
|
359
|
+
const trimmed = input.trim();
|
|
360
|
+
if (/^[0-9a-f]{64}i\d+$/i.test(trimmed)) {
|
|
361
|
+
return `${ORDINALS_INSCRIPTION_BASE}${trimmed.toLowerCase()}`;
|
|
362
|
+
}
|
|
363
|
+
if (/^https?:\/\//i.test(trimmed))
|
|
364
|
+
return trimmed;
|
|
365
|
+
throw new Error(`"${input}" is neither an inscription id (<64-hex-txid>i0) nor a URL. ` +
|
|
366
|
+
`Pass the inscription id from legion_inscribe_reveal, or a full https:// link.`);
|
|
367
|
+
}
|
|
368
|
+
/** The inscription id inside an ordinals link, when there is one. */
|
|
369
|
+
export function inscriptionIdFromLink(link) {
|
|
370
|
+
const match = link.match(/([0-9a-f]{64}i\d+)/i);
|
|
371
|
+
return match ? match[1].toLowerCase() : null;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The raw-bytes url for a stored link, so a voter can read the piece rather
|
|
375
|
+
* than just look at where it lives. Null when the link names no inscription.
|
|
376
|
+
*/
|
|
377
|
+
export function contentUrlFromLink(link) {
|
|
378
|
+
const id = inscriptionIdFromLink(link);
|
|
379
|
+
return id ? `${ORDINALS_CONTENT_BASE}${id}` : null;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Clarity `string-ascii` holds one byte per character, so a smart quote or an
|
|
383
|
+
* em dash pasted into a headline is a revert, not a truncation. Catch it here
|
|
384
|
+
* with a message that names the character.
|
|
385
|
+
*/
|
|
386
|
+
export function assertAscii(value, field, maxLength) {
|
|
387
|
+
const offender = [...value].find((char) => char.charCodeAt(0) > 127);
|
|
388
|
+
if (offender) {
|
|
389
|
+
throw new Error(`${field} must be ASCII — "${offender}" (U+${offender
|
|
390
|
+
.charCodeAt(0)
|
|
391
|
+
.toString(16)
|
|
392
|
+
.toUpperCase()
|
|
393
|
+
.padStart(4, "0")}) is not. Clarity string-ascii cannot hold it.`);
|
|
394
|
+
}
|
|
395
|
+
if (value.length > maxLength) {
|
|
396
|
+
throw new Error(`${field} is ${value.length} characters; the contract accepts at most ${maxLength}.`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** `(err uXXX)` from a failed read or a broadcast rejection, as a sentence. */
|
|
400
|
+
export function explainError(error, errors) {
|
|
401
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
402
|
+
const match = text.match(/\bu(\d{3})\b/);
|
|
403
|
+
if (match) {
|
|
404
|
+
const explanation = errors[Number(match[1])];
|
|
405
|
+
if (explanation)
|
|
406
|
+
return `${text} — ${explanation}`;
|
|
407
|
+
}
|
|
408
|
+
return text;
|
|
409
|
+
}
|
|
410
|
+
//# sourceMappingURL=legion.service.js.map
|
|
@@ -9,6 +9,28 @@ export declare class X402RateLimitError extends Error {
|
|
|
9
9
|
readonly retryAfterSeconds: number;
|
|
10
10
|
constructor(message: string, retryAfterSeconds: number);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* How long a completed x402 request suppresses an identical repeat.
|
|
14
|
+
*
|
|
15
|
+
* This was 60s, which is shorter than a realistic agent retry: an agent that
|
|
16
|
+
* reports a failure to its operator and retries takes minutes, not seconds.
|
|
17
|
+
* In the incident behind #630 the two payments landed 362s apart — 6x the old
|
|
18
|
+
* window — so the guard never engaged and the endpoint was paid twice.
|
|
19
|
+
*
|
|
20
|
+
* The asymmetry favours a long window: a false dedup hit costs a wait (and
|
|
21
|
+
* still returns the prior txid so the caller can verify), while a miss costs a
|
|
22
|
+
* duplicate on-chain payment that cannot be reversed. Deliberate repeat
|
|
23
|
+
* purchases of the same endpoint with the same params must wait it out or vary
|
|
24
|
+
* a param; raise or lower via X402_DEDUP_TTL_SECONDS.
|
|
25
|
+
*/
|
|
26
|
+
export declare const X402_DEDUP_TTL_MS: number;
|
|
27
|
+
/**
|
|
28
|
+
* Point the dedup cache at a different state file and drop what is in memory.
|
|
29
|
+
*
|
|
30
|
+
* Also the seam tests use to simulate a restart: re-pointing at the same file
|
|
31
|
+
* clears memory and forces the next read to come off disk.
|
|
32
|
+
*/
|
|
33
|
+
export declare function setDedupStateFile(file: string): void;
|
|
12
34
|
/**
|
|
13
35
|
* Convert mnemonic to account
|
|
14
36
|
*/
|
|
@@ -168,14 +190,21 @@ export declare function probeEndpoint(options: {
|
|
|
168
190
|
*/
|
|
169
191
|
export declare function generateDedupKey(method: string, url: string, params?: Record<string, string>, data?: Record<string, unknown>): string;
|
|
170
192
|
/**
|
|
171
|
-
* Check if a request was recently processed (within
|
|
193
|
+
* Check if a request was recently processed (within X402_DEDUP_TTL_MS).
|
|
194
|
+
*
|
|
195
|
+
* Loads persisted entries first so a payment broadcast before a server restart
|
|
196
|
+
* still suppresses the retry that follows it.
|
|
197
|
+
*
|
|
172
198
|
* @returns txid if duplicate found, null otherwise
|
|
173
199
|
*/
|
|
174
|
-
export declare function checkDedupCache(key: string): string | null
|
|
200
|
+
export declare function checkDedupCache(key: string): Promise<string | null>;
|
|
175
201
|
/**
|
|
176
|
-
* Record a transaction in the dedup cache
|
|
202
|
+
* Record a transaction in the dedup cache and persist it.
|
|
203
|
+
*
|
|
204
|
+
* Awaits the write so the entry survives a crash immediately after a payment —
|
|
205
|
+
* the window where losing it costs a duplicate payment.
|
|
177
206
|
*/
|
|
178
|
-
export declare function recordTransaction(key: string, txid: string): void
|
|
207
|
+
export declare function recordTransaction(key: string, txid: string): Promise<void>;
|
|
179
208
|
/**
|
|
180
209
|
* Check if account has sufficient balance to pay for x402 endpoint.
|
|
181
210
|
* @throws InsufficientBalanceError if balance is too low
|
|
@@ -10,6 +10,9 @@ import { formatStx, formatSbtc } from "../utils/formatting.js";
|
|
|
10
10
|
import { getSbtcService } from "./sbtc.service.js";
|
|
11
11
|
import { getHiroApi } from "./hiro-api.js";
|
|
12
12
|
import { createHash } from "crypto";
|
|
13
|
+
import { promises as fs } from "fs";
|
|
14
|
+
import os from "os";
|
|
15
|
+
import path from "path";
|
|
13
16
|
import { InsufficientBalanceError } from "../utils/errors.js";
|
|
14
17
|
import { getContracts, parseContractId } from "../config/contracts.js";
|
|
15
18
|
import { resolveDefaultFee } from "../utils/fee.js";
|
|
@@ -104,13 +107,167 @@ export class X402RateLimitError extends Error {
|
|
|
104
107
|
this.name = "X402RateLimitError";
|
|
105
108
|
}
|
|
106
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* How long a completed x402 request suppresses an identical repeat.
|
|
112
|
+
*
|
|
113
|
+
* This was 60s, which is shorter than a realistic agent retry: an agent that
|
|
114
|
+
* reports a failure to its operator and retries takes minutes, not seconds.
|
|
115
|
+
* In the incident behind #630 the two payments landed 362s apart — 6x the old
|
|
116
|
+
* window — so the guard never engaged and the endpoint was paid twice.
|
|
117
|
+
*
|
|
118
|
+
* The asymmetry favours a long window: a false dedup hit costs a wait (and
|
|
119
|
+
* still returns the prior txid so the caller can verify), while a miss costs a
|
|
120
|
+
* duplicate on-chain payment that cannot be reversed. Deliberate repeat
|
|
121
|
+
* purchases of the same endpoint with the same params must wait it out or vary
|
|
122
|
+
* a param; raise or lower via X402_DEDUP_TTL_SECONDS.
|
|
123
|
+
*/
|
|
124
|
+
export const X402_DEDUP_TTL_MS = parseSatsCap("X402_DEDUP_TTL_SECONDS", 900) * 1000; // 15 minutes
|
|
107
125
|
// Transaction deduplication cache: {dedupKey -> {txid, timestamp}}
|
|
108
126
|
const dedupCache = new Map();
|
|
127
|
+
/**
|
|
128
|
+
* Where the dedup cache is persisted.
|
|
129
|
+
*
|
|
130
|
+
* Memory alone left the guard open to the same double-pay it exists to stop: an
|
|
131
|
+
* MCP server restarts routinely (client reconnect, config change, crash), and a
|
|
132
|
+
* restart inside the TTL window dropped every entry. An agent retrying after one
|
|
133
|
+
* sailed past the guard and paid a second time — #630's outcome reached by a
|
|
134
|
+
* different route.
|
|
135
|
+
*
|
|
136
|
+
* Keys are SHA-256 digests (see generateDedupKey) and values are chain txids, so
|
|
137
|
+
* no request content reaches disk. The file is still written 0600 under a 0700
|
|
138
|
+
* directory to match the wallet store.
|
|
139
|
+
*
|
|
140
|
+
* Caveat: entries are keyed by request, not by payer. Switching wallets and
|
|
141
|
+
* repeating a request inside the window reports a hit for the other wallet's
|
|
142
|
+
* payment. That is the asymmetry the TTL already takes — a false hit costs a
|
|
143
|
+
* wait and still surfaces the prior txid to verify, while a miss costs an
|
|
144
|
+
* irreversible duplicate payment.
|
|
145
|
+
*
|
|
146
|
+
* Override the location with X402_DEDUP_STATE_FILE.
|
|
147
|
+
*/
|
|
148
|
+
let dedupStateFile = process.env.X402_DEDUP_STATE_FILE ||
|
|
149
|
+
path.join(os.homedir(), ".aibtc", "x402-dedup.json");
|
|
150
|
+
let dedupLoad = null;
|
|
151
|
+
let dedupWriteLock = Promise.resolve();
|
|
152
|
+
/**
|
|
153
|
+
* Point the dedup cache at a different state file and drop what is in memory.
|
|
154
|
+
*
|
|
155
|
+
* Also the seam tests use to simulate a restart: re-pointing at the same file
|
|
156
|
+
* clears memory and forces the next read to come off disk.
|
|
157
|
+
*/
|
|
158
|
+
export function setDedupStateFile(file) {
|
|
159
|
+
dedupStateFile = file;
|
|
160
|
+
dedupLoad = null;
|
|
161
|
+
dedupCache.clear();
|
|
162
|
+
}
|
|
163
|
+
function isFresh(entry, now) {
|
|
164
|
+
return now - entry.timestamp <= X402_DEDUP_TTL_MS;
|
|
165
|
+
}
|
|
166
|
+
/** Read persisted entries. A missing file is the normal first-run case. */
|
|
167
|
+
async function readDedupState() {
|
|
168
|
+
let raw;
|
|
169
|
+
try {
|
|
170
|
+
raw = await fs.readFile(dedupStateFile, "utf8");
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (err.code !== "ENOENT") {
|
|
174
|
+
console.error(`[x402-dedup] Could not read ${dedupStateFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
175
|
+
}
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
if (!parsed || typeof parsed !== "object")
|
|
181
|
+
return {};
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// A corrupt file must not brick every x402 call. Report it and continue
|
|
186
|
+
// with the in-memory guard; the next write replaces the file.
|
|
187
|
+
console.error(`[x402-dedup] Ignoring corrupt state at ${dedupStateFile}`);
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Load persisted entries into memory once per state file.
|
|
193
|
+
*
|
|
194
|
+
* Must be awaited before any dedup read: answering from a not-yet-loaded cache
|
|
195
|
+
* would report "no prior payment" for one that is sitting on disk, which is the
|
|
196
|
+
* exact miss this persistence closes.
|
|
197
|
+
*/
|
|
198
|
+
async function ensureDedupLoaded() {
|
|
199
|
+
if (!dedupLoad) {
|
|
200
|
+
dedupLoad = (async () => {
|
|
201
|
+
const persisted = await readDedupState();
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
for (const [key, entry] of Object.entries(persisted)) {
|
|
204
|
+
if (!entry ||
|
|
205
|
+
typeof entry.txid !== "string" ||
|
|
206
|
+
typeof entry.timestamp !== "number") {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!isFresh(entry, now))
|
|
210
|
+
continue;
|
|
211
|
+
// Anything recorded in this process is at least as fresh as disk.
|
|
212
|
+
const existing = dedupCache.get(key);
|
|
213
|
+
if (!existing || existing.timestamp < entry.timestamp) {
|
|
214
|
+
dedupCache.set(key, entry);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
}
|
|
219
|
+
return dedupLoad;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Merge memory over disk and write atomically, dropping expired entries.
|
|
223
|
+
*
|
|
224
|
+
* Re-reads inside the lock so a second server instance sharing the file does not
|
|
225
|
+
* lose the entries this one never saw. A failure here is logged rather than
|
|
226
|
+
* thrown: the caller records *after* funds have already moved, so rejecting
|
|
227
|
+
* would turn a recorded payment into a reported error while leaving the
|
|
228
|
+
* in-memory guard intact.
|
|
229
|
+
*/
|
|
230
|
+
async function persistDedupCache() {
|
|
231
|
+
dedupWriteLock = dedupWriteLock
|
|
232
|
+
.then(async () => {
|
|
233
|
+
const onDisk = await readDedupState();
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const merged = {};
|
|
236
|
+
for (const [key, entry] of Object.entries(onDisk)) {
|
|
237
|
+
if (entry &&
|
|
238
|
+
typeof entry.txid === "string" &&
|
|
239
|
+
typeof entry.timestamp === "number" &&
|
|
240
|
+
isFresh(entry, now)) {
|
|
241
|
+
merged[key] = entry;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const [key, entry] of dedupCache) {
|
|
245
|
+
if (!isFresh(entry, now))
|
|
246
|
+
continue;
|
|
247
|
+
const existing = merged[key];
|
|
248
|
+
if (!existing || existing.timestamp <= entry.timestamp) {
|
|
249
|
+
merged[key] = entry;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
await fs.mkdir(path.dirname(dedupStateFile), {
|
|
253
|
+
recursive: true,
|
|
254
|
+
mode: 0o700,
|
|
255
|
+
});
|
|
256
|
+
// pid-suffixed so concurrent servers do not clobber each other's temp.
|
|
257
|
+
const tmp = `${dedupStateFile}.${process.pid}.tmp`;
|
|
258
|
+
await fs.writeFile(tmp, JSON.stringify(merged), { mode: 0o600 });
|
|
259
|
+
await fs.rename(tmp, dedupStateFile);
|
|
260
|
+
})
|
|
261
|
+
.catch((err) => {
|
|
262
|
+
console.error(`[x402-dedup] Could not persist ${dedupStateFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
263
|
+
});
|
|
264
|
+
return dedupWriteLock;
|
|
265
|
+
}
|
|
109
266
|
// Cleanup expired dedup entries every 5 minutes
|
|
110
267
|
setInterval(() => {
|
|
111
268
|
const now = Date.now();
|
|
112
269
|
for (const [key, value] of dedupCache) {
|
|
113
|
-
if (
|
|
270
|
+
if (!isFresh(value, now)) {
|
|
114
271
|
dedupCache.delete(key);
|
|
115
272
|
}
|
|
116
273
|
}
|
|
@@ -845,26 +1002,36 @@ export function generateDedupKey(method, url, params, data) {
|
|
|
845
1002
|
return createHash('sha256').update(payload).digest('hex');
|
|
846
1003
|
}
|
|
847
1004
|
/**
|
|
848
|
-
* Check if a request was recently processed (within
|
|
1005
|
+
* Check if a request was recently processed (within X402_DEDUP_TTL_MS).
|
|
1006
|
+
*
|
|
1007
|
+
* Loads persisted entries first so a payment broadcast before a server restart
|
|
1008
|
+
* still suppresses the retry that follows it.
|
|
1009
|
+
*
|
|
849
1010
|
* @returns txid if duplicate found, null otherwise
|
|
850
1011
|
*/
|
|
851
|
-
export function checkDedupCache(key) {
|
|
1012
|
+
export async function checkDedupCache(key) {
|
|
1013
|
+
await ensureDedupLoaded();
|
|
852
1014
|
const cached = dedupCache.get(key);
|
|
853
1015
|
if (!cached) {
|
|
854
1016
|
return null;
|
|
855
1017
|
}
|
|
856
1018
|
const now = Date.now();
|
|
857
|
-
if (
|
|
1019
|
+
if (!isFresh(cached, now)) {
|
|
858
1020
|
dedupCache.delete(key);
|
|
859
1021
|
return null;
|
|
860
1022
|
}
|
|
861
1023
|
return cached.txid;
|
|
862
1024
|
}
|
|
863
1025
|
/**
|
|
864
|
-
* Record a transaction in the dedup cache
|
|
1026
|
+
* Record a transaction in the dedup cache and persist it.
|
|
1027
|
+
*
|
|
1028
|
+
* Awaits the write so the entry survives a crash immediately after a payment —
|
|
1029
|
+
* the window where losing it costs a duplicate payment.
|
|
865
1030
|
*/
|
|
866
|
-
export function recordTransaction(key, txid) {
|
|
1031
|
+
export async function recordTransaction(key, txid) {
|
|
1032
|
+
await ensureDedupLoaded();
|
|
867
1033
|
dedupCache.set(key, { txid, timestamp: Date.now() });
|
|
1034
|
+
await persistDedupCache();
|
|
868
1035
|
}
|
|
869
1036
|
/**
|
|
870
1037
|
* Check if account has sufficient balance to pay for x402 endpoint.
|