@aibtc/mcp-server 1.67.0 → 1.69.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.
@@ -1,13 +1,22 @@
1
1
  /**
2
- * AIBTC News Legion tools (news-gov-v5).
2
+ * AIBTC News Legion tools (mainnet `aibtc-news-gov`).
3
3
  *
4
- * Lifecycle: inscribe → reveal → propose → vote → veto → conclude, plus
5
- * contribute (buys weight) and sponsor (does not).
4
+ * Lifecycle: inscribe → reveal → propose → vote → conclude, plus contribute
5
+ * (buys weight) and sponsor (does not). There is no veto in this era: voting no
6
+ * while the window is open is the only way to stop a piece.
7
+ *
8
+ * EVERYTHING HERE MOVES REAL VALUE. The legion is on Stacks mainnet holding
9
+ * real sBTC, and neither `contribute` nor `sponsor` has a withdrawal path, so
10
+ * both meter against the wallet's `sats` spending rail before signing. That
11
+ * metering happens inside `callContract`, which reads the exact sBTC
12
+ * post-condition these calls already sign in DENY mode — so it must NOT be
13
+ * repeated here, or every contribute would bill the rail twice.
6
14
  *
7
15
  * 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.
16
+ * unlocked wallet for the legion's chain, so a testnet-configured server still
17
+ * signs against the mainnet contracts rather than quietly reading a chain the
18
+ * legion does not live on. Inscription is the exception — it follows the global
19
+ * NETWORK, because ordinals.com indexes Bitcoin mainnet only.
11
20
  *
12
21
  * Fund-moving calls sign in DENY mode with an exact post-condition.
13
22
  */
@@ -16,8 +25,8 @@ import { z } from "zod";
16
25
  import { Pc, boolCV, noneCV, principalCV, someCV, stringAsciiCV, uintCV, PostConditionMode, } from "@stacks/transactions";
17
26
  import { NETWORK, getExplorerTxUrl } from "../config/networks.js";
18
27
  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";
28
+ import { LEGION_ERAS, LEGION_ERRORS, LEGION_NETWORK, LEGION_SITE_URL, LIVE_ERA, MAX_DESCRIPTION_LENGTH, MAX_LINK_LENGTH, MAX_RATIONALE_LENGTH, MAX_SPONSOR_LINK_LENGTH, MAX_SPONSOR_MEMO_LENGTH, MAX_SPONSOR_NAME_LENGTH, MAX_TITLE_LENGTH, ORDINALS_CONTENT_BASE, ORDINALS_INSCRIPTION_BASE, STORY_STATUS, resolveEra, } from "../config/legion.js";
29
+ import { assertAscii, buildTimeline, contentUrlFromLink, explainError, getClockHeight, getLastProposalId, getLegionAccount, getLegionToken, getMembership, getParams, getPhase, getProposeStatus, getStory, getStoryMeta, getVoteRecord, inscriptionIdFromLink, normalizeOrdinalsLink, num, predictOutcome, proposeBlockers, readGov, readLegionContract, readTreasury, } from "../services/legion.service.js";
21
30
  import { callContract } from "../transactions/builder.js";
22
31
  import { getWalletManager } from "../services/wallet-manager.js";
23
32
  import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
@@ -25,8 +34,17 @@ import { buildCommitTransaction, buildRevealTransaction, deriveRevealScript, } f
25
34
  import { buildChildCommitTransaction, buildChildRevealTransaction, deriveChildRevealScript, lookupParentInscription, } from "../transactions/child-inscription-builder.js";
26
35
  import { signBtcTransaction } from "../transactions/bitcoin-builder.js";
27
36
  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(".");
37
+ // Writes always target the live era. A retired contract still answers reads,
38
+ // but proposing or voting into one would govern a deployment nothing watches.
39
+ const [GOV_ADDRESS, GOV_NAME] = LIVE_ERA.gov.split(".");
40
+ const [TREASURY_ADDRESS, TREASURY_NAME] = LIVE_ERA.treasury.split(".");
41
+ /** The `era` input shared by every read tool. */
42
+ const eraInput = z
43
+ .number()
44
+ .int()
45
+ .positive()
46
+ .optional()
47
+ .describe(`Which legion deployment to read: ${LEGION_ERAS.map((e) => `${e.version}${e.live ? " (live, default)" : " (retired)"}`).join(", ")}. Proposal ids restart at 1 each era, so an id alone is ambiguous.`);
30
48
  /** Markdown, so the inscription renders as a story rather than a wall of text. */
31
49
  const STORY_CONTENT_TYPE = "text/markdown;charset=utf-8";
32
50
  /**
@@ -41,17 +59,17 @@ const LARGE_INSCRIPTION_WARN_BYTES = 100_000;
41
59
  /**
42
60
  * Phases that mean the piece is still moving, for the `live` list filter.
43
61
  * `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
62
+ * from the moment it is filed, but voting does not open until voteDelay
45
63
  * blocks later.
46
64
  */
47
- const LIVE_PHASES = ["pending", "voting", "veto", "concludable"];
65
+ const LIVE_PHASES = ["pending", "voting", "concludable"];
48
66
  /** Errors surface as sentences, with the contract's `uXXX` still attached. */
49
67
  function legionError(error) {
50
68
  return createErrorResponse(new Error(explainError(error, LEGION_ERRORS)));
51
69
  }
52
70
  /** An sBTC balance on the legion's chain, in sats. */
53
- async function sbtcBalance(address) {
54
- const token = await getLegionToken();
71
+ async function sbtcBalance(address, era = LIVE_ERA) {
72
+ const token = await getLegionToken(era);
55
73
  return num(await readLegionContract(token.contract, "get-balance", [
56
74
  principalCV(address),
57
75
  ]));
@@ -128,33 +146,38 @@ export function registerLegionTools(server) {
128
146
  // legion_status — the whole legion in one read
129
147
  // ==========================================================================
130
148
  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" +
149
+ description: "News Legion at a glance: sBTC pool, total weight, membership and whether the legion " +
150
+ "is ACTIVATED yet, governance params read from the contract, current height, and your " +
151
+ "own weight if a wallet is unlocked. Start here.\n\n" +
152
+ "No story can be proposed until the legion reaches its member threshold, so check " +
153
+ "`membership.activated` before planning to publish.\n\n" +
133
154
  `Reads only. Runs on Stacks ${LEGION_NETWORK} regardless of this server's NETWORK.`,
134
- inputSchema: {},
135
- }, async () => {
155
+ inputSchema: { era: eraInput },
156
+ }, async ({ era: eraArg }) => {
136
157
  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"),
158
+ const era = resolveEra(eraArg);
159
+ const [params, clock, pool, weighted, totalWeight, lastProposalId, nextProposeHeight, quotePayout, minSponsor, govWiring, membership,] = await Promise.all([
160
+ getParams(era),
161
+ getClockHeight(era),
162
+ readTreasury("get-balance", [], era),
163
+ readTreasury("get-weighted-balance", [], era),
164
+ readGov("get-total-weight", [], era),
165
+ getLastProposalId(era),
166
+ readGov("get-next-propose-height", [], era),
167
+ readGov("quote-payout", [], era),
168
+ readTreasury("get-min-sponsor", [], era),
169
+ readTreasury("get-gov", [], era),
170
+ getMembership(era),
148
171
  ]);
149
172
  const walletManager = getWalletManager();
150
173
  let you;
151
174
  if (walletManager.isUnlocked() || process.env.CLIENT_MNEMONIC) {
152
175
  const account = await getLegionAccount();
153
176
  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),
177
+ readGov("get-weight", [principalCV(account.address)], era),
178
+ readGov("get-free-weight", [principalCV(account.address)], era),
179
+ readGov("get-live-proposal", [principalCV(account.address)], era),
180
+ sbtcBalance(account.address, era),
158
181
  ]);
159
182
  you = {
160
183
  address: account.address,
@@ -169,10 +192,18 @@ export function registerLegionTools(server) {
169
192
  }
170
193
  return createJsonResponse({
171
194
  network: LEGION_NETWORK,
195
+ era: {
196
+ version: era.version,
197
+ live: era.live,
198
+ known: LEGION_ERAS.map((e) => ({ version: e.version, live: e.live })),
199
+ note: era.live
200
+ ? undefined
201
+ : `v${era.version} is retired — it still answers reads, but every write goes to v${LIVE_ERA.version}.`,
202
+ },
172
203
  contracts: {
173
- governance: LEGION_GOV_CONTRACT,
174
- treasury: LEGION_TREASURY_CONTRACT,
175
- sbtcToken: (await getLegionToken()).contract,
204
+ governance: era.gov,
205
+ treasury: era.treasury,
206
+ sbtcToken: (await getLegionToken(era)).contract,
176
207
  treasuryWiredTo: govWiring,
177
208
  },
178
209
  site: LEGION_SITE_URL,
@@ -186,23 +217,35 @@ export function registerLegionTools(server) {
186
217
  weightedBalanceSats: num(weighted),
187
218
  sponsoredSats: num(pool) - num(weighted),
188
219
  note: "New weight is priced against weightedBalance (contributed sats only), " +
189
- "so sponsorships never raise the cost of joining — but the draw is a " +
220
+ "so sponsorships never raise the cost of joining — but the payout is a " +
190
221
  "fraction of the WHOLE pool, so they do enlarge every payout.",
191
222
  },
223
+ membership: {
224
+ ...membership,
225
+ note: membership.activated
226
+ ? "The legion is activated — proposals are open."
227
+ : `NOT ACTIVATED. ${membership.membersNeeded} more principals must each hold ` +
228
+ `at least ${params.minWeightToAct} weight before any story can be proposed ` +
229
+ `(u441). Contributing more sats yourself does not help — the gate counts ` +
230
+ `distinct members, not weight.`,
231
+ },
192
232
  governance: {
193
233
  totalWeight: num(totalWeight),
194
234
  lastProposalId,
195
235
  nextProposeHeight: num(nextProposeHeight),
196
236
  proposeSlotOpen: num(nextProposeHeight) <= clock.height,
197
- currentDrawSats: num(quoteDraw),
237
+ currentPayoutSats: num(quotePayout),
198
238
  minSponsorSats: num(minSponsor),
199
239
  params,
240
+ howAStoryPasses: `At least ${params.minVoters} distinct voter(s), at least ` +
241
+ `${params.votingThreshold}% of cast weight voting yes, AND yes weight of at ` +
242
+ `least ${params.yesMultiple}x the payout. There is no quorum and no veto.`,
200
243
  },
201
244
  you,
202
245
  nextSteps: [
203
246
  "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",
247
+ "legion_my_position — your weight, lock and whether you can propose",
248
+ "legion_contribute — buy voting weight with sBTC (spends real sBTC, no refund)",
206
249
  "legion_sponsor — fund the pool without buying a vote",
207
250
  ],
208
251
  });
@@ -216,7 +259,7 @@ export function registerLegionTools(server) {
216
259
  // ==========================================================================
217
260
  server.registerTool("legion_list_stories", {
218
261
  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`, " +
262
+ "Phases: `pending` (filed, voting not open yet), `voting`, `concludable`, " +
220
263
  "`passed`/`failed` (settled), `expired` (nobody concluded in time; can no longer pay).\n\n" +
221
264
  "Reads only.",
222
265
  inputSchema: {
@@ -226,14 +269,13 @@ export function registerLegionTools(server) {
226
269
  "live",
227
270
  "pending",
228
271
  "voting",
229
- "veto",
230
272
  "concludable",
231
273
  "passed",
232
274
  "failed",
233
275
  "expired",
234
276
  ])
235
277
  .optional()
236
- .describe("Filter by phase. 'live' means pending+voting+veto+concludable. Default: all"),
278
+ .describe("Filter by phase. 'live' means pending+voting+concludable. Default: all"),
237
279
  limit: z
238
280
  .number()
239
281
  .int()
@@ -241,21 +283,24 @@ export function registerLegionTools(server) {
241
283
  .max(50)
242
284
  .optional()
243
285
  .describe("How many proposals to scan back from the newest (default 10)"),
286
+ era: eraInput,
244
287
  },
245
- }, async ({ phase = "all", limit = 10 }) => {
288
+ }, async ({ phase = "all", limit = 10, era: eraArg }) => {
246
289
  try {
290
+ const era = resolveEra(eraArg);
247
291
  const [params, clock, lastProposalId, pool] = await Promise.all([
248
- getParams(),
249
- getClockHeight(),
250
- getLastProposalId(),
251
- readTreasury("get-balance"),
292
+ getParams(era),
293
+ getClockHeight(era),
294
+ getLastProposalId(era),
295
+ readTreasury("get-balance", [], era),
252
296
  ]);
253
297
  if (lastProposalId === 0) {
254
298
  return createJsonResponse({
255
299
  network: LEGION_NETWORK,
300
+ era: era.version,
256
301
  totalProposals: 0,
257
302
  stories: [],
258
- message: "No proposals have been filed in this legion yet.",
303
+ message: `No proposals have been filed in legion v${era.version} yet.`,
259
304
  });
260
305
  }
261
306
  const firstId = Math.max(1, lastProposalId - limit + 1);
@@ -264,9 +309,9 @@ export function registerLegionTools(server) {
264
309
  ids.push(id);
265
310
  const rows = await Promise.all(ids.map(async (proposalId) => {
266
311
  const [story, meta, storyPhase] = await Promise.all([
267
- getStory(proposalId),
268
- getStoryMeta(proposalId),
269
- getPhase(proposalId),
312
+ getStory(proposalId, era),
313
+ getStoryMeta(proposalId, era),
314
+ getPhase(proposalId, era),
270
315
  ]);
271
316
  if (!story)
272
317
  return null;
@@ -274,6 +319,7 @@ export function registerLegionTools(server) {
274
319
  const prediction = predictOutcome(story, params, num(pool), clock.height);
275
320
  return {
276
321
  proposalId,
322
+ era: era.version,
277
323
  phase: storyPhase,
278
324
  status: STORY_STATUS[story.status],
279
325
  reason: story.reason || undefined,
@@ -282,13 +328,13 @@ export function registerLegionTools(server) {
282
328
  inscriptionId: meta ? inscriptionIdFromLink(meta.link) : null,
283
329
  contentUrl: meta ? contentUrlFromLink(meta.link) : null,
284
330
  proposer: story.proposer,
285
- drawSats: story.draw,
331
+ payoutSats: story.payout,
286
332
  tally: {
287
333
  yes: story.yesWeight,
288
334
  no: story.noWeight,
289
- veto: story.vetoWeight,
335
+ yesNeeded: prediction.yesWeightRequired,
290
336
  voters: story.voterCount,
291
- eligible: story.eligibleSnapshot,
337
+ totalWeightAtOpen: story.totalWeightAtOpen,
292
338
  },
293
339
  blocksUntilNextTransition: timeline.blocksUntilNextTransition,
294
340
  ifConcludedNow: prediction.settled
@@ -307,6 +353,7 @@ export function registerLegionTools(server) {
307
353
  });
308
354
  return createJsonResponse({
309
355
  network: LEGION_NETWORK,
356
+ era: { version: era.version, live: era.live, gov: era.gov },
310
357
  currentHeight: clock.height,
311
358
  clock: `${clock.clock} blocks`,
312
359
  totalProposals: lastProposalId,
@@ -326,25 +373,27 @@ export function registerLegionTools(server) {
326
373
  // legion_get_story — one proposal in full
327
374
  // ==========================================================================
328
375
  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" +
376
+ description: "One proposal in full: the tally against all three gates it must clear, the window " +
377
+ "timeline, what `conclude` would decide now, and whether you have voted.\n\n" +
331
378
  "Read this before voting — the contract cannot read the inscription, so judging the " +
332
379
  "work is the voter's job.\n\nReads only.",
333
380
  inputSchema: {
334
381
  proposalId: z.number().int().positive().describe("The proposal id"),
382
+ era: eraInput,
335
383
  },
336
- }, async ({ proposalId }) => {
384
+ }, async ({ proposalId, era: eraArg }) => {
337
385
  try {
386
+ const era = resolveEra(eraArg);
338
387
  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"),
388
+ getParams(era),
389
+ getClockHeight(era),
390
+ getStory(proposalId, era),
391
+ getStoryMeta(proposalId, era),
392
+ getPhase(proposalId, era),
393
+ readTreasury("get-balance", [], era),
345
394
  ]);
346
395
  if (!story) {
347
- return createErrorResponse(new Error(`No proposal ${proposalId} in ${LEGION_GOV_CONTRACT}. ` +
396
+ return createErrorResponse(new Error(`No proposal ${proposalId} in ${era.gov} (v${era.version}). ` +
348
397
  `Call legion_list_stories to see what exists.`));
349
398
  }
350
399
  const timeline = buildTimeline(story, params, clock.height, clock.clock);
@@ -353,30 +402,26 @@ export function registerLegionTools(server) {
353
402
  let you;
354
403
  if (walletManager.isUnlocked() || process.env.CLIENT_MNEMONIC) {
355
404
  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)]),
405
+ const [voteRecord, weight] = await Promise.all([
406
+ getVoteRecord(proposalId, account.address, era),
407
+ readGov("get-weight", [principalCV(account.address)], era),
360
408
  ]);
361
409
  you = {
362
410
  address: account.address,
363
411
  weight: num(weight),
364
412
  isProposer: account.address === story.proposer,
365
413
  voted: voteRecord,
366
- vetoed: vetoRecord,
367
414
  canVoteNow: storyPhase === "voting" &&
368
415
  !voteRecord &&
369
416
  account.address !== story.proposer &&
370
- num(weight) >= params.minWeight,
371
- canVetoNow: storyPhase === "veto" &&
372
- !vetoRecord &&
373
- num(weight) >= params.minWeight,
417
+ num(weight) >= params.minWeightToAct,
374
418
  canConcludeNow: storyPhase === "concludable",
375
419
  };
376
420
  }
377
421
  return createJsonResponse({
378
422
  proposalId,
379
423
  network: LEGION_NETWORK,
424
+ era: { version: era.version, live: era.live },
380
425
  phase: storyPhase,
381
426
  status: STORY_STATUS[story.status],
382
427
  reason: story.reason || undefined,
@@ -386,35 +431,47 @@ export function registerLegionTools(server) {
386
431
  inscriptionId: meta ? inscriptionIdFromLink(meta.link) : null,
387
432
  contentUrl: meta ? contentUrlFromLink(meta.link) : null,
388
433
  proposer: story.proposer,
389
- drawSats: story.draw,
390
- bondWeight: story.bond,
434
+ payoutSats: story.payout,
435
+ lockedWeight: story.lockedWeight,
391
436
  tally: {
392
437
  yesWeight: story.yesWeight,
393
438
  noWeight: story.noWeight,
394
- vetoWeight: story.vetoWeight,
395
439
  castWeight: prediction.cast,
396
440
  voterCount: story.voterCount,
397
- eligibleSnapshot: story.eligibleSnapshot,
441
+ totalWeightAtOpen: story.totalWeightAtOpen,
398
442
  },
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,
443
+ // The three gates conclude applies, in the contract's own order. There
444
+ // is no quorum: turnout alone can never fail a piece here.
445
+ gates: {
446
+ voters: {
447
+ have: story.voterCount,
448
+ need: params.minVoters,
449
+ met: prediction.votersMet,
450
+ failsAs: "no-voters",
451
+ },
452
+ threshold: {
453
+ yesPct: prediction.yesPct,
454
+ needPct: params.votingThreshold,
455
+ met: prediction.thresholdMet,
456
+ failsAs: "voted-down",
457
+ },
458
+ yesWeight: {
459
+ have: story.yesWeight,
460
+ need: prediction.yesWeightRequired,
461
+ met: prediction.yesMet,
462
+ failsAs: "yes-short",
463
+ explanation: `Yes weight must cover ${params.yesMultiple}x the ${story.payout} sat payout. ` +
464
+ `A piece can be approved ${params.votingThreshold}%+ and still fail here — ` +
465
+ `that is the brake on a thin majority voting money out of a large pool.`,
466
+ },
411
467
  },
412
468
  timeline: {
413
469
  ...timeline,
414
470
  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.`,
471
+ `and closes at ${timeline.voteEnd}. There is no veto window, so conclude opens ` +
472
+ `as soon as voting closes. Conclude must be called before ` +
473
+ `${timeline.concludeDeadline} — after that the piece has expired and can no ` +
474
+ `longer be concluded at all.`,
418
475
  },
419
476
  outcome: {
420
477
  settled: prediction.settled,
@@ -437,93 +494,63 @@ export function registerLegionTools(server) {
437
494
  // legion_my_position — your standing
438
495
  // ==========================================================================
439
496
  server.registerTool("legion_my_position", {
440
- description: "Your weight, share, bond lock, sBTC balance, and every propose precondition folded " +
497
+ description: "Your weight, share, weight lock, sBTC balance, and every propose precondition folded " +
441
498
  "from the contract's `propose-status` — so a blocked propose names the gate to wait on.\n\n" +
442
499
  "Requires an unlocked wallet. Signs nothing.",
443
- inputSchema: {},
444
- }, async () => {
500
+ inputSchema: { era: eraInput },
501
+ }, async ({ era: eraArg }) => {
445
502
  try {
503
+ const era = resolveEra(eraArg);
446
504
  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"),
505
+ const [params, clock, weight, freeWeight, locked, lockedUntil, liveProposal, totalWeight, proposeStatus, balance, quotePayout] = await Promise.all([
506
+ getParams(era),
507
+ getClockHeight(era),
508
+ readGov("get-weight", [principalCV(account.address)], era),
509
+ readGov("get-free-weight", [principalCV(account.address)], era),
510
+ readGov("get-locked-weight", [principalCV(account.address)], era),
511
+ readGov("get-locked-until", [principalCV(account.address)], era),
512
+ readGov("get-live-proposal", [principalCV(account.address)], era),
513
+ readGov("get-total-weight", [], era),
514
+ getProposeStatus(account.address, era),
515
+ sbtcBalance(account.address, era),
516
+ readGov("quote-payout", [], era),
458
517
  ]);
459
- const blockers = proposeBlockers(proposeStatus, params.minWeight);
518
+ const blockers = proposeBlockers(proposeStatus, params.minWeightToAct);
460
519
  return createJsonResponse({
461
520
  address: account.address,
462
521
  network: LEGION_NETWORK,
522
+ era: { version: era.version, live: era.live },
463
523
  currentHeight: clock.height,
464
524
  weight: num(weight),
465
525
  freeWeight: num(freeWeight),
466
526
  lockedWeight: num(locked),
527
+ lockedUntilHeight: num(lockedUntil),
467
528
  sharePct: num(totalWeight) > 0
468
529
  ? Number(((num(weight) * 100) / num(totalWeight)).toFixed(4))
469
530
  : 0,
470
531
  totalWeight: num(totalWeight),
471
532
  sbtcBalanceSats: balance,
472
533
  liveProposalId: liveProposal === null ? null : num(liveProposal),
473
- canVote: num(weight) >= params.minWeight,
534
+ canVote: num(weight) >= params.minWeightToAct,
535
+ isMember: num(weight) >= params.minWeightToAct,
536
+ membership: {
537
+ memberCount: proposeStatus.memberCount,
538
+ membersToActivate: proposeStatus.membersToActivate,
539
+ activated: proposeStatus.membersOk,
540
+ },
474
541
  propose: {
475
542
  canPropose: proposeStatus.canPropose,
476
543
  blockers: blockers.length > 0 ? blockers : undefined,
477
544
  weightLockedOnPropose: proposeStatus.lockOnPropose,
478
- wouldPaySats: proposeStatus.draw,
545
+ wouldPaySats: proposeStatus.payout,
479
546
  nextProposeHeight: proposeStatus.nextProposeHeight,
480
547
  note: "Proposing locks your ENTIRE weight until the piece resolves. The lock is " +
481
548
  "never spent and never reduces your voting power — it exists only to enforce " +
482
549
  "one live proposal per principal, and releases on every outcome.",
483
550
  },
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.",
551
+ currentPayoutSats: num(quotePayout),
552
+ minWeightToVoteOrPropose: params.minWeightToAct,
553
+ minJoinSats: params.minJoinSats,
527
554
  });
528
555
  }
529
556
  catch (error) {
@@ -536,8 +563,13 @@ export function registerLegionTools(server) {
536
563
  server.registerTool("legion_contribute", {
537
564
  description: "Send sBTC to the pool and receive voting weight proportional to your share of the " +
538
565
  "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" +
566
+ "SPENDS REAL sBTC AND IS NOT REFUNDABLE — you get a say in which pieces get paid, not " +
567
+ "a claim on the pool. There is no withdrawal function. To fund the pool without a " +
568
+ "vote, use legion_sponsor.\n\n" +
569
+ "Metered against the wallet's `sats` spending limit before signing, so a large " +
570
+ "contribution blocks until you raise SPEND_LIMIT_SESSION_SATS.\n\n" +
571
+ "Crossing the minimum weight also makes you a MEMBER, which is what unlocks proposals " +
572
+ "for the whole legion once enough members join.\n\n" +
541
573
  "Requires an unlocked wallet.",
542
574
  inputSchema: {
543
575
  sats: z
@@ -549,22 +581,24 @@ export function registerLegionTools(server) {
549
581
  }, async ({ sats }) => {
550
582
  try {
551
583
  const account = await getLegionAccount();
552
- const [params, token, balance, weightBefore] = await Promise.all([
584
+ const [params, token, balance, weightBefore, membership] = await Promise.all([
553
585
  getParams(),
554
586
  getLegionToken(),
555
587
  sbtcBalance(account.address),
556
588
  readGov("get-weight", [principalCV(account.address)]),
589
+ getMembership(),
557
590
  ]);
558
- if (sats < params.minContribution) {
591
+ if (sats < params.minJoinSats) {
559
592
  return createErrorResponse(new Error(`${sats} sats is below the legion's minimum contribution of ` +
560
- `${params.minContribution} sats (u437).`));
593
+ `${params.minJoinSats} sats (u437).`));
561
594
  }
562
595
  if (balance < sats) {
563
596
  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.")));
597
+ `Acquire sBTC with sbtc_deposit or styx_deposit first.`));
567
598
  }
599
+ // Real, unrecoverable sBTC leaves the wallet here. The `sats` rail is
600
+ // metered by callContract off the willSendEq post-condition below —
601
+ // metering again here would double-bill the cap.
568
602
  const quotedWeight = num(await readGov("quote-weight", [uintCV(sats)]));
569
603
  const result = await callContract(account, {
570
604
  contractAddress: GOV_ADDRESS,
@@ -578,6 +612,9 @@ export function registerLegionTools(server) {
578
612
  .ft(token.contract, token.assetName),
579
613
  ],
580
614
  });
615
+ const weightAfter = num(weightBefore) + quotedWeight;
616
+ const wasMember = num(weightBefore) >= params.minWeightToAct;
617
+ const willBeMember = weightAfter >= params.minWeightToAct;
581
618
  return createJsonResponse({
582
619
  success: true,
583
620
  txid: result.txid,
@@ -586,12 +623,22 @@ export function registerLegionTools(server) {
586
623
  contributedSats: sats,
587
624
  expectedWeightMinted: quotedWeight,
588
625
  weightBefore: num(weightBefore),
589
- expectedWeightAfter: num(weightBefore) + quotedWeight,
626
+ expectedWeightAfter: weightAfter,
590
627
  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.`,
628
+ membership: {
629
+ joiningAsNewMember: !wasMember && willBeMember,
630
+ memberCountBefore: membership.memberCount,
631
+ membersToActivate: membership.membersToActivate,
632
+ activated: membership.activated,
633
+ },
634
+ warning: "Contributions are final and spend real sBTC. There is no withdrawal path.",
635
+ nextStep: willBeMember
636
+ ? membership.activated
637
+ ? "Once it confirms you can legion_vote and legion_propose_story."
638
+ : `Once it confirms you can legion_vote. Proposing stays blocked until the ` +
639
+ `legion reaches ${membership.membersToActivate} members (u441).`
640
+ : `You will still be below minWeightToAct (${params.minWeightToAct}) — contribute ` +
641
+ `more to vote or propose.`,
595
642
  });
596
643
  }
597
644
  catch (error) {
@@ -604,9 +651,10 @@ export function registerLegionTools(server) {
604
651
  server.registerTool("legion_sponsor", {
605
652
  description: "Fund the pool WITHOUT minting voting weight, with a name on the record.\n\n" +
606
653
  "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" +
654
+ "but does enlarge every payout, since the payout is a fraction of the whole pool.\n\n" +
608
655
  "`name`, `link` and `memo` are unverified — the paying principal and txid are the real " +
609
- "identity. FINAL: no refund path exists.\n\n" +
656
+ "identity. SPENDS REAL sBTC AND IS FINAL: no refund path exists.\n\n" +
657
+ "Metered against the wallet's `sats` spending limit before signing.\n\n" +
610
658
  "Requires an unlocked wallet.",
611
659
  inputSchema: {
612
660
  sats: z
@@ -641,7 +689,7 @@ export function registerLegionTools(server) {
641
689
  const [token, minSponsor, balance] = await Promise.all([
642
690
  getLegionToken(),
643
691
  readTreasury("get-min-sponsor"),
644
- sbtcBalance((await getLegionAccount()).address),
692
+ sbtcBalance(account.address),
645
693
  ]);
646
694
  if (sats < num(minSponsor)) {
647
695
  return createErrorResponse(new Error(`${sats} sats is below the treasury's minimum sponsorship of ` +
@@ -650,6 +698,8 @@ export function registerLegionTools(server) {
650
698
  if (balance < sats) {
651
699
  return createErrorResponse(new Error(`Your sBTC balance is ${balance} sats — not enough to sponsor ${sats}.`));
652
700
  }
701
+ // Same rail as contribute: real sBTC, no refund, metered by callContract
702
+ // off the post-condition below.
653
703
  const result = await callContract(account, {
654
704
  contractAddress: TREASURY_ADDRESS,
655
705
  contractName: TREASURY_NAME,
@@ -678,7 +728,7 @@ export function registerLegionTools(server) {
678
728
  memo: memo ?? "",
679
729
  mintedWeight: 0,
680
730
  network: LEGION_NETWORK,
681
- warning: "Final. No refund path exists. This mints no voting weight — use " +
731
+ warning: "Final. Real sBTC, and no refund path exists. This mints no voting weight — use " +
682
732
  "legion_contribute if you wanted a vote.",
683
733
  note: "How long a sponsor badge shows is decided off-chain by the reader at " +
684
734
  `${LEGION_SITE_URL}, not by the contract.`,
@@ -692,8 +742,10 @@ export function registerLegionTools(server) {
692
742
  // legion_propose_story — open the vote on an inscribed piece
693
743
  // ==========================================================================
694
744
  server.registerTool("legion_propose_story", {
695
- description: "Open a vote on one inscribed piece. If it passes, YOU are paid the draw — the " +
745
+ description: "Open a vote on one inscribed piece. If it passes, YOU are paid the payout — the " +
696
746
  "proposer is the only reachable payee.\n\n" +
747
+ "The legion must be ACTIVATED first: until it reaches its member threshold, every " +
748
+ "propose reverts (u441) no matter how much weight you hold. Check legion_status.\n\n" +
697
749
  "The contract stores link, title and description verbatim and never reads the " +
698
750
  "inscription. Proposing locks your ENTIRE weight until the piece resolves — one live " +
699
751
  "proposal per principal; the lock is never spent and releases on every outcome.\n\n" +
@@ -731,7 +783,7 @@ export function registerLegionTools(server) {
731
783
  getClockHeight(),
732
784
  ]);
733
785
  if (!proposeStatus.canPropose) {
734
- const blockers = proposeBlockers(proposeStatus, params.minWeight);
786
+ const blockers = proposeBlockers(proposeStatus, params.minWeightToAct);
735
787
  return createErrorResponse(new Error(`Cannot propose right now: ${blockers.join("; ")}. ` +
736
788
  `Current height ${clock.height}. Nothing was signed.`));
737
789
  }
@@ -744,12 +796,12 @@ export function registerLegionTools(server) {
744
796
  stringAsciiCV(title),
745
797
  stringAsciiCV(body),
746
798
  ],
747
- // The bond is weight, not sats: no sBTC moves on propose, so DENY with
799
+ // The lock is weight, not sats: no sBTC moves on propose, so DENY with
748
800
  // no conditions is exactly right.
749
801
  postConditionMode: PostConditionMode.Deny,
750
802
  postConditions: [],
751
803
  });
752
- const votingOpensAt = clock.height + params.votingDelay;
804
+ const votingOpensAt = clock.height + params.voteDelay;
753
805
  const voteEnd = votingOpensAt + params.voteWindow;
754
806
  return createJsonResponse({
755
807
  success: true,
@@ -762,19 +814,22 @@ export function registerLegionTools(server) {
762
814
  description: body,
763
815
  network: LEGION_NETWORK,
764
816
  weightLocked: proposeStatus.lockOnPropose,
765
- expectedDrawSats: proposeStatus.draw,
817
+ expectedPayoutSats: proposeStatus.payout,
818
+ yesWeightNeeded: proposeStatus.payout * params.yesMultiple,
766
819
  expectedTimeline: {
767
820
  note: "Approximate — the contract snapshots these at the block that mines the tx.",
768
821
  filedAtAbout: clock.height,
769
822
  votingOpensAbout: votingOpensAt,
770
823
  voteEndsAbout: voteEnd,
771
- vetoEndsAbout: voteEnd + params.vetoWindow,
772
- concludeDeadlineAbout: voteEnd + params.vetoWindow + params.concludeWindow,
824
+ concludeDeadlineAbout: voteEnd + params.concludeWindow,
773
825
  clock: `${clock.clock} blocks`,
774
826
  },
775
827
  nextSteps: [
776
828
  "legion_list_stories to pick up the assigned proposalId once it confirms",
777
- `Votes cannot be cast for ~${params.votingDelay} blocks (the pending period)`,
829
+ `Votes cannot be cast for ~${params.voteDelay} blocks (the pending period)`,
830
+ `To be paid, this needs ${params.minVoters}+ voter(s), ${params.votingThreshold}% ` +
831
+ `yes, AND at least ${proposeStatus.payout * params.yesMultiple} yes weight — ` +
832
+ `you cannot vote for your own piece, so that weight must come from others`,
778
833
  "Someone must call legion_conclude inside the conclude window or the piece expires and pays nobody",
779
834
  ],
780
835
  });
@@ -787,8 +842,10 @@ export function registerLegionTools(server) {
787
842
  // legion_vote
788
843
  // ==========================================================================
789
844
  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" +
845
+ description: "Vote yes or no with your current weight, with a written rationale recorded on chain. " +
846
+ "One vote per principal; a proposer cannot vote on their own piece. Votes cannot be " +
847
+ "changed.\n\n" +
848
+ "Voting no is the ONLY way to stop a piece — this legion has no veto.\n\n" +
792
849
  "Read the inscription first — legion_get_story gives you the link.\n\n" +
793
850
  "Pre-flights phase, weight and prior vote. Requires an unlocked wallet.",
794
851
  inputSchema: {
@@ -796,8 +853,14 @@ export function registerLegionTools(server) {
796
853
  support: z
797
854
  .boolean()
798
855
  .describe("true = yes (pay this piece), false = no"),
856
+ rationale: z
857
+ .string()
858
+ .min(1)
859
+ .max(MAX_RATIONALE_LENGTH)
860
+ .describe(`Why you voted this way, recorded on chain (ASCII, ≤${MAX_RATIONALE_LENGTH} chars). ` +
861
+ "Required and must be non-empty — the contract rejects a blank one (u440)."),
799
862
  },
800
- }, async ({ proposalId, support }) => {
863
+ }, async ({ proposalId, support, rationale }) => {
801
864
  try {
802
865
  const account = await getLegionAccount();
803
866
  const [params, story, storyPhase, weight, existingVote, clock] = await Promise.all([
@@ -808,6 +871,11 @@ export function registerLegionTools(server) {
808
871
  getVoteRecord(proposalId, account.address),
809
872
  getClockHeight(),
810
873
  ]);
874
+ if (rationale.trim().length === 0) {
875
+ return createErrorResponse(new Error(`The rationale cannot be blank (u440). Pass a sentence on why this piece is ` +
876
+ `or is not worth paying for. Nothing was signed.`));
877
+ }
878
+ assertAscii(rationale, "rationale", MAX_RATIONALE_LENGTH);
811
879
  if (!story) {
812
880
  return createErrorResponse(new Error(`No proposal ${proposalId} in this legion (u404).`));
813
881
  }
@@ -817,43 +885,53 @@ export function registerLegionTools(server) {
817
885
  }
818
886
  if (account.address === story.proposer) {
819
887
  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.`));
888
+ `This era has no veto either, so there is no way to withdraw it — let it run ` +
889
+ `or let the conclude window lapse.`));
821
890
  }
822
891
  if (storyPhase === "pending") {
823
- const opensAt = story.createdAt + params.votingDelay;
892
+ const opensAt = story.createdAt + params.voteDelay;
824
893
  return createErrorResponse(new Error(`Proposal ${proposalId} is still pending — voting opens at height ${opensAt}, ` +
825
894
  `and the tip is ${clock.height} (${opensAt - clock.height} blocks to go, u436).`));
826
895
  }
827
896
  if (storyPhase !== "voting") {
828
897
  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.")));
898
+ `Nothing was signed.`));
832
899
  }
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.`));
900
+ if (num(weight) < params.minWeightToAct) {
901
+ return createErrorResponse(new Error(`Your weight is ${num(weight)}, below the minWeightToAct of ` +
902
+ `${params.minWeightToAct} (u401). Call legion_contribute first.`));
836
903
  }
837
904
  const result = await callContract(account, {
838
905
  contractAddress: GOV_ADDRESS,
839
906
  contractName: GOV_NAME,
840
907
  functionName: "vote",
841
- functionArgs: [uintCV(proposalId), boolCV(support)],
908
+ functionArgs: [
909
+ uintCV(proposalId),
910
+ boolCV(support),
911
+ stringAsciiCV(rationale),
912
+ ],
842
913
  // Voting moves nothing.
843
914
  postConditionMode: PostConditionMode.Deny,
844
915
  postConditions: [],
845
916
  });
917
+ const yesAfter = support ? story.yesWeight + num(weight) : story.yesWeight;
918
+ const yesNeeded = story.payout * params.yesMultiple;
846
919
  return createJsonResponse({
847
920
  success: true,
848
921
  txid: result.txid,
849
922
  explorerUrl: explorerUrl(result.txid),
850
923
  proposalId,
924
+ era: LIVE_ERA.version,
851
925
  support,
926
+ rationale,
852
927
  votedWith: num(weight),
853
928
  voter: account.address,
854
929
  network: LEGION_NETWORK,
855
930
  voteEnd: story.voteEnd,
856
931
  blocksLeftToVote: story.voteEnd - clock.height,
932
+ yesWeightAfter: yesAfter,
933
+ yesWeightNeeded: yesNeeded,
934
+ yesWeightMet: yesAfter >= yesNeeded,
857
935
  note: "One vote per principal — this cannot be changed or withdrawn.",
858
936
  });
859
937
  }
@@ -862,82 +940,13 @@ export function registerLegionTools(server) {
862
940
  }
863
941
  });
864
942
  // ==========================================================================
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
943
  // legion_conclude
935
944
  // ==========================================================================
936
945
  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" +
946
+ description: "Settle a proposal and, if it passed, pay the proposer. Permissionless — anyone may " +
947
+ "call it, and someone must. Conclude opens the moment voting closes.\n\n" +
939
948
  "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 " +
949
+ "concluded after. Concluding late pays what concluding early would; the payout was " +
941
950
  "snapshotted at propose time.\n\n" +
942
951
  "Requires an unlocked wallet — caller pays gas, proposer gets the payout.",
943
952
  inputSchema: {
@@ -963,14 +972,14 @@ export function registerLegionTools(server) {
963
972
  }
964
973
  if (storyPhase === "expired") {
965
974
  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.`));
975
+ `${story.voteEnd + params.concludeWindow} and the tip is ${clock.height} ` +
976
+ `(u435). It can no longer be concluded and pays nobody. The proposer's weight ` +
977
+ `lock has already released itself.`));
969
978
  }
970
979
  if (storyPhase !== "concludable") {
971
- const vetoEnd = story.voteEnd + params.vetoWindow;
972
980
  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).`));
981
+ `${story.voteEnd} and the tip is ${clock.height} ` +
982
+ `(${story.voteEnd - clock.height} blocks to go, u408).`));
974
983
  }
975
984
  const prediction = predictOutcome(story, params, num(pool), clock.height);
976
985
  const result = await callContract(account, {
@@ -979,12 +988,12 @@ export function registerLegionTools(server) {
979
988
  functionName: "conclude",
980
989
  functionArgs: [uintCV(proposalId)],
981
990
  postConditionMode: PostConditionMode.Deny,
982
- // The treasury pays either the whole snapshotted draw or nothing at
991
+ // The treasury pays either the whole snapshotted payout or nothing at
983
992
  // all, and the outcome is decided on chain at mining time. A cap of
984
- // the draw covers both, and covers nothing larger.
993
+ // the payout covers both, and covers nothing larger.
985
994
  postConditions: [
986
- Pc.principal(LEGION_TREASURY_CONTRACT)
987
- .willSendLte(story.draw)
995
+ Pc.principal(LIVE_ERA.treasury)
996
+ .willSendLte(story.payout)
988
997
  .ft(token.contract, token.assetName),
989
998
  ],
990
999
  });
@@ -1002,9 +1011,9 @@ export function registerLegionTools(server) {
1002
1011
  tally: {
1003
1012
  yes: story.yesWeight,
1004
1013
  no: story.noWeight,
1005
- veto: story.vetoWeight,
1006
1014
  cast: prediction.cast,
1007
- eligible: story.eligibleSnapshot,
1015
+ yesNeeded: prediction.yesWeightRequired,
1016
+ totalWeightAtOpen: story.totalWeightAtOpen,
1008
1017
  voters: story.voterCount,
1009
1018
  },
1010
1019
  note: "Expected outcome is computed from the tally as of now with the contract's own " +
@@ -1022,10 +1031,11 @@ export function registerLegionTools(server) {
1022
1031
  description: "Inscribe a news piece to a Bitcoin ordinal as markdown — STEP 1, the commit.\n\n" +
1023
1032
  "Content type is text/markdown; the title is prepended as an H1 unless the body already " +
1024
1033
  "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. A mainnet ` +
1027
- "inscription therefore needs `confirmMainnetSpend: true`; without it the tool prices " +
1028
- "the piece and refuses, so real BTC is never spent by accident.\n\n" +
1034
+ `SPENDS REAL BITCOIN on L1, on the Bitcoin network NETWORK names (currently ${NETWORK}) ` +
1035
+ `— this is native BTC from your UTXOs, not the sBTC the rest of the legion moves on ` +
1036
+ `Stacks ${LEGION_NETWORK}. A mainnet inscription needs \`confirmMainnetSpend: true\`; ` +
1037
+ "without it the tool prices the piece and refuses, so real BTC is never spent by " +
1038
+ "accident.\n\n" +
1029
1039
  "Optional `parentInscriptionId` files the piece as a child of a parent you own, binding " +
1030
1040
  "your pieces to one inscribed identity. That is authorship, not originality.\n\n" +
1031
1041
  "Pre-flights content size, fee estimate, parent ownership, funding and network before " +
@@ -1070,9 +1080,9 @@ export function registerLegionTools(server) {
1070
1080
  }, async ({ title, body, parentInscriptionId, dryRun, confirmMainnetSpend, allowNonMainnet, feeRate }) => {
1071
1081
  try {
1072
1082
  // A testnet inscription is almost always a misconfiguration here: the
1073
- // legion's governance is testnet but its links are read on mainnet
1074
- // ordinals.com, so the piece would be unreadable to every voter. A JSON
1075
- // warning field was too easy to miss for something that costs sats.
1083
+ // link is read on mainnet ordinals.com, so the piece would be unreadable
1084
+ // to every voter. A JSON warning field was too easy to miss for
1085
+ // something that costs sats.
1076
1086
  if (NETWORK !== "mainnet" && !allowNonMainnet) {
1077
1087
  return createErrorResponse(new Error(`Bitcoin network is ${NETWORK}, and ordinals.com serves mainnet inscriptions ` +
1078
1088
  `only — voters could not open the link this produces. Set NETWORK=mainnet, ` +
@@ -1191,18 +1201,16 @@ export function registerLegionTools(server) {
1191
1201
  nextStep: "Nothing was signed. Re-call without dryRun to broadcast the commit.",
1192
1202
  });
1193
1203
  }
1194
- // The legion itself is testnet, so an agent working through it has no
1195
- // reason to expect real money to move. Inscription is the one step that
1196
- // does, and it cannot be moved off mainnet (ordinals.com indexes mainnet
1197
- // only), so the cost is quoted and consent taken instead.
1204
+ // This is native BTC leaving L1 UTXOs, which the SPEND_LIMIT_* sats rail
1205
+ // does not meter on this path — so consent is taken explicitly instead,
1206
+ // with the exact cost quoted.
1198
1207
  if (NETWORK === "mainnet" && !confirmMainnetSpend) {
1199
1208
  const total = commitResult.fee + commitResult.revealAmount;
1200
1209
  return createErrorResponse(new Error(`This would spend ${total} sats of REAL BITCOIN on mainnet — ` +
1201
1210
  `${commitResult.fee} commit fee plus ${commitResult.revealAmount} locked for ` +
1202
- `the reveal. The legion's governance is Stacks ${LEGION_NETWORK}, but the ` +
1203
- `inscription is mainnet Bitcoin because ordinals.com indexes mainnet only. ` +
1204
- `Nothing was signed. Re-call with confirmMainnetSpend: true to proceed, or ` +
1205
- `dryRun: true for the full breakdown.`));
1211
+ `the reveal. This is native L1 BTC from your UTXOs, separate from the sBTC ` +
1212
+ `the legion's governance moves on Stacks. Nothing was signed. Re-call with ` +
1213
+ `confirmMainnetSpend: true to proceed, or dryRun: true for the full breakdown.`));
1206
1214
  }
1207
1215
  const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
1208
1216
  const commitTxid = await mempoolApi.broadcastTransaction(commitSigned.txHex);