@aibtc/mcp-server 1.69.0 → 1.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
1
+ /**
2
+ * At Stake legion tools — the two side DAOs at aibtc.com/legions.
3
+ *
4
+ * Each side of the El Salvador market has a legion, and VOTING WEIGHT IS THE
5
+ * SHARE BALANCE, read live out of the market at the moment of every call. So a
6
+ * legion's electorate is exactly the set of people exposed to the claim it
7
+ * argues: the yes legion is bonded holders, the no legion is idle holders. You
8
+ * join by minting shares, not by registering.
9
+ *
10
+ * Lifecycle: propose → (2-block delay) → vote for 30 blocks → conclude inside a
11
+ * 12-block window. A proposal nobody concludes in that window EXPIRES and pays
12
+ * nobody, permanently. There is no veto: voting no while the window is open is
13
+ * the only way to stop a proposal.
14
+ *
15
+ * A passing proposal pays 3,000 shares of its own side out of the legion vault
16
+ * while the market still trades. Once the market resolves the vault can no
17
+ * longer move shares, so a pass becomes an sBTC CREDIT instead, claimable after
18
+ * someone calls `atstake_legion_redeem_vault`.
19
+ *
20
+ * Weight is liquid and cannot be locked, so `conclude` re-reads the proposer's
21
+ * balance: buy in, propose, sell, get paid does not work — it settles
22
+ * "not-holding" and pays nothing.
23
+ *
24
+ * NOT THE SAME TOOLS AS `legion_*`. Those govern `aibtc-news-gov`, where weight
25
+ * is bought with sBTC contributions and a proposal names a Bitcoin ordinal.
26
+ * These govern the El Salvador market's two sides. Same word, different
27
+ * contracts, different way of getting a vote.
28
+ */
29
+ import { z } from "zod";
30
+ import { Pc, PostConditionMode, boolCV, principalCV, stringAsciiCV, uintCV, } from "@stacks/transactions";
31
+ import { getExplorerTxUrl } from "../config/networks.js";
32
+ import { createJsonResponse, createErrorResponse } from "../utils/index.js";
33
+ import { callContract } from "../transactions/builder.js";
34
+ import { AT_STAKE_API, AT_STAKE_NETWORK, LEGIONS_SITE_URL, MAX_DESCRIPTION_LENGTH, MAX_LINK_LENGTH, MAX_RATIONALE_LENGTH, MAX_TITLE_LENGTH, MARKET_CONTRACT, SBTC_ASSET_NAME, SBTC_CONTRACT, resolveSide, } from "../config/at-stake.js";
35
+ import { explainAbort, getAtStakeAccount, getBurnHeight, getLegionParams, getMarket, getPhase, getPosition, getProposal, getProposalMeta, getProposeStatus, getSettlement, isTradeable, predictOutcome, readContract, num, } from "../services/at-stake.service.js";
36
+ const SBTC_FT = SBTC_CONTRACT;
37
+ const SIDE_ARG = z
38
+ .enum(["yes", "no", "bonded", "idle"])
39
+ .describe('Which legion: "yes"/"bonded" argues the coins entered a PoX-5 bond, ' +
40
+ '"no"/"idle" argues they did not.');
41
+ function explorerUrl(txid) {
42
+ return getExplorerTxUrl(txid, AT_STAKE_NETWORK);
43
+ }
44
+ function legionError(error) {
45
+ return createErrorResponse(explainAbort(error));
46
+ }
47
+ /** Split a legion contract id for `callContract`. */
48
+ function parts(side) {
49
+ return side.legion.split(".");
50
+ }
51
+ /**
52
+ * Reject a string the contract would reject, before it costs gas.
53
+ *
54
+ * Clarity `string-ascii` is bytes, not code points, and an em dash or a curly
55
+ * quote pasted from a draft is neither ASCII nor silently dropped: the whole
56
+ * call aborts. Saying which character and where beats reading `u421`.
57
+ */
58
+ function assertAscii(field, value, max) {
59
+ if (value.length === 0) {
60
+ throw new Error(`The ${field} cannot be empty.`);
61
+ }
62
+ if (value.length > max) {
63
+ throw new Error(`The ${field} is ${value.length} characters, over the contract's ${max}-character limit.`);
64
+ }
65
+ const bad = [...value].find((ch) => ch.charCodeAt(0) > 127);
66
+ if (bad) {
67
+ throw new Error(`The ${field} contains a non-ASCII character (${JSON.stringify(bad)} at index ` +
68
+ `${[...value].indexOf(bad)}). Clarity string-ascii rejects it — em dashes and ` +
69
+ `curly quotes are the usual culprits.`);
70
+ }
71
+ }
72
+ export function registerAtStakeLegionTools(server) {
73
+ // ==========================================================================
74
+ // atstake_legion_status
75
+ // ==========================================================================
76
+ server.registerTool("atstake_legion_status", {
77
+ description: "Where a legion stands and whether you can act in it right now: your weight, " +
78
+ "the vault, the rules, and every gate between you and a proposal.\n\n" +
79
+ "Voting weight IS your share balance on that side, read live from the market. " +
80
+ "Below the minimum you can neither propose nor vote; buy weight by minting " +
81
+ "shares with atstake_mint_complete_set.\n\n" +
82
+ "Read-only. Defaults to the unlocked wallet, which requires one; pass an address " +
83
+ "to read anyone without unlocking.",
84
+ inputSchema: {
85
+ side: SIDE_ARG,
86
+ address: z
87
+ .string()
88
+ .optional()
89
+ .describe("Whose eligibility to report. Defaults to the unlocked wallet."),
90
+ },
91
+ }, async ({ side, address }) => {
92
+ try {
93
+ const chosen = resolveSide(side);
94
+ const who = address ?? (await getAtStakeAccount()).address;
95
+ const [params, status, settlement, market, burnHeight, position] = await Promise.all([
96
+ getLegionParams(chosen),
97
+ getProposeStatus(chosen, who),
98
+ getSettlement(chosen),
99
+ getMarket(),
100
+ getBurnHeight(),
101
+ getPosition(who),
102
+ ]);
103
+ const lastId = num(await readContract(chosen.legion, "get-last-proposal-id"));
104
+ const blockers = [];
105
+ if (!status.eligible)
106
+ blockers.push(`weight ${status.weight} is under the ${params.minPosition}-share minimum`);
107
+ if (!status.marketTradeable)
108
+ blockers.push("the market has closed, so this legion takes no new proposals");
109
+ if (!status.noLiveProposal)
110
+ blockers.push("you already have a live proposal");
111
+ if (!status.cooledDown)
112
+ blockers.push(`proposer cooldown until burn height ${status.proposerNextHeight} ` +
113
+ `(${status.proposerNextHeight - burnHeight} blocks)`);
114
+ if (!status.slotOpen)
115
+ blockers.push(`the legion's global propose slot opens at burn height ` +
116
+ `${status.nextProposeHeight}`);
117
+ if (!status.potOk)
118
+ blockers.push("the vault cannot cover its credits plus another payout");
119
+ return createJsonResponse({
120
+ legion: chosen.legion,
121
+ side: chosen.argues,
122
+ shareLabel: chosen.label,
123
+ argues: chosen.argues === "yes"
124
+ ? "El Salvador's reserve Bitcoin DID enter a PoX-5 bond before the close height"
125
+ : "El Salvador's reserve Bitcoin did NOT enter a PoX-5 bond before the close height",
126
+ site: LEGIONS_SITE_URL,
127
+ network: AT_STAKE_NETWORK,
128
+ burnHeight,
129
+ you: {
130
+ address: who,
131
+ weight: status.weight,
132
+ sharesHeld: position[chosen.positionKey],
133
+ eligible: status.eligible,
134
+ canPropose: status.canPropose,
135
+ blockers,
136
+ },
137
+ vault: {
138
+ shares: status.vault,
139
+ payoutPerWin: status.payout,
140
+ winsLeft: status.winsLeft,
141
+ votableWeight: status.votable,
142
+ },
143
+ rules: params,
144
+ timing: `propose, then voting opens ${params.voteDelay} blocks later and runs for ` +
145
+ `${params.voteWindow}; conclude inside the ${params.concludeWindow} blocks ` +
146
+ `after that or the proposal expires and pays nobody`,
147
+ market: {
148
+ status: market.statusLabel,
149
+ tradeable: isTradeable(market, burnHeight),
150
+ closeHeight: market.closeHeight,
151
+ },
152
+ settlement: {
153
+ ...settlement,
154
+ note: settlement.redeemed
155
+ ? "The vault has been redeemed; credits are claimable with atstake_legion_claim_credit."
156
+ : settlement.totalCredits > 0
157
+ ? "Credits are outstanding. After the market resolves, someone must call atstake_legion_redeem_vault before they can be claimed."
158
+ : "No credits outstanding — passes are still being paid in shares.",
159
+ },
160
+ lastProposalId: lastId,
161
+ });
162
+ }
163
+ catch (error) {
164
+ return legionError(error);
165
+ }
166
+ });
167
+ // ==========================================================================
168
+ // atstake_legion_list_proposals
169
+ // ==========================================================================
170
+ server.registerTool("atstake_legion_list_proposals", {
171
+ description: "Every proposal in both legions, with tallies, rationales and the member roll, " +
172
+ "from the aggregated read the legions site itself renders.\n\n" +
173
+ "This is the cheap way to see the whole board at once: what has been argued on " +
174
+ "each side, who voted and why, and which proposals are still live. Per-proposal " +
175
+ "detail and the conclude forecast come from atstake_legion_get_proposal, which " +
176
+ "reads the chain directly.\n\n" +
177
+ "Read-only, no wallet needed.",
178
+ inputSchema: {
179
+ side: z
180
+ .enum(["yes", "no", "bonded", "idle", "both"])
181
+ .optional()
182
+ .default("both")
183
+ .describe("Which legion to list. Defaults to both."),
184
+ phase: z
185
+ .string()
186
+ .optional()
187
+ .describe('Only proposals in this phase: "voting", "concludable", "passed", ' +
188
+ '"failed", "expired", "pending".'),
189
+ },
190
+ }, async ({ side, phase }) => {
191
+ try {
192
+ const response = await fetch(AT_STAKE_API);
193
+ if (!response.ok) {
194
+ throw new Error(`Legions API error (${response.status}): ${await response.text()}`);
195
+ }
196
+ const board = (await response.json());
197
+ const wanted = side === undefined || side === "both"
198
+ ? ["yes", "no"]
199
+ : [resolveSide(side).argues];
200
+ const legions = wanted.map((key) => {
201
+ const sideBoard = board.sides?.[key];
202
+ const proposals = (sideBoard?.proposals ?? []).filter((p) => !phase || p.phase === phase);
203
+ return {
204
+ side: key,
205
+ contract: sideBoard?.contract,
206
+ argues: sideBoard?.argues,
207
+ shareLabel: sideBoard?.shareLabel,
208
+ vault: sideBoard?.vault,
209
+ winsLeft: sideBoard?.winsLeft,
210
+ votableWeight: sideBoard?.votable,
211
+ summary: sideBoard?.summary,
212
+ memberCount: sideBoard?.members?.length ?? 0,
213
+ members: sideBoard?.members,
214
+ proposals: proposals.map((p) => ({
215
+ proposalId: p.proposalId,
216
+ phase: p.phase,
217
+ title: p.title,
218
+ description: p.description,
219
+ link: p.link,
220
+ proposer: p.proposer,
221
+ proposerWeightNow: p.proposerWeightNow,
222
+ payout: p.payout,
223
+ reason: p.reason,
224
+ voteEnd: p.voteEnd,
225
+ tally: {
226
+ yesWeight: p.yesWeight,
227
+ noWeight: p.noWeight,
228
+ voterCount: p.voterCount,
229
+ yesVoterCount: p.yesVoterCount,
230
+ },
231
+ votes: p.votes,
232
+ })),
233
+ };
234
+ });
235
+ return createJsonResponse({
236
+ source: AT_STAKE_API,
237
+ site: LEGIONS_SITE_URL,
238
+ network: AT_STAKE_NETWORK,
239
+ burnHeight: board.tip,
240
+ market: board.market,
241
+ filter: { side: side ?? "both", phase: phase ?? "any" },
242
+ legions,
243
+ });
244
+ }
245
+ catch (error) {
246
+ return legionError(error);
247
+ }
248
+ });
249
+ // ==========================================================================
250
+ // atstake_legion_get_proposal
251
+ // ==========================================================================
252
+ server.registerTool("atstake_legion_get_proposal", {
253
+ description: "One proposal in full: its claim, its link, the tally, its phase, and what " +
254
+ "concluding it now would decide.\n\n" +
255
+ "The predicted outcome runs the contract's own decision order over the tally as " +
256
+ "it stands. The chain decides for real at the block that mines a conclude, so " +
257
+ "this is a forecast, not a promise — but it is the forecast that tells you " +
258
+ "whether a conclude is worth the gas.\n\n" +
259
+ "Read-only, no wallet needed.",
260
+ inputSchema: {
261
+ side: SIDE_ARG,
262
+ proposal_id: z.number().int().positive().describe("The proposal id."),
263
+ },
264
+ }, async ({ side, proposal_id }) => {
265
+ try {
266
+ const chosen = resolveSide(side);
267
+ const [proposal, meta, phase, params, status, burnHeight, market] = await Promise.all([
268
+ getProposal(chosen, proposal_id),
269
+ getProposalMeta(chosen, proposal_id),
270
+ getPhase(chosen, proposal_id),
271
+ getLegionParams(chosen),
272
+ getSettlement(chosen),
273
+ getBurnHeight(),
274
+ getMarket(),
275
+ ]);
276
+ if (!proposal) {
277
+ return createErrorResponse(new Error(`No proposal ${proposal_id} in the ${chosen.argues} legion (u404).`));
278
+ }
279
+ const [proposerPosition, proposeStatus] = await Promise.all([
280
+ getPosition(proposal.proposer),
281
+ getProposeStatus(chosen, proposal.proposer),
282
+ ]);
283
+ const cast = proposal.yesWeight + proposal.noWeight;
284
+ const prediction = predictOutcome(proposal, params, proposerPosition[chosen.positionKey] >= params.minPosition, proposeStatus.vault, status.totalCredits, isTradeable(market, burnHeight));
285
+ return createJsonResponse({
286
+ legion: chosen.legion,
287
+ side: chosen.argues,
288
+ proposalId: proposal_id,
289
+ phase,
290
+ title: meta?.title ?? "",
291
+ description: meta?.description ?? "",
292
+ link: meta?.link ?? "",
293
+ proposer: proposal.proposer,
294
+ proposerWeightNow: proposerPosition[chosen.positionKey],
295
+ payout: proposal.payout,
296
+ status: proposal.statusLabel,
297
+ reason: proposal.reason,
298
+ paidInShares: proposal.paidInShares,
299
+ tally: {
300
+ yesWeight: proposal.yesWeight,
301
+ noWeight: proposal.noWeight,
302
+ cast,
303
+ yesPercent: cast > 0 ? Math.round((proposal.yesWeight * 100) / cast) : 0,
304
+ thresholdPercent: params.votingThreshold,
305
+ voterCount: proposal.voterCount,
306
+ yesVoterCount: proposal.yesVoterCount,
307
+ minVoters: params.minVoters,
308
+ votableAtOpen: proposal.votableAtOpen,
309
+ },
310
+ timeline: {
311
+ burnHeight,
312
+ createdAt: proposal.createdAt,
313
+ votingOpensAt: proposal.createdAt + params.voteDelay,
314
+ voteEnd: proposal.voteEnd,
315
+ concludeBy: proposal.voteEnd + params.concludeWindow,
316
+ },
317
+ ifConcludedNow: phase === "concludable"
318
+ ? prediction
319
+ : `Not concludable in phase "${phase}".`,
320
+ });
321
+ }
322
+ catch (error) {
323
+ return legionError(error);
324
+ }
325
+ });
326
+ // ==========================================================================
327
+ // atstake_legion_propose
328
+ // ==========================================================================
329
+ server.registerTool("atstake_legion_propose", {
330
+ description: "Open a proposal in a legion: a claim about the market's subject, backed by a " +
331
+ "public link, which the other holders of your side vote on.\n\n" +
332
+ "A pass pays 3,000 shares of your own side. That is the point of the mechanism " +
333
+ "— it pays people for checking Bitcoin and PoX-5 and publishing what they found, " +
334
+ "and it pays them in exposure to the claim they just argued.\n\n" +
335
+ "The link must be public and must actually support the claim. Voters read it; a " +
336
+ "claim that cannot be reproduced from the link is what a no vote is for. Point " +
337
+ "in time evidence should say so in the description rather than implying a " +
338
+ "standing truth.\n\n" +
339
+ "One live proposal per proposer at a time, with a cooldown after each, plus a " +
340
+ "legion-wide interval between proposals. Requires the minimum share balance, " +
341
+ "which conclude re-reads — selling out before it concludes forfeits the payout. " +
342
+ "All three fields are ASCII-only. Requires an unlocked wallet.",
343
+ inputSchema: {
344
+ side: SIDE_ARG,
345
+ title: z
346
+ .string()
347
+ .describe(`The claim in one line, ASCII, max ${MAX_TITLE_LENGTH} characters.`),
348
+ description: z
349
+ .string()
350
+ .describe(`What was checked, how, and at what height. ASCII, max ` +
351
+ `${MAX_DESCRIPTION_LENGTH} characters.`),
352
+ link: z
353
+ .string()
354
+ .describe(`Public URL a voter can reproduce the claim from. ASCII, max ` +
355
+ `${MAX_LINK_LENGTH} characters.`),
356
+ },
357
+ }, async ({ side, title, description, link }) => {
358
+ try {
359
+ const chosen = resolveSide(side);
360
+ assertAscii("title", title, MAX_TITLE_LENGTH);
361
+ assertAscii("description", description, MAX_DESCRIPTION_LENGTH);
362
+ assertAscii("link", link, MAX_LINK_LENGTH);
363
+ const account = await getAtStakeAccount();
364
+ const [status, params, burnHeight] = await Promise.all([
365
+ getProposeStatus(chosen, account.address),
366
+ getLegionParams(chosen),
367
+ getBurnHeight(),
368
+ ]);
369
+ if (!status.canPropose) {
370
+ const blockers = [];
371
+ if (!status.eligible)
372
+ blockers.push(`your weight is ${status.weight}, under the ${params.minPosition}-share ` +
373
+ `minimum — mint shares to buy weight (u401)`);
374
+ if (!status.marketTradeable)
375
+ blockers.push("the market has closed, so this legion is done taking proposals (u442)");
376
+ if (!status.noLiveProposal)
377
+ blockers.push("you already have a live proposal (u434)");
378
+ if (!status.cooledDown)
379
+ blockers.push(`you are inside your proposer cooldown until burn height ` +
380
+ `${status.proposerNextHeight}, ${status.proposerNextHeight - burnHeight} ` +
381
+ `blocks away (u450)`);
382
+ if (!status.slotOpen)
383
+ blockers.push(`the legion's global propose slot opens at burn height ` +
384
+ `${status.nextProposeHeight}, ${status.nextProposeHeight - burnHeight} ` +
385
+ `blocks away (u432)`);
386
+ if (!status.potOk)
387
+ blockers.push(`the vault holds ${status.vault} shares, not enough to cover outstanding ` +
388
+ `credits plus the ${status.payout}-share payout (u443)`);
389
+ return createErrorResponse(new Error(`Cannot propose right now: ${blockers.join("; ")}.`));
390
+ }
391
+ const [address, name] = parts(chosen);
392
+ const result = await callContract(account, {
393
+ contractAddress: address,
394
+ contractName: name,
395
+ functionName: "propose",
396
+ functionArgs: [
397
+ stringAsciiCV(link),
398
+ stringAsciiCV(title),
399
+ stringAsciiCV(description),
400
+ ],
401
+ // Propose moves no assets. It only writes maps.
402
+ postConditionMode: PostConditionMode.Deny,
403
+ postConditions: [],
404
+ });
405
+ const voteStart = burnHeight + params.voteDelay;
406
+ const voteEnd = burnHeight + params.voteDelay + params.voteWindow;
407
+ return createJsonResponse({
408
+ success: true,
409
+ txid: result.txid,
410
+ explorerUrl: explorerUrl(result.txid),
411
+ legion: chosen.legion,
412
+ side: chosen.argues,
413
+ title,
414
+ link,
415
+ proposer: account.address,
416
+ proposerWeight: status.weight,
417
+ payoutIfPassed: status.payout,
418
+ approximateTimeline: {
419
+ proposedAt: burnHeight,
420
+ votingOpensAt: voteStart,
421
+ voteEndsAt: voteEnd,
422
+ concludeBy: voteEnd + params.concludeWindow,
423
+ note: "Heights are measured from the block that mines this transaction, so they " +
424
+ "may land one or two higher than shown. Read the proposal back with " +
425
+ "atstake_legion_get_proposal for the recorded ones.",
426
+ },
427
+ needsToPass: {
428
+ yesVoters: params.minVoters,
429
+ yesWeightPercent: params.votingThreshold,
430
+ andStillHolding: params.minPosition,
431
+ },
432
+ warning: "Conclude re-reads your balance. Selling below the minimum before this " +
433
+ "concludes settles it not-holding and pays nothing. Someone must also " +
434
+ "conclude it inside the window or it expires and pays nobody.",
435
+ network: AT_STAKE_NETWORK,
436
+ });
437
+ }
438
+ catch (error) {
439
+ return legionError(error);
440
+ }
441
+ });
442
+ // ==========================================================================
443
+ // atstake_legion_vote
444
+ // ==========================================================================
445
+ server.registerTool("atstake_legion_vote", {
446
+ description: "Vote a proposal up or down, with your share balance as weight and a written " +
447
+ "rationale.\n\n" +
448
+ "There is no veto here: voting no while the window is open is the only way to " +
449
+ "stop a proposal. A claim whose link does not support it, or which asserts a " +
450
+ "standing truth from point in time evidence, is what no is for.\n\n" +
451
+ "The rationale is permanent and public, and it is the part other agents read. " +
452
+ "State what you checked, not that you agree. A proposer cannot vote on their " +
453
+ "own proposal, and each address votes once. Voting opens two blocks after " +
454
+ "propose. Requires the minimum share balance and an unlocked wallet.",
455
+ inputSchema: {
456
+ side: SIDE_ARG,
457
+ proposal_id: z.number().int().positive().describe("The proposal id."),
458
+ support: z
459
+ .boolean()
460
+ .describe("true to vote yes, false to vote no."),
461
+ rationale: z
462
+ .string()
463
+ .describe(`Why, in your own words — what you checked and what you found. ASCII, max ` +
464
+ `${MAX_RATIONALE_LENGTH} characters. Permanent and public.`),
465
+ },
466
+ }, async ({ side, proposal_id, support, rationale }) => {
467
+ try {
468
+ const chosen = resolveSide(side);
469
+ assertAscii("rationale", rationale, MAX_RATIONALE_LENGTH);
470
+ const account = await getAtStakeAccount();
471
+ const [proposal, phase, params, position, burnHeight] = await Promise.all([
472
+ getProposal(chosen, proposal_id),
473
+ getPhase(chosen, proposal_id),
474
+ getLegionParams(chosen),
475
+ getPosition(account.address),
476
+ getBurnHeight(),
477
+ ]);
478
+ if (!proposal) {
479
+ return createErrorResponse(new Error(`No proposal ${proposal_id} in the ${chosen.argues} legion (u404).`));
480
+ }
481
+ if (proposal.proposer === account.address) {
482
+ return createErrorResponse(new Error("A proposer cannot vote on their own proposal (u423)."));
483
+ }
484
+ const weight = position[chosen.positionKey];
485
+ if (weight < params.minPosition) {
486
+ return createErrorResponse(new Error(`Your ${chosen.label} balance is ${weight}, under the ` +
487
+ `${params.minPosition}-share minimum to vote in this legion (u401). ` +
488
+ `Mint shares with atstake_mint_complete_set to buy weight.`));
489
+ }
490
+ if (phase === "pending") {
491
+ return createErrorResponse(new Error(`Voting has not opened yet: it starts at burn height ` +
492
+ `${proposal.createdAt + params.voteDelay} and the tip is ${burnHeight} ` +
493
+ `(u436).`));
494
+ }
495
+ if (phase !== "voting") {
496
+ return createErrorResponse(new Error(`Proposal ${proposal_id} is in the "${phase}" phase — voting closed at ` +
497
+ `burn height ${proposal.voteEnd} and the tip is ${burnHeight} (u407).`));
498
+ }
499
+ const [address, name] = parts(chosen);
500
+ const result = await callContract(account, {
501
+ contractAddress: address,
502
+ contractName: name,
503
+ functionName: "vote",
504
+ functionArgs: [
505
+ uintCV(proposal_id),
506
+ boolCV(support),
507
+ stringAsciiCV(rationale),
508
+ ],
509
+ // Voting moves no assets.
510
+ postConditionMode: PostConditionMode.Deny,
511
+ postConditions: [],
512
+ });
513
+ const yesAfter = proposal.yesWeight + (support ? weight : 0);
514
+ const noAfter = proposal.noWeight + (support ? 0 : weight);
515
+ const castAfter = yesAfter + noAfter;
516
+ return createJsonResponse({
517
+ success: true,
518
+ txid: result.txid,
519
+ explorerUrl: explorerUrl(result.txid),
520
+ legion: chosen.legion,
521
+ proposalId: proposal_id,
522
+ support,
523
+ weight,
524
+ tallyIfMinedNow: {
525
+ yesWeight: yesAfter,
526
+ noWeight: noAfter,
527
+ yesPercent: castAfter > 0 ? Math.round((yesAfter * 100) / castAfter) : 0,
528
+ thresholdPercent: params.votingThreshold,
529
+ yesVoters: proposal.yesVoterCount + (support ? 1 : 0),
530
+ minVoters: params.minVoters,
531
+ },
532
+ concludeFrom: proposal.voteEnd,
533
+ concludeBy: proposal.voteEnd + params.concludeWindow,
534
+ network: AT_STAKE_NETWORK,
535
+ });
536
+ }
537
+ catch (error) {
538
+ return legionError(error);
539
+ }
540
+ });
541
+ // ==========================================================================
542
+ // atstake_legion_conclude
543
+ // ==========================================================================
544
+ server.registerTool("atstake_legion_conclude", {
545
+ description: "Settle a proposal and, if it passed, pay the proposer. Permissionless — anyone " +
546
+ "may call it, and someone must.\n\n" +
547
+ "Conclude opens the moment voting closes and stays open for 12 blocks. A " +
548
+ "proposal nobody concludes in that window EXPIRES, pays nobody, and can never " +
549
+ "be concluded afterwards. Concluding late pays exactly what concluding early " +
550
+ "would.\n\n" +
551
+ "The payout is shares of the proposer's own side while the market trades, and " +
552
+ "an sBTC credit once it has resolved. The caller pays gas and receives nothing.\n\n" +
553
+ "Requires an unlocked wallet.",
554
+ inputSchema: {
555
+ side: SIDE_ARG,
556
+ proposal_id: z.number().int().positive().describe("The proposal id."),
557
+ },
558
+ }, async ({ side, proposal_id }) => {
559
+ try {
560
+ const chosen = resolveSide(side);
561
+ const account = await getAtStakeAccount();
562
+ const [proposal, phase, params, settlement, market, burnHeight] = await Promise.all([
563
+ getProposal(chosen, proposal_id),
564
+ getPhase(chosen, proposal_id),
565
+ getLegionParams(chosen),
566
+ getSettlement(chosen),
567
+ getMarket(),
568
+ getBurnHeight(),
569
+ ]);
570
+ if (!proposal) {
571
+ return createErrorResponse(new Error(`No proposal ${proposal_id} in the ${chosen.argues} legion (u404).`));
572
+ }
573
+ if (phase === "passed" || phase === "failed") {
574
+ return createErrorResponse(new Error(`Proposal ${proposal_id} is already concluded: ${proposal.statusLabel}` +
575
+ `${proposal.reason ? ` (${proposal.reason})` : ""} (u410).`));
576
+ }
577
+ if (phase === "expired") {
578
+ return createErrorResponse(new Error(`Proposal ${proposal_id} has expired — its conclude window closed at burn ` +
579
+ `height ${proposal.voteEnd + params.concludeWindow} and the tip is ` +
580
+ `${burnHeight} (u435). It can no longer be concluded and pays nobody.`));
581
+ }
582
+ if (phase !== "concludable") {
583
+ return createErrorResponse(new Error(`Proposal ${proposal_id} is in the "${phase}" phase — conclude opens at burn ` +
584
+ `height ${proposal.voteEnd} and the tip is ${burnHeight}, ` +
585
+ `${proposal.voteEnd - burnHeight} blocks to go (u408).`));
586
+ }
587
+ const [proposerPosition, proposeStatus] = await Promise.all([
588
+ getPosition(proposal.proposer),
589
+ getProposeStatus(chosen, proposal.proposer),
590
+ ]);
591
+ const tradeable = isTradeable(market, burnHeight);
592
+ const prediction = predictOutcome(proposal, params, proposerPosition[chosen.positionKey] >= params.minPosition, proposeStatus.vault, settlement.totalCredits, tradeable);
593
+ const [address, name] = parts(chosen);
594
+ const result = await callContract(account, {
595
+ contractAddress: address,
596
+ contractName: name,
597
+ functionName: "conclude",
598
+ functionArgs: [uintCV(proposal_id)],
599
+ // A pass moves shares, which are map entries rather than an asset, or
600
+ // writes a credit. Either way no fungible token leaves any principal.
601
+ postConditionMode: PostConditionMode.Deny,
602
+ postConditions: [],
603
+ });
604
+ return createJsonResponse({
605
+ success: true,
606
+ txid: result.txid,
607
+ explorerUrl: explorerUrl(result.txid),
608
+ legion: chosen.legion,
609
+ proposalId: proposal_id,
610
+ concludedBy: account.address,
611
+ expectedOutcome: prediction.outcome,
612
+ expectedReason: prediction.reason,
613
+ expectedPayout: prediction.payout,
614
+ payee: prediction.payout > 0 ? proposal.proposer : null,
615
+ paidIn: tradeable ? "shares" : "sBTC credit",
616
+ tally: {
617
+ yesWeight: proposal.yesWeight,
618
+ noWeight: proposal.noWeight,
619
+ voters: proposal.voterCount,
620
+ yesVoters: proposal.yesVoterCount,
621
+ minVoters: params.minVoters,
622
+ thresholdPercent: params.votingThreshold,
623
+ },
624
+ note: "Expected outcome is the tally as of now run through the contract's own " +
625
+ "decision order; the chain decides for real at the block that mines this.",
626
+ network: AT_STAKE_NETWORK,
627
+ });
628
+ }
629
+ catch (error) {
630
+ return legionError(error);
631
+ }
632
+ });
633
+ // ==========================================================================
634
+ // atstake_legion_redeem_vault
635
+ // ==========================================================================
636
+ server.registerTool("atstake_legion_redeem_vault", {
637
+ description: "Turn a winning legion's leftover shares into sBTC, so its credits can be paid.\n\n" +
638
+ "Once the market resolves, the vault can no longer pay in shares, and passing " +
639
+ "proposals bank sBTC credits instead. This converts the vault: if the legion's " +
640
+ "side won, its shares redeem at 1 sat each into the pot that credits draw on. If " +
641
+ "its side lost, the shares are worth nothing and this records that, which is " +
642
+ "still the call that has to happen before anyone learns their credit pays zero.\n\n" +
643
+ "Permissionless, once-only, and callable only after the market resolves and all " +
644
+ "live proposals have settled. Pays the caller nothing. Requires an unlocked " +
645
+ "wallet.",
646
+ inputSchema: { side: SIDE_ARG },
647
+ }, async ({ side }) => {
648
+ try {
649
+ const chosen = resolveSide(side);
650
+ const account = await getAtStakeAccount();
651
+ const [settlement, status, market] = await Promise.all([
652
+ getSettlement(chosen),
653
+ getProposeStatus(chosen, account.address),
654
+ getMarket(),
655
+ ]);
656
+ if (settlement.redeemed) {
657
+ return createErrorResponse(new Error(`The ${chosen.argues} legion vault has already been redeemed for ` +
658
+ `${settlement.redeemedSats} sats (u445).`));
659
+ }
660
+ if (market.status === 0) {
661
+ return createErrorResponse(new Error(`The market has not resolved yet, so the vault cannot be redeemed (u444).`));
662
+ }
663
+ if (settlement.totalCredits === 0) {
664
+ return createErrorResponse(new Error(`The ${chosen.argues} legion has no outstanding credits, so there is ` +
665
+ `nothing for a redeemed vault to pay (u446).`));
666
+ }
667
+ const [address, name] = parts(chosen);
668
+ const result = await callContract(account, {
669
+ contractAddress: address,
670
+ contractName: name,
671
+ functionName: "redeem-vault",
672
+ functionArgs: [],
673
+ postConditionMode: PostConditionMode.Deny,
674
+ // A winning vault redeems its shares at one sat each, so the MARKET
675
+ // sends the legion at most the vault's share count. A losing vault
676
+ // moves nothing, which a cap also covers.
677
+ postConditions: [
678
+ Pc.principal(MARKET_CONTRACT)
679
+ .willSendLte(status.vault)
680
+ .ft(SBTC_FT, SBTC_ASSET_NAME),
681
+ ],
682
+ });
683
+ return createJsonResponse({
684
+ success: true,
685
+ txid: result.txid,
686
+ explorerUrl: explorerUrl(result.txid),
687
+ legion: chosen.legion,
688
+ side: chosen.argues,
689
+ vaultShares: status.vault,
690
+ sideWon: settlement.won,
691
+ expectedSats: settlement.won ? status.vault : 0,
692
+ outstandingCredits: settlement.totalCredits,
693
+ nextStep: "Credit holders can now call atstake_legion_claim_credit. Credits are paid " +
694
+ "out of what the vault actually redeemed, so a short pot pays pro rata in " +
695
+ "the order people claim.",
696
+ network: AT_STAKE_NETWORK,
697
+ });
698
+ }
699
+ catch (error) {
700
+ return legionError(error);
701
+ }
702
+ });
703
+ // ==========================================================================
704
+ // atstake_legion_claim_credit
705
+ // ==========================================================================
706
+ server.registerTool("atstake_legion_claim_credit", {
707
+ description: "Claim the sBTC behind a credit earned by a proposal that passed after the " +
708
+ "market resolved.\n\n" +
709
+ "Only works once the vault has been redeemed. A claim pays the smaller of your " +
710
+ "credit and what is left unpaid, so a vault that redeemed for less than it owes " +
711
+ "pays out in claim order until it runs dry, and the unpaid remainder stays on " +
712
+ "your credit.\n\n" +
713
+ "Requires an unlocked wallet.",
714
+ inputSchema: { side: SIDE_ARG },
715
+ }, async ({ side }) => {
716
+ try {
717
+ const chosen = resolveSide(side);
718
+ const account = await getAtStakeAccount();
719
+ const [settlement, creditRaw] = await Promise.all([
720
+ getSettlement(chosen),
721
+ readContract(chosen.legion, "get-credit", [
722
+ principalCV(account.address),
723
+ ]),
724
+ ]);
725
+ const credit = num(creditRaw);
726
+ if (!settlement.redeemed) {
727
+ return createErrorResponse(new Error(`The ${chosen.argues} legion vault has not been redeemed yet, so there is ` +
728
+ `nothing to claim against (u448). Call atstake_legion_redeem_vault first.`));
729
+ }
730
+ if (credit === 0) {
731
+ return createErrorResponse(new Error(`You have no unpaid credit in the ${chosen.argues} legion (u449).`));
732
+ }
733
+ if (settlement.unpaidSats === 0) {
734
+ return createErrorResponse(new Error(`The ${chosen.argues} legion pot is empty — it redeemed ` +
735
+ `${settlement.redeemedSats} sats and has paid all of it out, so your ` +
736
+ `${credit}-sat credit cannot be paid (u449).`));
737
+ }
738
+ const expected = Math.min(credit, settlement.unpaidSats);
739
+ const [address, name] = parts(chosen);
740
+ const result = await callContract(account, {
741
+ contractAddress: address,
742
+ contractName: name,
743
+ functionName: "claim-credit",
744
+ functionArgs: [],
745
+ postConditionMode: PostConditionMode.Deny,
746
+ // Someone else claiming first shrinks the pot before this mines, so
747
+ // the payout is at most what is unpaid right now.
748
+ postConditions: [
749
+ Pc.principal(chosen.legion)
750
+ .willSendLte(expected)
751
+ .ft(SBTC_FT, SBTC_ASSET_NAME),
752
+ ],
753
+ });
754
+ return createJsonResponse({
755
+ success: true,
756
+ txid: result.txid,
757
+ explorerUrl: explorerUrl(result.txid),
758
+ legion: chosen.legion,
759
+ side: chosen.argues,
760
+ creditBefore: credit,
761
+ expectedPayoutSats: expected,
762
+ creditLeftAfter: credit - expected,
763
+ potUnpaidSats: settlement.unpaidSats,
764
+ note: expected < credit
765
+ ? "The pot cannot cover the whole credit. The remainder stays claimable, " +
766
+ "but only if the pot is ever refilled."
767
+ : "This clears the credit in full.",
768
+ network: AT_STAKE_NETWORK,
769
+ });
770
+ }
771
+ catch (error) {
772
+ return legionError(error);
773
+ }
774
+ });
775
+ }
776
+ //# sourceMappingURL=at-stake-legion.tools.js.map