@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.
@@ -0,0 +1,1373 @@
1
+ /**
2
+ * AIBTC News Legion tools (news-gov-v5).
3
+ *
4
+ * Lifecycle: inscribe → reveal → propose → vote → veto → conclude, plus
5
+ * contribute (buys weight) and sponsor (does not).
6
+ *
7
+ * These tools pin their own network: `getLegionAccount()` re-derives the
8
+ * unlocked wallet for the legion's chain, so a mainnet-configured server signs
9
+ * against testnet. Inscription is the exception — real BTC on whatever chain
10
+ * NETWORK names.
11
+ *
12
+ * Fund-moving calls sign in DENY mode with an exact post-condition.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { z } from "zod";
16
+ import { Pc, boolCV, noneCV, principalCV, someCV, stringAsciiCV, uintCV, PostConditionMode, } from "@stacks/transactions";
17
+ import { NETWORK, getExplorerTxUrl } from "../config/networks.js";
18
+ import { DUST_THRESHOLD } from "../config/bitcoin-constants.js";
19
+ import { LEGION_ERRORS, LEGION_GOV_CONTRACT, LEGION_NETWORK, LEGION_SITE_URL, LEGION_TREASURY_CONTRACT, MAX_DESCRIPTION_LENGTH, MAX_LINK_LENGTH, MAX_SPONSOR_LINK_LENGTH, MAX_SPONSOR_MEMO_LENGTH, MAX_SPONSOR_NAME_LENGTH, MAX_TITLE_LENGTH, ORDINALS_CONTENT_BASE, ORDINALS_INSCRIPTION_BASE, STORY_STATUS, } from "../config/legion.js";
20
+ import { assertAscii, buildTimeline, contentUrlFromLink, explainError, getClockHeight, getLastProposalId, getLegionAccount, getLegionToken, getParams, getPhase, getProposeStatus, getStory, getStoryMeta, getVetoRecord, getVoteRecord, inscriptionIdFromLink, normalizeOrdinalsLink, num, predictOutcome, proposeBlockers, readGov, readLegionContract, readTreasury, } from "../services/legion.service.js";
21
+ import { callContract } from "../transactions/builder.js";
22
+ import { getWalletManager } from "../services/wallet-manager.js";
23
+ import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
24
+ import { buildCommitTransaction, buildRevealTransaction, deriveRevealScript, } from "../transactions/inscription-builder.js";
25
+ import { buildChildCommitTransaction, buildChildRevealTransaction, deriveChildRevealScript, lookupParentInscription, } from "../transactions/child-inscription-builder.js";
26
+ import { signBtcTransaction } from "../transactions/bitcoin-builder.js";
27
+ import { createErrorResponse, createJsonResponse } from "../utils/index.js";
28
+ const [GOV_ADDRESS, GOV_NAME] = LEGION_GOV_CONTRACT.split(".");
29
+ const [TREASURY_ADDRESS, TREASURY_NAME] = LEGION_TREASURY_CONTRACT.split(".");
30
+ /** Markdown, so the inscription renders as a story rather than a wall of text. */
31
+ const STORY_CONTENT_TYPE = "text/markdown;charset=utf-8";
32
+ /**
33
+ * Bitcoin's standardness limit on a transaction is ~400 kB, and the inscription
34
+ * body rides in the REVEAL witness. An oversized body therefore commits fine and
35
+ * then strands the sats: the reveal is rejected by every node it reaches. Refuse
36
+ * at the commit, with headroom for the script and control block.
37
+ */
38
+ const MAX_INSCRIPTION_BYTES = 390_000;
39
+ /** Large enough to be worth saying out loud before spending the fee on it. */
40
+ const LARGE_INSCRIPTION_WARN_BYTES = 100_000;
41
+ /**
42
+ * Phases that mean the piece is still moving, for the `live` list filter.
43
+ * `pending` is a phase, not a stored status — a proposal is OPEN in storage
44
+ * from the moment it is filed, but voting does not open until votingDelay
45
+ * blocks later.
46
+ */
47
+ const LIVE_PHASES = ["pending", "voting", "veto", "concludable"];
48
+ /** Errors surface as sentences, with the contract's `uXXX` still attached. */
49
+ function legionError(error) {
50
+ return createErrorResponse(new Error(explainError(error, LEGION_ERRORS)));
51
+ }
52
+ /** An sBTC balance on the legion's chain, in sats. */
53
+ async function sbtcBalance(address) {
54
+ const token = await getLegionToken();
55
+ return num(await readLegionContract(token.contract, "get-balance", [
56
+ principalCV(address),
57
+ ]));
58
+ }
59
+ /** The block explorer link for a legion transaction. */
60
+ function explorerUrl(txid) {
61
+ return getExplorerTxUrl(txid, LEGION_NETWORK);
62
+ }
63
+ /**
64
+ * Resolve a named fee tier against live mempool estimates.
65
+ *
66
+ * The builders reject `feeRate <= 0`, which `NaN` slips past — and a NaN rate
67
+ * propagates through every size calculation into a transaction with garbage
68
+ * amounts. mempool.space does intermittently return junk here, so the estimate
69
+ * is validated as a finite positive number rather than trusted.
70
+ */
71
+ async function resolveFeeRate(mempoolApi, feeRate) {
72
+ let resolved;
73
+ if (typeof feeRate === "number") {
74
+ resolved = feeRate;
75
+ }
76
+ else {
77
+ const fees = await mempoolApi.getFeeEstimates();
78
+ resolved =
79
+ feeRate === "fast"
80
+ ? fees.fastestFee
81
+ : feeRate === "slow"
82
+ ? fees.hourFee
83
+ : fees.halfHourFee;
84
+ if (!Number.isFinite(resolved) || resolved <= 0) {
85
+ throw new Error(`mempool.space returned an unusable fee estimate (${resolved}) for the ` +
86
+ `"${feeRate ?? "medium"}" tier. Pass an explicit feeRate in sat/vB.`);
87
+ }
88
+ }
89
+ if (!Number.isFinite(resolved) || resolved <= 0) {
90
+ throw new Error(`feeRate must be a positive number of sat/vB, got ${resolved}.`);
91
+ }
92
+ return resolved;
93
+ }
94
+ /** The markdown a title+body pair inscribes to. One definition, both steps. */
95
+ function buildStoryMarkdown(title, body) {
96
+ return body.trimStart().startsWith("#") ? body : `# ${title}\n\n${body}`;
97
+ }
98
+ /**
99
+ * Check the commit actually paid the reveal script we just derived.
100
+ *
101
+ * The reveal script is derived from the content, so one stray character between
102
+ * commit and reveal yields a different address, an unspendable commit and lost
103
+ * sats. Comparing against the commit's real output catches that — and a wrong
104
+ * commitTxid, parent or revealAmount — before anything is signed.
105
+ */
106
+ async function assertCommitMatches(mempoolApi, commitTxid, derivedRevealAddress, revealAmount) {
107
+ if (!derivedRevealAddress) {
108
+ throw new Error("Could not derive a reveal address for this content.");
109
+ }
110
+ const tx = await mempoolApi.getTx(commitTxid);
111
+ const output = tx.vout[0];
112
+ if (!output) {
113
+ throw new Error(`Commit ${commitTxid} has no output 0.`);
114
+ }
115
+ if (output.scriptpubkey_address !== derivedRevealAddress) {
116
+ throw new Error(`This content does not match commit ${commitTxid}. It paid ` +
117
+ `${output.scriptpubkey_address}, but this title/body derives ` +
118
+ `${derivedRevealAddress}. Pass the exact title, body and parentInscriptionId ` +
119
+ `used at commit. Nothing was broadcast.`);
120
+ }
121
+ if (output.value !== revealAmount) {
122
+ throw new Error(`revealAmount ${revealAmount} does not match commit output 0, which holds ` +
123
+ `${output.value} sats. Use ${output.value}. Nothing was broadcast.`);
124
+ }
125
+ }
126
+ export function registerLegionTools(server) {
127
+ // ==========================================================================
128
+ // legion_status — the whole legion in one read
129
+ // ==========================================================================
130
+ server.registerTool("legion_status", {
131
+ description: "News Legion at a glance: sBTC pool, total weight, governance params read from the " +
132
+ "contract, current height, and your own weight if a wallet is unlocked. Start here.\n\n" +
133
+ `Reads only. Runs on Stacks ${LEGION_NETWORK} regardless of this server's NETWORK.`,
134
+ inputSchema: {},
135
+ }, async () => {
136
+ try {
137
+ const [params, clock, pool, weighted, totalWeight, lastProposalId, nextProposeHeight, quoteDraw, minSponsor, govWiring,] = await Promise.all([
138
+ getParams(),
139
+ getClockHeight(),
140
+ readTreasury("get-balance"),
141
+ readTreasury("get-weighted-balance"),
142
+ readGov("get-total-weight"),
143
+ getLastProposalId(),
144
+ readGov("get-next-propose-height"),
145
+ readGov("quote-draw"),
146
+ readTreasury("get-min-sponsor"),
147
+ readTreasury("get-gov"),
148
+ ]);
149
+ const walletManager = getWalletManager();
150
+ let you;
151
+ if (walletManager.isUnlocked() || process.env.CLIENT_MNEMONIC) {
152
+ const account = await getLegionAccount();
153
+ const [weight, freeWeight, liveProposal, balance] = await Promise.all([
154
+ readGov("get-weight", [principalCV(account.address)]),
155
+ readGov("get-free-weight", [principalCV(account.address)]),
156
+ readGov("get-live-proposal", [principalCV(account.address)]),
157
+ sbtcBalance(account.address),
158
+ ]);
159
+ you = {
160
+ address: account.address,
161
+ weight: num(weight),
162
+ freeWeight: num(freeWeight),
163
+ liveProposalId: liveProposal === null ? null : num(liveProposal),
164
+ sbtcBalanceSats: balance,
165
+ sharePct: num(totalWeight) > 0
166
+ ? Number(((num(weight) * 100) / num(totalWeight)).toFixed(4))
167
+ : 0,
168
+ };
169
+ }
170
+ return createJsonResponse({
171
+ network: LEGION_NETWORK,
172
+ contracts: {
173
+ governance: LEGION_GOV_CONTRACT,
174
+ treasury: LEGION_TREASURY_CONTRACT,
175
+ sbtcToken: (await getLegionToken()).contract,
176
+ treasuryWiredTo: govWiring,
177
+ },
178
+ site: LEGION_SITE_URL,
179
+ clock: {
180
+ timingMode: clock.timingMode,
181
+ countsOn: `${clock.clock} blocks`,
182
+ currentHeight: clock.height,
183
+ },
184
+ pool: {
185
+ balanceSats: num(pool),
186
+ weightedBalanceSats: num(weighted),
187
+ sponsoredSats: num(pool) - num(weighted),
188
+ note: "New weight is priced against weightedBalance (contributed sats only), " +
189
+ "so sponsorships never raise the cost of joining — but the draw is a " +
190
+ "fraction of the WHOLE pool, so they do enlarge every payout.",
191
+ },
192
+ governance: {
193
+ totalWeight: num(totalWeight),
194
+ lastProposalId,
195
+ nextProposeHeight: num(nextProposeHeight),
196
+ proposeSlotOpen: num(nextProposeHeight) <= clock.height,
197
+ currentDrawSats: num(quoteDraw),
198
+ minSponsorSats: num(minSponsor),
199
+ params,
200
+ },
201
+ you,
202
+ nextSteps: [
203
+ "legion_list_stories — what is open for a vote right now",
204
+ "legion_my_position — your weight, bond and whether you can propose",
205
+ "legion_contribute — buy voting weight with sBTC",
206
+ "legion_sponsor — fund the pool without buying a vote",
207
+ ],
208
+ });
209
+ }
210
+ catch (error) {
211
+ return legionError(error);
212
+ }
213
+ });
214
+ // ==========================================================================
215
+ // legion_list_stories — the feed
216
+ // ==========================================================================
217
+ server.registerTool("legion_list_stories", {
218
+ description: "Proposals newest first, with phase, tally and the inscription each points at.\n\n" +
219
+ "Phases: `pending` (filed, voting not open yet), `voting`, `veto`, `concludable`, " +
220
+ "`passed`/`failed` (settled), `expired` (nobody concluded in time; can no longer pay).\n\n" +
221
+ "Reads only.",
222
+ inputSchema: {
223
+ phase: z
224
+ .enum([
225
+ "all",
226
+ "live",
227
+ "pending",
228
+ "voting",
229
+ "veto",
230
+ "concludable",
231
+ "passed",
232
+ "failed",
233
+ "expired",
234
+ ])
235
+ .optional()
236
+ .describe("Filter by phase. 'live' means pending+voting+veto+concludable. Default: all"),
237
+ limit: z
238
+ .number()
239
+ .int()
240
+ .positive()
241
+ .max(50)
242
+ .optional()
243
+ .describe("How many proposals to scan back from the newest (default 10)"),
244
+ },
245
+ }, async ({ phase = "all", limit = 10 }) => {
246
+ try {
247
+ const [params, clock, lastProposalId, pool] = await Promise.all([
248
+ getParams(),
249
+ getClockHeight(),
250
+ getLastProposalId(),
251
+ readTreasury("get-balance"),
252
+ ]);
253
+ if (lastProposalId === 0) {
254
+ return createJsonResponse({
255
+ network: LEGION_NETWORK,
256
+ totalProposals: 0,
257
+ stories: [],
258
+ message: "No proposals have been filed in this legion yet.",
259
+ });
260
+ }
261
+ const firstId = Math.max(1, lastProposalId - limit + 1);
262
+ const ids = [];
263
+ for (let id = lastProposalId; id >= firstId; id--)
264
+ ids.push(id);
265
+ const rows = await Promise.all(ids.map(async (proposalId) => {
266
+ const [story, meta, storyPhase] = await Promise.all([
267
+ getStory(proposalId),
268
+ getStoryMeta(proposalId),
269
+ getPhase(proposalId),
270
+ ]);
271
+ if (!story)
272
+ return null;
273
+ const timeline = buildTimeline(story, params, clock.height, clock.clock);
274
+ const prediction = predictOutcome(story, params, num(pool), clock.height);
275
+ return {
276
+ proposalId,
277
+ phase: storyPhase,
278
+ status: STORY_STATUS[story.status],
279
+ reason: story.reason || undefined,
280
+ title: meta?.title ?? "",
281
+ link: meta?.link ?? "",
282
+ inscriptionId: meta ? inscriptionIdFromLink(meta.link) : null,
283
+ contentUrl: meta ? contentUrlFromLink(meta.link) : null,
284
+ proposer: story.proposer,
285
+ drawSats: story.draw,
286
+ tally: {
287
+ yes: story.yesWeight,
288
+ no: story.noWeight,
289
+ veto: story.vetoWeight,
290
+ voters: story.voterCount,
291
+ eligible: story.eligibleSnapshot,
292
+ },
293
+ blocksUntilNextTransition: timeline.blocksUntilNextTransition,
294
+ ifConcludedNow: prediction.settled
295
+ ? undefined
296
+ : `${prediction.outcome} (${prediction.reason})`,
297
+ };
298
+ }));
299
+ const stories = rows
300
+ .filter((row) => row !== null)
301
+ .filter((row) => {
302
+ if (phase === "all")
303
+ return true;
304
+ if (phase === "live")
305
+ return LIVE_PHASES.includes(row.phase);
306
+ return row.phase === phase;
307
+ });
308
+ return createJsonResponse({
309
+ network: LEGION_NETWORK,
310
+ currentHeight: clock.height,
311
+ clock: `${clock.clock} blocks`,
312
+ totalProposals: lastProposalId,
313
+ scanned: ids.length,
314
+ filter: phase,
315
+ stories,
316
+ note: "Call legion_get_story for the full record, the timeline and what conclude " +
317
+ "would decide. Open the link to read the piece before voting — the contract " +
318
+ "cannot see the inscription and does not check it.",
319
+ });
320
+ }
321
+ catch (error) {
322
+ return legionError(error);
323
+ }
324
+ });
325
+ // ==========================================================================
326
+ // legion_get_story — one proposal in full
327
+ // ==========================================================================
328
+ server.registerTool("legion_get_story", {
329
+ description: "One proposal in full: tally against the quorum and threshold it must clear, the " +
330
+ "window timeline, what `conclude` would decide now, and whether you have voted.\n\n" +
331
+ "Read this before voting — the contract cannot read the inscription, so judging the " +
332
+ "work is the voter's job.\n\nReads only.",
333
+ inputSchema: {
334
+ proposalId: z.number().int().positive().describe("The proposal id"),
335
+ },
336
+ }, async ({ proposalId }) => {
337
+ try {
338
+ const [params, clock, story, meta, storyPhase, pool] = await Promise.all([
339
+ getParams(),
340
+ getClockHeight(),
341
+ getStory(proposalId),
342
+ getStoryMeta(proposalId),
343
+ getPhase(proposalId),
344
+ readTreasury("get-balance"),
345
+ ]);
346
+ if (!story) {
347
+ return createErrorResponse(new Error(`No proposal ${proposalId} in ${LEGION_GOV_CONTRACT}. ` +
348
+ `Call legion_list_stories to see what exists.`));
349
+ }
350
+ const timeline = buildTimeline(story, params, clock.height, clock.clock);
351
+ const prediction = predictOutcome(story, params, num(pool), clock.height);
352
+ const walletManager = getWalletManager();
353
+ let you;
354
+ if (walletManager.isUnlocked() || process.env.CLIENT_MNEMONIC) {
355
+ const account = await getLegionAccount();
356
+ const [voteRecord, vetoRecord, weight] = await Promise.all([
357
+ getVoteRecord(proposalId, account.address),
358
+ getVetoRecord(proposalId, account.address),
359
+ readGov("get-weight", [principalCV(account.address)]),
360
+ ]);
361
+ you = {
362
+ address: account.address,
363
+ weight: num(weight),
364
+ isProposer: account.address === story.proposer,
365
+ voted: voteRecord,
366
+ vetoed: vetoRecord,
367
+ canVoteNow: storyPhase === "voting" &&
368
+ !voteRecord &&
369
+ account.address !== story.proposer &&
370
+ num(weight) >= params.minWeight,
371
+ canVetoNow: storyPhase === "veto" &&
372
+ !vetoRecord &&
373
+ num(weight) >= params.minWeight,
374
+ canConcludeNow: storyPhase === "concludable",
375
+ };
376
+ }
377
+ return createJsonResponse({
378
+ proposalId,
379
+ network: LEGION_NETWORK,
380
+ phase: storyPhase,
381
+ status: STORY_STATUS[story.status],
382
+ reason: story.reason || undefined,
383
+ title: meta?.title ?? "",
384
+ description: meta?.description ?? "",
385
+ link: meta?.link ?? "",
386
+ inscriptionId: meta ? inscriptionIdFromLink(meta.link) : null,
387
+ contentUrl: meta ? contentUrlFromLink(meta.link) : null,
388
+ proposer: story.proposer,
389
+ drawSats: story.draw,
390
+ bondWeight: story.bond,
391
+ tally: {
392
+ yesWeight: story.yesWeight,
393
+ noWeight: story.noWeight,
394
+ vetoWeight: story.vetoWeight,
395
+ castWeight: prediction.cast,
396
+ voterCount: story.voterCount,
397
+ eligibleSnapshot: story.eligibleSnapshot,
398
+ },
399
+ thresholds: {
400
+ quorumPct: prediction.quorumPct,
401
+ quorumRequiredPct: params.votingQuorum,
402
+ quorumMet: prediction.quorumMet,
403
+ yesPct: prediction.yesPct,
404
+ thresholdRequiredPct: params.votingThreshold,
405
+ thresholdMet: prediction.thresholdMet,
406
+ vetoPct: prediction.vetoPct,
407
+ vetoQuorumPct: params.vetoQuorum,
408
+ vetoed: prediction.vetoed,
409
+ participants: `${story.voterCount}/${params.minParticipants}`,
410
+ participantsMet: prediction.participantsMet,
411
+ },
412
+ timeline: {
413
+ ...timeline,
414
+ explanation: `Filed at ${timeline.createdAt}. Voting opens at ${timeline.votingOpensAt} ` +
415
+ `and closes at ${timeline.voteEnd}. Veto runs until ${timeline.vetoEnd}. ` +
416
+ `Conclude must be called before ${timeline.concludeDeadline} — after that ` +
417
+ `the piece has expired and can no longer be concluded at all.`,
418
+ },
419
+ outcome: {
420
+ settled: prediction.settled,
421
+ label: prediction.settled ? "recorded on chain" : "if concluded now",
422
+ outcome: prediction.outcome,
423
+ reason: prediction.reason,
424
+ payoutSats: prediction.payout,
425
+ payee: prediction.payout > 0 ? story.proposer : null,
426
+ },
427
+ you,
428
+ readTheWork: meta ? contentUrlFromLink(meta.link) ?? meta.link : undefined,
429
+ storyUrl: LEGION_SITE_URL,
430
+ });
431
+ }
432
+ catch (error) {
433
+ return legionError(error);
434
+ }
435
+ });
436
+ // ==========================================================================
437
+ // legion_my_position — your standing
438
+ // ==========================================================================
439
+ server.registerTool("legion_my_position", {
440
+ description: "Your weight, share, bond lock, sBTC balance, and every propose precondition folded " +
441
+ "from the contract's `propose-status` — so a blocked propose names the gate to wait on.\n\n" +
442
+ "Requires an unlocked wallet. Signs nothing.",
443
+ inputSchema: {},
444
+ }, async () => {
445
+ try {
446
+ const account = await getLegionAccount();
447
+ const [params, clock, weight, freeWeight, locked, liveProposal, totalWeight, proposeStatus, balance, quoteDraw] = await Promise.all([
448
+ getParams(),
449
+ getClockHeight(),
450
+ readGov("get-weight", [principalCV(account.address)]),
451
+ readGov("get-free-weight", [principalCV(account.address)]),
452
+ readGov("locked-of", [principalCV(account.address)]),
453
+ readGov("get-live-proposal", [principalCV(account.address)]),
454
+ readGov("get-total-weight"),
455
+ getProposeStatus(account.address),
456
+ sbtcBalance(account.address),
457
+ readGov("quote-draw"),
458
+ ]);
459
+ const blockers = proposeBlockers(proposeStatus, params.minWeight);
460
+ return createJsonResponse({
461
+ address: account.address,
462
+ network: LEGION_NETWORK,
463
+ currentHeight: clock.height,
464
+ weight: num(weight),
465
+ freeWeight: num(freeWeight),
466
+ lockedWeight: num(locked),
467
+ sharePct: num(totalWeight) > 0
468
+ ? Number(((num(weight) * 100) / num(totalWeight)).toFixed(4))
469
+ : 0,
470
+ totalWeight: num(totalWeight),
471
+ sbtcBalanceSats: balance,
472
+ liveProposalId: liveProposal === null ? null : num(liveProposal),
473
+ canVote: num(weight) >= params.minWeight,
474
+ propose: {
475
+ canPropose: proposeStatus.canPropose,
476
+ blockers: blockers.length > 0 ? blockers : undefined,
477
+ weightLockedOnPropose: proposeStatus.lockOnPropose,
478
+ wouldPaySats: proposeStatus.draw,
479
+ nextProposeHeight: proposeStatus.nextProposeHeight,
480
+ note: "Proposing locks your ENTIRE weight until the piece resolves. The lock is " +
481
+ "never spent and never reduces your voting power — it exists only to enforce " +
482
+ "one live proposal per principal, and releases on every outcome.",
483
+ },
484
+ currentDrawSats: num(quoteDraw),
485
+ minWeightToVoteOrPropose: params.minWeight,
486
+ minContributionSats: params.minContribution,
487
+ });
488
+ }
489
+ catch (error) {
490
+ return legionError(error);
491
+ }
492
+ });
493
+ // ==========================================================================
494
+ // legion_faucet — testnet sBTC
495
+ // ==========================================================================
496
+ server.registerTool("legion_faucet", {
497
+ description: "Mint testnet sBTC from the faucet on this legion's token contract.\n\n" +
498
+ "Testnet only. Requires an unlocked wallet.",
499
+ inputSchema: {},
500
+ }, async () => {
501
+ try {
502
+ if (LEGION_NETWORK !== "testnet") {
503
+ return createErrorResponse(new Error(`The legion is on ${LEGION_NETWORK}, which has no sBTC faucet. ` +
504
+ `Acquire sBTC with sbtc_deposit or styx_deposit instead.`));
505
+ }
506
+ const account = await getLegionAccount();
507
+ const token = await getLegionToken();
508
+ const [tokenAddress, tokenName] = token.contract.split(".");
509
+ const result = await callContract(account, {
510
+ contractAddress: tokenAddress,
511
+ contractName: tokenName,
512
+ functionName: "faucet",
513
+ functionArgs: [],
514
+ // A faucet mints rather than transfers, and mints have no sender to
515
+ // write a condition against.
516
+ postConditionMode: PostConditionMode.Allow,
517
+ postConditions: [],
518
+ });
519
+ return createJsonResponse({
520
+ success: true,
521
+ txid: result.txid,
522
+ explorerUrl: explorerUrl(result.txid),
523
+ address: account.address,
524
+ token: token.contract,
525
+ network: LEGION_NETWORK,
526
+ nextStep: "Once it confirms, call legion_contribute to turn sBTC into voting weight.",
527
+ });
528
+ }
529
+ catch (error) {
530
+ return legionError(error);
531
+ }
532
+ });
533
+ // ==========================================================================
534
+ // legion_contribute — buy voting weight
535
+ // ==========================================================================
536
+ server.registerTool("legion_contribute", {
537
+ description: "Send sBTC to the pool and receive voting weight proportional to your share of the " +
538
+ "contributed balance.\n\n" +
539
+ "NOT REFUNDABLE — you get a say in which pieces get paid, not a claim on the pool. " +
540
+ "To fund the pool without a vote, use legion_sponsor.\n\n" +
541
+ "Requires an unlocked wallet.",
542
+ inputSchema: {
543
+ sats: z
544
+ .number()
545
+ .int()
546
+ .positive()
547
+ .describe("Amount of sBTC to contribute, in sats"),
548
+ },
549
+ }, async ({ sats }) => {
550
+ try {
551
+ const account = await getLegionAccount();
552
+ const [params, token, balance, weightBefore] = await Promise.all([
553
+ getParams(),
554
+ getLegionToken(),
555
+ sbtcBalance(account.address),
556
+ readGov("get-weight", [principalCV(account.address)]),
557
+ ]);
558
+ if (sats < params.minContribution) {
559
+ return createErrorResponse(new Error(`${sats} sats is below the legion's minimum contribution of ` +
560
+ `${params.minContribution} sats (u437).`));
561
+ }
562
+ if (balance < sats) {
563
+ return createErrorResponse(new Error(`Your sBTC balance is ${balance} sats — not enough to contribute ${sats}. ` +
564
+ (LEGION_NETWORK === "testnet"
565
+ ? "Call legion_faucet for testnet sBTC."
566
+ : "Acquire sBTC first.")));
567
+ }
568
+ const quotedWeight = num(await readGov("quote-weight", [uintCV(sats)]));
569
+ const result = await callContract(account, {
570
+ contractAddress: GOV_ADDRESS,
571
+ contractName: GOV_NAME,
572
+ functionName: "contribute",
573
+ functionArgs: [uintCV(sats)],
574
+ postConditionMode: PostConditionMode.Deny,
575
+ postConditions: [
576
+ Pc.principal(account.address)
577
+ .willSendEq(sats)
578
+ .ft(token.contract, token.assetName),
579
+ ],
580
+ });
581
+ return createJsonResponse({
582
+ success: true,
583
+ txid: result.txid,
584
+ explorerUrl: explorerUrl(result.txid),
585
+ address: account.address,
586
+ contributedSats: sats,
587
+ expectedWeightMinted: quotedWeight,
588
+ weightBefore: num(weightBefore),
589
+ expectedWeightAfter: num(weightBefore) + quotedWeight,
590
+ network: LEGION_NETWORK,
591
+ warning: "Contributions are final. There is no withdrawal path.",
592
+ nextStep: quotedWeight + num(weightBefore) >= params.minWeight
593
+ ? "Once it confirms you can legion_vote and legion_propose_story."
594
+ : `You will still be below minWeight (${params.minWeight}) — contribute more to vote or propose.`,
595
+ });
596
+ }
597
+ catch (error) {
598
+ return legionError(error);
599
+ }
600
+ });
601
+ // ==========================================================================
602
+ // legion_sponsor — fund the pool, weight-less
603
+ // ==========================================================================
604
+ server.registerTool("legion_sponsor", {
605
+ description: "Fund the pool WITHOUT minting voting weight, with a name on the record.\n\n" +
606
+ "Does not raise the price of joining (weight is priced against contributed sats only), " +
607
+ "but does enlarge every payout, since the draw is a fraction of the whole pool.\n\n" +
608
+ "`name`, `link` and `memo` are unverified — the paying principal and txid are the real " +
609
+ "identity. FINAL: no refund path exists.\n\n" +
610
+ "Requires an unlocked wallet.",
611
+ inputSchema: {
612
+ sats: z
613
+ .number()
614
+ .int()
615
+ .positive()
616
+ .describe("Amount of sBTC to sponsor, in sats (must meet the treasury's minimum)"),
617
+ name: z
618
+ .string()
619
+ .min(1)
620
+ .max(MAX_SPONSOR_NAME_LENGTH)
621
+ .describe(`Sponsor name as it should be attributed (ASCII, ≤${MAX_SPONSOR_NAME_LENGTH} chars)`),
622
+ link: z
623
+ .string()
624
+ .max(MAX_SPONSOR_LINK_LENGTH)
625
+ .optional()
626
+ .describe(`Optional sponsor URL (ASCII, ≤${MAX_SPONSOR_LINK_LENGTH} chars)`),
627
+ memo: z
628
+ .string()
629
+ .max(MAX_SPONSOR_MEMO_LENGTH)
630
+ .optional()
631
+ .describe(`Optional free-form note on the record (ASCII, ≤${MAX_SPONSOR_MEMO_LENGTH} chars)`),
632
+ },
633
+ }, async ({ sats, name, link, memo }) => {
634
+ try {
635
+ assertAscii(name, "name", MAX_SPONSOR_NAME_LENGTH);
636
+ if (link)
637
+ assertAscii(link, "link", MAX_SPONSOR_LINK_LENGTH);
638
+ if (memo)
639
+ assertAscii(memo, "memo", MAX_SPONSOR_MEMO_LENGTH);
640
+ const account = await getLegionAccount();
641
+ const [token, minSponsor, balance] = await Promise.all([
642
+ getLegionToken(),
643
+ readTreasury("get-min-sponsor"),
644
+ sbtcBalance((await getLegionAccount()).address),
645
+ ]);
646
+ if (sats < num(minSponsor)) {
647
+ return createErrorResponse(new Error(`${sats} sats is below the treasury's minimum sponsorship of ` +
648
+ `${num(minSponsor)} sats (u450).`));
649
+ }
650
+ if (balance < sats) {
651
+ return createErrorResponse(new Error(`Your sBTC balance is ${balance} sats — not enough to sponsor ${sats}.`));
652
+ }
653
+ const result = await callContract(account, {
654
+ contractAddress: TREASURY_ADDRESS,
655
+ contractName: TREASURY_NAME,
656
+ functionName: "sponsor-in",
657
+ functionArgs: [
658
+ uintCV(sats),
659
+ stringAsciiCV(name),
660
+ link ? someCV(stringAsciiCV(link)) : noneCV(),
661
+ stringAsciiCV(memo ?? ""),
662
+ ],
663
+ postConditionMode: PostConditionMode.Deny,
664
+ postConditions: [
665
+ Pc.principal(account.address)
666
+ .willSendEq(sats)
667
+ .ft(token.contract, token.assetName),
668
+ ],
669
+ });
670
+ return createJsonResponse({
671
+ success: true,
672
+ txid: result.txid,
673
+ explorerUrl: explorerUrl(result.txid),
674
+ from: account.address,
675
+ sponsoredSats: sats,
676
+ name,
677
+ link: link ?? null,
678
+ memo: memo ?? "",
679
+ mintedWeight: 0,
680
+ network: LEGION_NETWORK,
681
+ warning: "Final. No refund path exists. This mints no voting weight — use " +
682
+ "legion_contribute if you wanted a vote.",
683
+ note: "How long a sponsor badge shows is decided off-chain by the reader at " +
684
+ `${LEGION_SITE_URL}, not by the contract.`,
685
+ });
686
+ }
687
+ catch (error) {
688
+ return legionError(error);
689
+ }
690
+ });
691
+ // ==========================================================================
692
+ // legion_propose_story — open the vote on an inscribed piece
693
+ // ==========================================================================
694
+ server.registerTool("legion_propose_story", {
695
+ description: "Open a vote on one inscribed piece. If it passes, YOU are paid the draw — the " +
696
+ "proposer is the only reachable payee.\n\n" +
697
+ "The contract stores link, title and description verbatim and never reads the " +
698
+ "inscription. Proposing locks your ENTIRE weight until the piece resolves — one live " +
699
+ "proposal per principal; the lock is never spent and releases on every outcome.\n\n" +
700
+ "Pre-flights every precondition and refuses locally with the specific blocker.\n\n" +
701
+ "Requires an unlocked wallet.",
702
+ inputSchema: {
703
+ link: z
704
+ .string()
705
+ .min(1)
706
+ .describe("Inscription id (<64-hex-txid>i0) or a full ordinals URL. " +
707
+ "A bare id is expanded to " + ORDINALS_INSCRIPTION_BASE + "<id>"),
708
+ title: z
709
+ .string()
710
+ .min(1)
711
+ .max(MAX_TITLE_LENGTH)
712
+ .describe(`Headline (ASCII, ≤${MAX_TITLE_LENGTH} chars)`),
713
+ description: z
714
+ .string()
715
+ .max(MAX_DESCRIPTION_LENGTH)
716
+ .optional()
717
+ .describe(`Why this piece is worth paying for (ASCII, ≤${MAX_DESCRIPTION_LENGTH} chars). ` +
718
+ "Stored on chain but not emitted in the event — voters read it via legion_get_story."),
719
+ },
720
+ }, async ({ link, title, description }) => {
721
+ try {
722
+ const normalizedLink = normalizeOrdinalsLink(link);
723
+ assertAscii(normalizedLink, "link", MAX_LINK_LENGTH);
724
+ assertAscii(title, "title", MAX_TITLE_LENGTH);
725
+ const body = description ?? "";
726
+ assertAscii(body, "description", MAX_DESCRIPTION_LENGTH);
727
+ const account = await getLegionAccount();
728
+ const [params, proposeStatus, clock] = await Promise.all([
729
+ getParams(),
730
+ getProposeStatus(account.address),
731
+ getClockHeight(),
732
+ ]);
733
+ if (!proposeStatus.canPropose) {
734
+ const blockers = proposeBlockers(proposeStatus, params.minWeight);
735
+ return createErrorResponse(new Error(`Cannot propose right now: ${blockers.join("; ")}. ` +
736
+ `Current height ${clock.height}. Nothing was signed.`));
737
+ }
738
+ const result = await callContract(account, {
739
+ contractAddress: GOV_ADDRESS,
740
+ contractName: GOV_NAME,
741
+ functionName: "propose-story",
742
+ functionArgs: [
743
+ stringAsciiCV(normalizedLink),
744
+ stringAsciiCV(title),
745
+ stringAsciiCV(body),
746
+ ],
747
+ // The bond is weight, not sats: no sBTC moves on propose, so DENY with
748
+ // no conditions is exactly right.
749
+ postConditionMode: PostConditionMode.Deny,
750
+ postConditions: [],
751
+ });
752
+ const votingOpensAt = clock.height + params.votingDelay;
753
+ const voteEnd = votingOpensAt + params.voteWindow;
754
+ return createJsonResponse({
755
+ success: true,
756
+ txid: result.txid,
757
+ explorerUrl: explorerUrl(result.txid),
758
+ proposer: account.address,
759
+ link: normalizedLink,
760
+ inscriptionId: inscriptionIdFromLink(normalizedLink),
761
+ title,
762
+ description: body,
763
+ network: LEGION_NETWORK,
764
+ weightLocked: proposeStatus.lockOnPropose,
765
+ expectedDrawSats: proposeStatus.draw,
766
+ expectedTimeline: {
767
+ note: "Approximate — the contract snapshots these at the block that mines the tx.",
768
+ filedAtAbout: clock.height,
769
+ votingOpensAbout: votingOpensAt,
770
+ voteEndsAbout: voteEnd,
771
+ vetoEndsAbout: voteEnd + params.vetoWindow,
772
+ concludeDeadlineAbout: voteEnd + params.vetoWindow + params.concludeWindow,
773
+ clock: `${clock.clock} blocks`,
774
+ },
775
+ nextSteps: [
776
+ "legion_list_stories to pick up the assigned proposalId once it confirms",
777
+ `Votes cannot be cast for ~${params.votingDelay} blocks (the pending period)`,
778
+ "Someone must call legion_conclude inside the conclude window or the piece expires and pays nobody",
779
+ ],
780
+ });
781
+ }
782
+ catch (error) {
783
+ return legionError(error);
784
+ }
785
+ });
786
+ // ==========================================================================
787
+ // legion_vote
788
+ // ==========================================================================
789
+ server.registerTool("legion_vote", {
790
+ description: "Vote yes or no with your current weight. One vote per principal; a proposer cannot " +
791
+ "vote on their own piece.\n\n" +
792
+ "Read the inscription first — legion_get_story gives you the link.\n\n" +
793
+ "Pre-flights phase, weight and prior vote. Requires an unlocked wallet.",
794
+ inputSchema: {
795
+ proposalId: z.number().int().positive().describe("The proposal id"),
796
+ support: z
797
+ .boolean()
798
+ .describe("true = yes (pay this piece), false = no"),
799
+ },
800
+ }, async ({ proposalId, support }) => {
801
+ try {
802
+ const account = await getLegionAccount();
803
+ const [params, story, storyPhase, weight, existingVote, clock] = await Promise.all([
804
+ getParams(),
805
+ getStory(proposalId),
806
+ getPhase(proposalId),
807
+ readGov("get-weight", [principalCV(account.address)]),
808
+ getVoteRecord(proposalId, account.address),
809
+ getClockHeight(),
810
+ ]);
811
+ if (!story) {
812
+ return createErrorResponse(new Error(`No proposal ${proposalId} in this legion (u404).`));
813
+ }
814
+ if (existingVote) {
815
+ return createErrorResponse(new Error(`You already voted ${existingVote.support ? "yes" : "no"} on proposal ` +
816
+ `${proposalId} with ${existingVote.weight} weight (u405). Votes cannot be changed.`));
817
+ }
818
+ if (account.address === story.proposer) {
819
+ return createErrorResponse(new Error(`You are the proposer of ${proposalId} and cannot vote on your own piece (u423). ` +
820
+ `You may still legion_veto it to withdraw it.`));
821
+ }
822
+ if (storyPhase === "pending") {
823
+ const opensAt = story.createdAt + params.votingDelay;
824
+ return createErrorResponse(new Error(`Proposal ${proposalId} is still pending — voting opens at height ${opensAt}, ` +
825
+ `and the tip is ${clock.height} (${opensAt - clock.height} blocks to go, u436).`));
826
+ }
827
+ if (storyPhase !== "voting") {
828
+ return createErrorResponse(new Error(`Proposal ${proposalId} is in the "${storyPhase}" phase — voting is closed (u407). ` +
829
+ (storyPhase === "veto"
830
+ ? "You can still legion_veto it."
831
+ : "Nothing was signed.")));
832
+ }
833
+ if (num(weight) < params.minWeight) {
834
+ return createErrorResponse(new Error(`Your weight is ${num(weight)}, below the minWeight of ${params.minWeight} (u401). ` +
835
+ `Call legion_contribute first.`));
836
+ }
837
+ const result = await callContract(account, {
838
+ contractAddress: GOV_ADDRESS,
839
+ contractName: GOV_NAME,
840
+ functionName: "vote",
841
+ functionArgs: [uintCV(proposalId), boolCV(support)],
842
+ // Voting moves nothing.
843
+ postConditionMode: PostConditionMode.Deny,
844
+ postConditions: [],
845
+ });
846
+ return createJsonResponse({
847
+ success: true,
848
+ txid: result.txid,
849
+ explorerUrl: explorerUrl(result.txid),
850
+ proposalId,
851
+ support,
852
+ votedWith: num(weight),
853
+ voter: account.address,
854
+ network: LEGION_NETWORK,
855
+ voteEnd: story.voteEnd,
856
+ blocksLeftToVote: story.voteEnd - clock.height,
857
+ note: "One vote per principal — this cannot be changed or withdrawn.",
858
+ });
859
+ }
860
+ catch (error) {
861
+ return legionError(error);
862
+ }
863
+ });
864
+ // ==========================================================================
865
+ // legion_veto
866
+ // ==========================================================================
867
+ server.registerTool("legion_veto", {
868
+ description: "Object during the veto window. At veto quorum the piece fails regardless of the vote — " +
869
+ "the backstop against a plagiarised or junk inscription that slipped past. A proposer " +
870
+ "may veto their own piece, which withdraws it.\n\n" +
871
+ "Pre-flights the window and your weight. Requires an unlocked wallet.",
872
+ inputSchema: {
873
+ proposalId: z.number().int().positive().describe("The proposal id"),
874
+ },
875
+ }, async ({ proposalId }) => {
876
+ try {
877
+ const account = await getLegionAccount();
878
+ const [params, story, storyPhase, weight, existingVeto, clock] = await Promise.all([
879
+ getParams(),
880
+ getStory(proposalId),
881
+ getPhase(proposalId),
882
+ readGov("get-weight", [principalCV(account.address)]),
883
+ getVetoRecord(proposalId, account.address),
884
+ getClockHeight(),
885
+ ]);
886
+ if (!story) {
887
+ return createErrorResponse(new Error(`No proposal ${proposalId} in this legion (u404).`));
888
+ }
889
+ if (existingVeto) {
890
+ return createErrorResponse(new Error(`You already vetoed proposal ${proposalId} with ${existingVeto.weight} weight (u425).`));
891
+ }
892
+ if (storyPhase !== "veto") {
893
+ const vetoEnd = story.voteEnd + params.vetoWindow;
894
+ return createErrorResponse(new Error(`Proposal ${proposalId} is in the "${storyPhase}" phase — the veto window is ` +
895
+ `[${story.voteEnd}, ${vetoEnd}) and the tip is ${clock.height} (u424). ` +
896
+ (storyPhase === "voting"
897
+ ? "Vote no with legion_vote while voting is still open."
898
+ : "Nothing was signed.")));
899
+ }
900
+ if (num(weight) < params.minWeight) {
901
+ return createErrorResponse(new Error(`Your weight is ${num(weight)}, below the minWeight of ${params.minWeight} (u401).`));
902
+ }
903
+ const result = await callContract(account, {
904
+ contractAddress: GOV_ADDRESS,
905
+ contractName: GOV_NAME,
906
+ functionName: "veto",
907
+ functionArgs: [uintCV(proposalId)],
908
+ postConditionMode: PostConditionMode.Deny,
909
+ postConditions: [],
910
+ });
911
+ const vetoWeightAfter = story.vetoWeight + num(weight);
912
+ const vetoPctAfter = story.eligibleSnapshot > 0
913
+ ? Math.floor((vetoWeightAfter * 100) / story.eligibleSnapshot)
914
+ : 0;
915
+ return createJsonResponse({
916
+ success: true,
917
+ txid: result.txid,
918
+ explorerUrl: explorerUrl(result.txid),
919
+ proposalId,
920
+ vetoedWith: num(weight),
921
+ vetoWeightAfter,
922
+ vetoPctAfter,
923
+ vetoQuorumPct: params.vetoQuorum,
924
+ wouldBlock: vetoPctAfter >= params.vetoQuorum,
925
+ network: LEGION_NETWORK,
926
+ vetoEnd: story.voteEnd + params.vetoWindow,
927
+ });
928
+ }
929
+ catch (error) {
930
+ return legionError(error);
931
+ }
932
+ });
933
+ // ==========================================================================
934
+ // legion_conclude
935
+ // ==========================================================================
936
+ server.registerTool("legion_conclude", {
937
+ description: "Settle a proposal and, if it passed, pay the proposer the draw. Permissionless — " +
938
+ "anyone may call it, and someone must.\n\n" +
939
+ "A piece nobody concludes inside its window EXPIRES, pays no one, and can never be " +
940
+ "concluded after. Concluding late pays what concluding early would; the draw was " +
941
+ "snapshotted at propose time.\n\n" +
942
+ "Requires an unlocked wallet — caller pays gas, proposer gets the payout.",
943
+ inputSchema: {
944
+ proposalId: z.number().int().positive().describe("The proposal id"),
945
+ },
946
+ }, async ({ proposalId }) => {
947
+ try {
948
+ const account = await getLegionAccount();
949
+ const [params, story, storyPhase, clock, pool, token] = await Promise.all([
950
+ getParams(),
951
+ getStory(proposalId),
952
+ getPhase(proposalId),
953
+ getClockHeight(),
954
+ readTreasury("get-balance"),
955
+ getLegionToken(),
956
+ ]);
957
+ if (!story) {
958
+ return createErrorResponse(new Error(`No proposal ${proposalId} in this legion (u404).`));
959
+ }
960
+ if (storyPhase === "passed" || storyPhase === "failed") {
961
+ return createErrorResponse(new Error(`Proposal ${proposalId} is already concluded: ${STORY_STATUS[story.status]}` +
962
+ `${story.reason ? ` (${story.reason})` : ""} (u410).`));
963
+ }
964
+ if (storyPhase === "expired") {
965
+ return createErrorResponse(new Error(`Proposal ${proposalId} has expired — its conclude window closed at height ` +
966
+ `${story.voteEnd + params.vetoWindow + params.concludeWindow} and the tip is ` +
967
+ `${clock.height} (u435). It can no longer be concluded and pays nobody. ` +
968
+ `The proposer's bond has already released itself.`));
969
+ }
970
+ if (storyPhase !== "concludable") {
971
+ const vetoEnd = story.voteEnd + params.vetoWindow;
972
+ return createErrorResponse(new Error(`Proposal ${proposalId} is in the "${storyPhase}" phase — conclude opens at height ` +
973
+ `${vetoEnd} and the tip is ${clock.height} (${vetoEnd - clock.height} blocks to go, u408).`));
974
+ }
975
+ const prediction = predictOutcome(story, params, num(pool), clock.height);
976
+ const result = await callContract(account, {
977
+ contractAddress: GOV_ADDRESS,
978
+ contractName: GOV_NAME,
979
+ functionName: "conclude",
980
+ functionArgs: [uintCV(proposalId)],
981
+ postConditionMode: PostConditionMode.Deny,
982
+ // The treasury pays either the whole snapshotted draw or nothing at
983
+ // all, and the outcome is decided on chain at mining time. A cap of
984
+ // the draw covers both, and covers nothing larger.
985
+ postConditions: [
986
+ Pc.principal(LEGION_TREASURY_CONTRACT)
987
+ .willSendLte(story.draw)
988
+ .ft(token.contract, token.assetName),
989
+ ],
990
+ });
991
+ return createJsonResponse({
992
+ success: true,
993
+ txid: result.txid,
994
+ explorerUrl: explorerUrl(result.txid),
995
+ proposalId,
996
+ concludedBy: account.address,
997
+ network: LEGION_NETWORK,
998
+ expectedOutcome: prediction.outcome,
999
+ expectedReason: prediction.reason,
1000
+ expectedPayoutSats: prediction.payout,
1001
+ payee: prediction.payout > 0 ? story.proposer : null,
1002
+ tally: {
1003
+ yes: story.yesWeight,
1004
+ no: story.noWeight,
1005
+ veto: story.vetoWeight,
1006
+ cast: prediction.cast,
1007
+ eligible: story.eligibleSnapshot,
1008
+ voters: story.voterCount,
1009
+ },
1010
+ note: "Expected outcome is computed from the tally as of now with the contract's own " +
1011
+ "decision order; the chain decides for real at the block that mines this tx.",
1012
+ });
1013
+ }
1014
+ catch (error) {
1015
+ return legionError(error);
1016
+ }
1017
+ });
1018
+ // ==========================================================================
1019
+ // legion_inscribe_story — step 1 of 2 (Bitcoin, not Stacks)
1020
+ // ==========================================================================
1021
+ server.registerTool("legion_inscribe_story", {
1022
+ description: "Inscribe a news piece to a Bitcoin ordinal as markdown — STEP 1, the commit.\n\n" +
1023
+ "Content type is text/markdown; the title is prepended as an H1 unless the body already " +
1024
+ "starts with one.\n\n" +
1025
+ `SPENDS REAL BITCOIN, on the Bitcoin network NETWORK names (currently ${NETWORK}) — ` +
1026
+ `not the Stacks ${LEGION_NETWORK} the rest of the legion_* tools use.\n\n` +
1027
+ "Optional `parentInscriptionId` files the piece as a child of a parent you own, binding " +
1028
+ "your pieces to one inscribed identity. That is authorship, not originality.\n\n" +
1029
+ "Pre-flights content size, fee estimate, parent ownership, funding and network before " +
1030
+ "signing. `dryRun` prices it without broadcasting.\n\n" +
1031
+ "Returns without waiting. After the commit confirms, call legion_inscribe_reveal.\n\n" +
1032
+ "Requires an unlocked managed wallet with funded UTXOs.",
1033
+ inputSchema: {
1034
+ title: z
1035
+ .string()
1036
+ .min(1)
1037
+ .describe("Headline. Prepended to the body as an H1 unless the body already has one."),
1038
+ body: z
1039
+ .string()
1040
+ .min(1)
1041
+ .describe("The piece itself, as markdown."),
1042
+ parentInscriptionId: z
1043
+ .string()
1044
+ .optional()
1045
+ .describe("Optional parent inscription id (e.g. 'abc…i0') to file this piece under. " +
1046
+ "You must own it: the reveal spends the parent's UTXO and returns it to you, " +
1047
+ "so children from one parent must be inscribed one at a time."),
1048
+ dryRun: z
1049
+ .boolean()
1050
+ .optional()
1051
+ .describe("Run every pre-flight check and price the inscription, but sign nothing. " +
1052
+ "Use this to see the cost before spending it."),
1053
+ allowNonMainnet: z
1054
+ .boolean()
1055
+ .optional()
1056
+ .describe("Permit inscribing on a non-mainnet Bitcoin network. Refused by default: " +
1057
+ "ordinals.com serves mainnet only, so the link would 404 for every voter."),
1058
+ feeRate: z
1059
+ .union([z.enum(["fast", "medium", "slow"]), z.number().positive()])
1060
+ .optional()
1061
+ .describe("Fee rate: 'fast', 'medium', 'slow', or a number in sat/vB (default: medium)"),
1062
+ },
1063
+ }, async ({ title, body, parentInscriptionId, dryRun, allowNonMainnet, feeRate }) => {
1064
+ try {
1065
+ // A testnet inscription is almost always a misconfiguration here: the
1066
+ // legion's governance is testnet but its links are read on mainnet
1067
+ // ordinals.com, so the piece would be unreadable to every voter. A JSON
1068
+ // warning field was too easy to miss for something that costs sats.
1069
+ if (NETWORK !== "mainnet" && !allowNonMainnet) {
1070
+ return createErrorResponse(new Error(`Bitcoin network is ${NETWORK}, and ordinals.com serves mainnet inscriptions ` +
1071
+ `only — voters could not open the link this produces. Set NETWORK=mainnet, ` +
1072
+ `or pass allowNonMainnet: true if you are deliberately testing. Nothing was broadcast.`));
1073
+ }
1074
+ const walletManager = getWalletManager();
1075
+ const sessionInfo = walletManager.getSessionInfo();
1076
+ if (!sessionInfo) {
1077
+ return createErrorResponse(new Error("Wallet not unlocked. Use wallet_unlock first."));
1078
+ }
1079
+ if (!sessionInfo.btcAddress || !sessionInfo.taprootAddress) {
1080
+ return createErrorResponse(new Error("This wallet has no Bitcoin addresses. Inscription needs a managed wallet."));
1081
+ }
1082
+ const account = walletManager.getAccount();
1083
+ if (!account?.btcPrivateKey || !account.btcPublicKey) {
1084
+ return createErrorResponse(new Error("Bitcoin keys not available. Wallet may not be unlocked."));
1085
+ }
1086
+ // Provenance is signed by the Taproot key that holds the parent, not by
1087
+ // the funding key, so a parented piece needs both.
1088
+ if (parentInscriptionId && (!account.taprootPrivateKey || !account.taprootPublicKey)) {
1089
+ return createErrorResponse(new Error("Taproot keys not available, so this wallet cannot spend a parent inscription. " +
1090
+ "Inscribe without parentInscriptionId, or use a managed wallet."));
1091
+ }
1092
+ // Verify ownership before spending anything: the reveal must spend the
1093
+ // parent's UTXO, so a parent someone else holds is unusable and the
1094
+ // commit fee would be burned discovering that later.
1095
+ const parentInfo = parentInscriptionId
1096
+ ? await lookupParentInscription(parentInscriptionId)
1097
+ : null;
1098
+ if (parentInfo && parentInfo.address !== sessionInfo.taprootAddress) {
1099
+ return createErrorResponse(new Error(`Parent inscription ${parentInscriptionId} is held by ${parentInfo.address}, ` +
1100
+ `not by your Taproot address ${sessionInfo.taprootAddress}. ` +
1101
+ `You must own a parent to file children under it. Nothing was broadcast.`));
1102
+ }
1103
+ const markdown = buildStoryMarkdown(title, body);
1104
+ const content = Buffer.from(markdown, "utf8");
1105
+ // The body rides in the reveal witness, so an oversized piece commits
1106
+ // fine and then cannot be revealed at all. Refuse before spending.
1107
+ if (content.length > MAX_INSCRIPTION_BYTES) {
1108
+ return createErrorResponse(new Error(`The piece is ${content.length} bytes, over the ${MAX_INSCRIPTION_BYTES}-byte ` +
1109
+ `ceiling. The content rides in the reveal witness, so a commit would confirm ` +
1110
+ `and the reveal would then be rejected as non-standard, stranding the sats. ` +
1111
+ `Split the piece or inscribe a summary that links out. Nothing was broadcast.`));
1112
+ }
1113
+ const contentHash = createHash("sha256").update(content).digest("hex");
1114
+ const inscription = {
1115
+ contentType: STORY_CONTENT_TYPE,
1116
+ body: content,
1117
+ };
1118
+ const mempoolApi = new MempoolApi(NETWORK);
1119
+ const actualFeeRate = await resolveFeeRate(mempoolApi, feeRate);
1120
+ const utxos = await mempoolApi.getUtxos(sessionInfo.btcAddress);
1121
+ if (utxos.length === 0) {
1122
+ return createErrorResponse(new Error(`No UTXOs at ${sessionInfo.btcAddress}. Fund the address before inscribing.`));
1123
+ }
1124
+ const commitResult = parentInscriptionId !== undefined
1125
+ ? buildChildCommitTransaction({
1126
+ utxos,
1127
+ inscription,
1128
+ parentInscriptionId,
1129
+ feeRate: actualFeeRate,
1130
+ senderPubKey: account.btcPublicKey,
1131
+ senderAddress: sessionInfo.btcAddress,
1132
+ network: NETWORK,
1133
+ })
1134
+ : buildCommitTransaction({
1135
+ utxos,
1136
+ inscription,
1137
+ feeRate: actualFeeRate,
1138
+ senderPubKey: account.btcPublicKey,
1139
+ senderAddress: sessionInfo.btcAddress,
1140
+ network: NETWORK,
1141
+ });
1142
+ // What the commit output can still afford at reveal time. The reveal
1143
+ // takes its own feeRate, and a rate above this leaves the reveal output
1144
+ // under dust — the builder refuses and the sats sit in the commit until
1145
+ // it is retried lower. Say the ceiling here so it is never hit.
1146
+ const revealVbytes = Math.max(1, Math.round(commitResult.revealAmount / Math.max(actualFeeRate, 1)));
1147
+ const maxRevealFeeRate = Math.max(1, Math.floor((commitResult.revealAmount - DUST_THRESHOLD) / revealVbytes));
1148
+ const probes = {
1149
+ network: NETWORK,
1150
+ contentSize: content.length,
1151
+ contentHash: `sha256:${contentHash}`,
1152
+ feeRate: actualFeeRate,
1153
+ utxos: {
1154
+ count: utxos.length,
1155
+ totalSats: utxos.reduce((sum, utxo) => sum + utxo.value, 0),
1156
+ },
1157
+ parent: parentInfo
1158
+ ? {
1159
+ inscriptionId: parentInscriptionId,
1160
+ txid: parentInfo.txid,
1161
+ vout: parentInfo.vout,
1162
+ value: parentInfo.value,
1163
+ owned: true,
1164
+ }
1165
+ : null,
1166
+ estimatedCost: {
1167
+ commitFee: commitResult.fee,
1168
+ revealAmount: commitResult.revealAmount,
1169
+ totalSats: commitResult.fee + commitResult.revealAmount,
1170
+ },
1171
+ maxRevealFeeRate,
1172
+ sizeWarning: content.length > LARGE_INSCRIPTION_WARN_BYTES
1173
+ ? `${content.length} bytes is a large inscription — the fee scales with it.`
1174
+ : undefined,
1175
+ };
1176
+ if (dryRun) {
1177
+ return createJsonResponse({
1178
+ status: "dry_run",
1179
+ probes,
1180
+ revealAddress: commitResult.revealAddress,
1181
+ title,
1182
+ markdown,
1183
+ broadcast: false,
1184
+ nextStep: "Nothing was signed. Re-call without dryRun to broadcast the commit.",
1185
+ });
1186
+ }
1187
+ const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
1188
+ const commitTxid = await mempoolApi.broadcastTransaction(commitSigned.txHex);
1189
+ return createJsonResponse({
1190
+ status: "commit_broadcast",
1191
+ bitcoinNetwork: NETWORK,
1192
+ commitTxid,
1193
+ commitExplorerUrl: getMempoolTxUrl(commitTxid, NETWORK),
1194
+ revealAddress: commitResult.revealAddress,
1195
+ revealAmount: commitResult.revealAmount,
1196
+ commitFee: commitResult.fee,
1197
+ feeRate: actualFeeRate,
1198
+ contentType: STORY_CONTENT_TYPE,
1199
+ contentSize: content.length,
1200
+ title,
1201
+ markdown,
1202
+ parentInscriptionId: parentInscriptionId ?? null,
1203
+ parentUtxo: parentInfo
1204
+ ? { txid: parentInfo.txid, vout: parentInfo.vout, value: parentInfo.value }
1205
+ : undefined,
1206
+ warning: NETWORK === "mainnet"
1207
+ ? undefined
1208
+ : `This is a ${NETWORK} inscription. ordinals.com serves mainnet only, so the ` +
1209
+ `link it produces will not resolve for legion voters.`,
1210
+ nextStep: "Wait for the commit to confirm, then call legion_inscribe_reveal with this " +
1211
+ "commitTxid and revealAmount plus the same title and body" +
1212
+ (parentInscriptionId ? " and the same parentInscriptionId." : "."),
1213
+ });
1214
+ }
1215
+ catch (error) {
1216
+ return createErrorResponse(error);
1217
+ }
1218
+ });
1219
+ // ==========================================================================
1220
+ // legion_inscribe_reveal — step 2 of 2
1221
+ // ==========================================================================
1222
+ server.registerTool("legion_inscribe_reveal", {
1223
+ description: "Complete a news inscription — STEP 2, the reveal, after the commit has confirmed.\n\n" +
1224
+ "Pass the SAME title, body and parentInscriptionId used at commit: the reveal script is " +
1225
+ "derived from all three. The commit's actual output is checked against the script this " +
1226
+ "content derives, so a mismatch is refused before signing rather than losing the sats.\n\n" +
1227
+ "Returns the inscription id and link for legion_propose_story.\n\n" +
1228
+ "Requires an unlocked managed wallet.",
1229
+ inputSchema: {
1230
+ commitTxid: z
1231
+ .string()
1232
+ .length(64)
1233
+ .describe("Txid of the confirmed commit transaction"),
1234
+ revealAmount: z
1235
+ .number()
1236
+ .positive()
1237
+ .describe("revealAmount from the legion_inscribe_story response"),
1238
+ title: z.string().min(1).describe("Same title used in the commit step"),
1239
+ body: z.string().min(1).describe("Same body used in the commit step"),
1240
+ parentInscriptionId: z
1241
+ .string()
1242
+ .optional()
1243
+ .describe("Same parentInscriptionId used in the commit step, if any"),
1244
+ feeRate: z
1245
+ .union([z.enum(["fast", "medium", "slow"]), z.number().positive()])
1246
+ .optional()
1247
+ .describe("Fee rate for the reveal tx (default: medium)"),
1248
+ },
1249
+ }, async ({ commitTxid, revealAmount, title, body, parentInscriptionId, feeRate }) => {
1250
+ try {
1251
+ const walletManager = getWalletManager();
1252
+ const sessionInfo = walletManager.getSessionInfo();
1253
+ if (!sessionInfo?.taprootAddress) {
1254
+ return createErrorResponse(new Error("Wallet not unlocked, or it has no Taproot address. Use wallet_unlock first."));
1255
+ }
1256
+ const account = walletManager.getAccount();
1257
+ if (!account?.btcPrivateKey || !account.btcPublicKey) {
1258
+ return createErrorResponse(new Error("Bitcoin keys not available. Wallet may not be unlocked."));
1259
+ }
1260
+ if (parentInscriptionId && (!account.taprootPrivateKey || !account.taprootPublicKey)) {
1261
+ return createErrorResponse(new Error("Taproot keys not available, so the parent's UTXO cannot be spent. " +
1262
+ "Wallet may not be unlocked."));
1263
+ }
1264
+ // Re-check ownership: the parent can have moved between commit and
1265
+ // reveal, and a reveal that tries to spend a UTXO someone else now holds
1266
+ // fails on broadcast after the fee is already committed.
1267
+ const parentInfo = parentInscriptionId
1268
+ ? await lookupParentInscription(parentInscriptionId)
1269
+ : null;
1270
+ if (parentInfo && parentInfo.address !== sessionInfo.taprootAddress) {
1271
+ return createErrorResponse(new Error(`Parent inscription ${parentInscriptionId} is no longer held by your wallet — ` +
1272
+ `current holder is ${parentInfo.address}. Nothing was broadcast.`));
1273
+ }
1274
+ const markdown = buildStoryMarkdown(title, body);
1275
+ const content = Buffer.from(markdown, "utf8");
1276
+ const contentHash = createHash("sha256").update(content).digest("hex");
1277
+ const inscription = {
1278
+ contentType: STORY_CONTENT_TYPE,
1279
+ body: content,
1280
+ };
1281
+ const mempoolApi = new MempoolApi(NETWORK);
1282
+ const actualFeeRate = await resolveFeeRate(mempoolApi, feeRate);
1283
+ let revealResult;
1284
+ let revealTxid;
1285
+ let derivedRevealAddress;
1286
+ if (parentInscriptionId !== undefined && parentInfo) {
1287
+ const revealScript = deriveChildRevealScript({
1288
+ inscription,
1289
+ parentInscriptionId,
1290
+ senderPubKey: account.btcPublicKey,
1291
+ network: NETWORK,
1292
+ });
1293
+ derivedRevealAddress = revealScript.address;
1294
+ await assertCommitMatches(mempoolApi, commitTxid, derivedRevealAddress, revealAmount);
1295
+ revealResult = buildChildRevealTransaction({
1296
+ commitTxid,
1297
+ commitVout: 0,
1298
+ commitAmount: revealAmount,
1299
+ revealScript,
1300
+ parentUtxo: {
1301
+ txid: parentInfo.txid,
1302
+ vout: parentInfo.vout,
1303
+ value: parentInfo.value,
1304
+ },
1305
+ parentOwnerTaprootInternalPubKey: account.taprootPublicKey,
1306
+ recipientAddress: sessionInfo.taprootAddress,
1307
+ feeRate: actualFeeRate,
1308
+ network: NETWORK,
1309
+ });
1310
+ // Two inputs, two keys: the commit output spends script-path with the
1311
+ // funding key, the parent spends key-path with the Taproot key.
1312
+ revealResult.tx.sign(account.btcPrivateKey);
1313
+ revealResult.tx.sign(account.taprootPrivateKey);
1314
+ revealResult.tx.finalize();
1315
+ revealTxid = await mempoolApi.broadcastTransaction(revealResult.tx.hex);
1316
+ }
1317
+ else {
1318
+ const revealScript = deriveRevealScript({
1319
+ inscription,
1320
+ senderPubKey: account.btcPublicKey,
1321
+ network: NETWORK,
1322
+ });
1323
+ derivedRevealAddress = revealScript.address;
1324
+ await assertCommitMatches(mempoolApi, commitTxid, derivedRevealAddress, revealAmount);
1325
+ revealResult = buildRevealTransaction({
1326
+ commitTxid,
1327
+ commitVout: 0,
1328
+ commitAmount: revealAmount,
1329
+ revealScript,
1330
+ recipientAddress: sessionInfo.taprootAddress,
1331
+ feeRate: actualFeeRate,
1332
+ network: NETWORK,
1333
+ });
1334
+ const revealSigned = signBtcTransaction(revealResult.tx, account.btcPrivateKey);
1335
+ revealTxid = await mempoolApi.broadcastTransaction(revealSigned.txHex);
1336
+ }
1337
+ const inscriptionId = `${revealTxid}i0`;
1338
+ const link = `${ORDINALS_INSCRIPTION_BASE}${inscriptionId}`;
1339
+ const contentUrl = `${ORDINALS_CONTENT_BASE}${inscriptionId}`;
1340
+ return createJsonResponse({
1341
+ status: "success",
1342
+ bitcoinNetwork: NETWORK,
1343
+ inscriptionId,
1344
+ link,
1345
+ contentUrl,
1346
+ title,
1347
+ contentType: STORY_CONTENT_TYPE,
1348
+ commit: {
1349
+ txid: commitTxid,
1350
+ explorerUrl: getMempoolTxUrl(commitTxid, NETWORK),
1351
+ },
1352
+ reveal: {
1353
+ txid: revealTxid,
1354
+ fee: revealResult.fee,
1355
+ explorerUrl: getMempoolTxUrl(revealTxid, NETWORK),
1356
+ },
1357
+ recipientAddress: sessionInfo.taprootAddress,
1358
+ contentHash: `sha256:${contentHash}`,
1359
+ parentInscriptionId: parentInscriptionId ?? null,
1360
+ warning: NETWORK === "mainnet"
1361
+ ? undefined
1362
+ : `This is a ${NETWORK} inscription; ordinals.com serves mainnet only, so ` +
1363
+ `voters will not be able to open ${link}.`,
1364
+ nextStep: `Once the reveal confirms, call legion_propose_story with link "${inscriptionId}" ` +
1365
+ `to open the vote. The inscription must be readable before voters can judge it.`,
1366
+ });
1367
+ }
1368
+ catch (error) {
1369
+ return createErrorResponse(error);
1370
+ }
1371
+ });
1372
+ }
1373
+ //# sourceMappingURL=legion.tools.js.map