@aibtc/mcp-server 1.67.0 → 1.68.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,21 @@
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 —
11
+ * `callContract` is not itself a metered chokepoint, but these two take the
12
+ * amount as a direct parameter, which is what the rail needs.
6
13
  *
7
14
  * 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.
15
+ * unlocked wallet for the legion's chain, so a testnet-configured server still
16
+ * signs against the mainnet contracts rather than quietly reading a chain the
17
+ * legion does not live on. Inscription is the exception — it follows the global
18
+ * NETWORK, because ordinals.com indexes Bitcoin mainnet only.
11
19
  *
12
20
  * Fund-moving calls sign in DENY mode with an exact post-condition.
13
21
  */
@@ -16,17 +24,27 @@ import { z } from "zod";
16
24
  import { Pc, boolCV, noneCV, principalCV, someCV, stringAsciiCV, uintCV, PostConditionMode, } from "@stacks/transactions";
17
25
  import { NETWORK, getExplorerTxUrl } from "../config/networks.js";
18
26
  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";
27
+ 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";
28
+ 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
29
  import { callContract } from "../transactions/builder.js";
30
+ import { getSpendLimiter } from "../services/spend-limiter.js";
22
31
  import { getWalletManager } from "../services/wallet-manager.js";
23
32
  import { MempoolApi, getMempoolTxUrl } from "../services/mempool-api.js";
24
33
  import { buildCommitTransaction, buildRevealTransaction, deriveRevealScript, } from "../transactions/inscription-builder.js";
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,25 @@ 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. callContract is not
600
+ // itself a metered chokepoint, but the amount is a direct parameter, so
601
+ // the rail applies cleanly — and must, before anything is signed.
602
+ await getSpendLimiter().check("sats", BigInt(sats), account.address);
568
603
  const quotedWeight = num(await readGov("quote-weight", [uintCV(sats)]));
569
604
  const result = await callContract(account, {
570
605
  contractAddress: GOV_ADDRESS,
@@ -578,6 +613,10 @@ export function registerLegionTools(server) {
578
613
  .ft(token.contract, token.assetName),
579
614
  ],
580
615
  });
616
+ await getSpendLimiter().record("sats", BigInt(sats), account.address);
617
+ const weightAfter = num(weightBefore) + quotedWeight;
618
+ const wasMember = num(weightBefore) >= params.minWeightToAct;
619
+ const willBeMember = weightAfter >= params.minWeightToAct;
581
620
  return createJsonResponse({
582
621
  success: true,
583
622
  txid: result.txid,
@@ -586,12 +625,22 @@ export function registerLegionTools(server) {
586
625
  contributedSats: sats,
587
626
  expectedWeightMinted: quotedWeight,
588
627
  weightBefore: num(weightBefore),
589
- expectedWeightAfter: num(weightBefore) + quotedWeight,
628
+ expectedWeightAfter: weightAfter,
590
629
  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.`,
630
+ membership: {
631
+ joiningAsNewMember: !wasMember && willBeMember,
632
+ memberCountBefore: membership.memberCount,
633
+ membersToActivate: membership.membersToActivate,
634
+ activated: membership.activated,
635
+ },
636
+ warning: "Contributions are final and spend real sBTC. There is no withdrawal path.",
637
+ nextStep: willBeMember
638
+ ? membership.activated
639
+ ? "Once it confirms you can legion_vote and legion_propose_story."
640
+ : `Once it confirms you can legion_vote. Proposing stays blocked until the ` +
641
+ `legion reaches ${membership.membersToActivate} members (u441).`
642
+ : `You will still be below minWeightToAct (${params.minWeightToAct}) — contribute ` +
643
+ `more to vote or propose.`,
595
644
  });
596
645
  }
597
646
  catch (error) {
@@ -604,9 +653,10 @@ export function registerLegionTools(server) {
604
653
  server.registerTool("legion_sponsor", {
605
654
  description: "Fund the pool WITHOUT minting voting weight, with a name on the record.\n\n" +
606
655
  "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" +
656
+ "but does enlarge every payout, since the payout is a fraction of the whole pool.\n\n" +
608
657
  "`name`, `link` and `memo` are unverified — the paying principal and txid are the real " +
609
- "identity. FINAL: no refund path exists.\n\n" +
658
+ "identity. SPENDS REAL sBTC AND IS FINAL: no refund path exists.\n\n" +
659
+ "Metered against the wallet's `sats` spending limit before signing.\n\n" +
610
660
  "Requires an unlocked wallet.",
611
661
  inputSchema: {
612
662
  sats: z
@@ -641,7 +691,7 @@ export function registerLegionTools(server) {
641
691
  const [token, minSponsor, balance] = await Promise.all([
642
692
  getLegionToken(),
643
693
  readTreasury("get-min-sponsor"),
644
- sbtcBalance((await getLegionAccount()).address),
694
+ sbtcBalance(account.address),
645
695
  ]);
646
696
  if (sats < num(minSponsor)) {
647
697
  return createErrorResponse(new Error(`${sats} sats is below the treasury's minimum sponsorship of ` +
@@ -650,6 +700,8 @@ export function registerLegionTools(server) {
650
700
  if (balance < sats) {
651
701
  return createErrorResponse(new Error(`Your sBTC balance is ${balance} sats — not enough to sponsor ${sats}.`));
652
702
  }
703
+ // Same rail as contribute: real sBTC, no refund, amount is a direct param.
704
+ await getSpendLimiter().check("sats", BigInt(sats), account.address);
653
705
  const result = await callContract(account, {
654
706
  contractAddress: TREASURY_ADDRESS,
655
707
  contractName: TREASURY_NAME,
@@ -667,6 +719,7 @@ export function registerLegionTools(server) {
667
719
  .ft(token.contract, token.assetName),
668
720
  ],
669
721
  });
722
+ await getSpendLimiter().record("sats", BigInt(sats), account.address);
670
723
  return createJsonResponse({
671
724
  success: true,
672
725
  txid: result.txid,
@@ -678,7 +731,7 @@ export function registerLegionTools(server) {
678
731
  memo: memo ?? "",
679
732
  mintedWeight: 0,
680
733
  network: LEGION_NETWORK,
681
- warning: "Final. No refund path exists. This mints no voting weight — use " +
734
+ warning: "Final. Real sBTC, and no refund path exists. This mints no voting weight — use " +
682
735
  "legion_contribute if you wanted a vote.",
683
736
  note: "How long a sponsor badge shows is decided off-chain by the reader at " +
684
737
  `${LEGION_SITE_URL}, not by the contract.`,
@@ -692,8 +745,10 @@ export function registerLegionTools(server) {
692
745
  // legion_propose_story — open the vote on an inscribed piece
693
746
  // ==========================================================================
694
747
  server.registerTool("legion_propose_story", {
695
- description: "Open a vote on one inscribed piece. If it passes, YOU are paid the draw — the " +
748
+ description: "Open a vote on one inscribed piece. If it passes, YOU are paid the payout — the " +
696
749
  "proposer is the only reachable payee.\n\n" +
750
+ "The legion must be ACTIVATED first: until it reaches its member threshold, every " +
751
+ "propose reverts (u441) no matter how much weight you hold. Check legion_status.\n\n" +
697
752
  "The contract stores link, title and description verbatim and never reads the " +
698
753
  "inscription. Proposing locks your ENTIRE weight until the piece resolves — one live " +
699
754
  "proposal per principal; the lock is never spent and releases on every outcome.\n\n" +
@@ -731,7 +786,7 @@ export function registerLegionTools(server) {
731
786
  getClockHeight(),
732
787
  ]);
733
788
  if (!proposeStatus.canPropose) {
734
- const blockers = proposeBlockers(proposeStatus, params.minWeight);
789
+ const blockers = proposeBlockers(proposeStatus, params.minWeightToAct);
735
790
  return createErrorResponse(new Error(`Cannot propose right now: ${blockers.join("; ")}. ` +
736
791
  `Current height ${clock.height}. Nothing was signed.`));
737
792
  }
@@ -744,12 +799,12 @@ export function registerLegionTools(server) {
744
799
  stringAsciiCV(title),
745
800
  stringAsciiCV(body),
746
801
  ],
747
- // The bond is weight, not sats: no sBTC moves on propose, so DENY with
802
+ // The lock is weight, not sats: no sBTC moves on propose, so DENY with
748
803
  // no conditions is exactly right.
749
804
  postConditionMode: PostConditionMode.Deny,
750
805
  postConditions: [],
751
806
  });
752
- const votingOpensAt = clock.height + params.votingDelay;
807
+ const votingOpensAt = clock.height + params.voteDelay;
753
808
  const voteEnd = votingOpensAt + params.voteWindow;
754
809
  return createJsonResponse({
755
810
  success: true,
@@ -762,19 +817,22 @@ export function registerLegionTools(server) {
762
817
  description: body,
763
818
  network: LEGION_NETWORK,
764
819
  weightLocked: proposeStatus.lockOnPropose,
765
- expectedDrawSats: proposeStatus.draw,
820
+ expectedPayoutSats: proposeStatus.payout,
821
+ yesWeightNeeded: proposeStatus.payout * params.yesMultiple,
766
822
  expectedTimeline: {
767
823
  note: "Approximate — the contract snapshots these at the block that mines the tx.",
768
824
  filedAtAbout: clock.height,
769
825
  votingOpensAbout: votingOpensAt,
770
826
  voteEndsAbout: voteEnd,
771
- vetoEndsAbout: voteEnd + params.vetoWindow,
772
- concludeDeadlineAbout: voteEnd + params.vetoWindow + params.concludeWindow,
827
+ concludeDeadlineAbout: voteEnd + params.concludeWindow,
773
828
  clock: `${clock.clock} blocks`,
774
829
  },
775
830
  nextSteps: [
776
831
  "legion_list_stories to pick up the assigned proposalId once it confirms",
777
- `Votes cannot be cast for ~${params.votingDelay} blocks (the pending period)`,
832
+ `Votes cannot be cast for ~${params.voteDelay} blocks (the pending period)`,
833
+ `To be paid, this needs ${params.minVoters}+ voter(s), ${params.votingThreshold}% ` +
834
+ `yes, AND at least ${proposeStatus.payout * params.yesMultiple} yes weight — ` +
835
+ `you cannot vote for your own piece, so that weight must come from others`,
778
836
  "Someone must call legion_conclude inside the conclude window or the piece expires and pays nobody",
779
837
  ],
780
838
  });
@@ -787,8 +845,10 @@ export function registerLegionTools(server) {
787
845
  // legion_vote
788
846
  // ==========================================================================
789
847
  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" +
848
+ description: "Vote yes or no with your current weight, with a written rationale recorded on chain. " +
849
+ "One vote per principal; a proposer cannot vote on their own piece. Votes cannot be " +
850
+ "changed.\n\n" +
851
+ "Voting no is the ONLY way to stop a piece — this legion has no veto.\n\n" +
792
852
  "Read the inscription first — legion_get_story gives you the link.\n\n" +
793
853
  "Pre-flights phase, weight and prior vote. Requires an unlocked wallet.",
794
854
  inputSchema: {
@@ -796,8 +856,14 @@ export function registerLegionTools(server) {
796
856
  support: z
797
857
  .boolean()
798
858
  .describe("true = yes (pay this piece), false = no"),
859
+ rationale: z
860
+ .string()
861
+ .min(1)
862
+ .max(MAX_RATIONALE_LENGTH)
863
+ .describe(`Why you voted this way, recorded on chain (ASCII, ≤${MAX_RATIONALE_LENGTH} chars). ` +
864
+ "Required and must be non-empty — the contract rejects a blank one (u440)."),
799
865
  },
800
- }, async ({ proposalId, support }) => {
866
+ }, async ({ proposalId, support, rationale }) => {
801
867
  try {
802
868
  const account = await getLegionAccount();
803
869
  const [params, story, storyPhase, weight, existingVote, clock] = await Promise.all([
@@ -808,6 +874,11 @@ export function registerLegionTools(server) {
808
874
  getVoteRecord(proposalId, account.address),
809
875
  getClockHeight(),
810
876
  ]);
877
+ if (rationale.trim().length === 0) {
878
+ return createErrorResponse(new Error(`The rationale cannot be blank (u440). Pass a sentence on why this piece is ` +
879
+ `or is not worth paying for. Nothing was signed.`));
880
+ }
881
+ assertAscii(rationale, "rationale", MAX_RATIONALE_LENGTH);
811
882
  if (!story) {
812
883
  return createErrorResponse(new Error(`No proposal ${proposalId} in this legion (u404).`));
813
884
  }
@@ -817,43 +888,53 @@ export function registerLegionTools(server) {
817
888
  }
818
889
  if (account.address === story.proposer) {
819
890
  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.`));
891
+ `This era has no veto either, so there is no way to withdraw it — let it run ` +
892
+ `or let the conclude window lapse.`));
821
893
  }
822
894
  if (storyPhase === "pending") {
823
- const opensAt = story.createdAt + params.votingDelay;
895
+ const opensAt = story.createdAt + params.voteDelay;
824
896
  return createErrorResponse(new Error(`Proposal ${proposalId} is still pending — voting opens at height ${opensAt}, ` +
825
897
  `and the tip is ${clock.height} (${opensAt - clock.height} blocks to go, u436).`));
826
898
  }
827
899
  if (storyPhase !== "voting") {
828
900
  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.")));
901
+ `Nothing was signed.`));
832
902
  }
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.`));
903
+ if (num(weight) < params.minWeightToAct) {
904
+ return createErrorResponse(new Error(`Your weight is ${num(weight)}, below the minWeightToAct of ` +
905
+ `${params.minWeightToAct} (u401). Call legion_contribute first.`));
836
906
  }
837
907
  const result = await callContract(account, {
838
908
  contractAddress: GOV_ADDRESS,
839
909
  contractName: GOV_NAME,
840
910
  functionName: "vote",
841
- functionArgs: [uintCV(proposalId), boolCV(support)],
911
+ functionArgs: [
912
+ uintCV(proposalId),
913
+ boolCV(support),
914
+ stringAsciiCV(rationale),
915
+ ],
842
916
  // Voting moves nothing.
843
917
  postConditionMode: PostConditionMode.Deny,
844
918
  postConditions: [],
845
919
  });
920
+ const yesAfter = support ? story.yesWeight + num(weight) : story.yesWeight;
921
+ const yesNeeded = story.payout * params.yesMultiple;
846
922
  return createJsonResponse({
847
923
  success: true,
848
924
  txid: result.txid,
849
925
  explorerUrl: explorerUrl(result.txid),
850
926
  proposalId,
927
+ era: LIVE_ERA.version,
851
928
  support,
929
+ rationale,
852
930
  votedWith: num(weight),
853
931
  voter: account.address,
854
932
  network: LEGION_NETWORK,
855
933
  voteEnd: story.voteEnd,
856
934
  blocksLeftToVote: story.voteEnd - clock.height,
935
+ yesWeightAfter: yesAfter,
936
+ yesWeightNeeded: yesNeeded,
937
+ yesWeightMet: yesAfter >= yesNeeded,
857
938
  note: "One vote per principal — this cannot be changed or withdrawn.",
858
939
  });
859
940
  }
@@ -862,82 +943,13 @@ export function registerLegionTools(server) {
862
943
  }
863
944
  });
864
945
  // ==========================================================================
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
946
  // legion_conclude
935
947
  // ==========================================================================
936
948
  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" +
949
+ description: "Settle a proposal and, if it passed, pay the proposer. Permissionless — anyone may " +
950
+ "call it, and someone must. Conclude opens the moment voting closes.\n\n" +
939
951
  "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 " +
952
+ "concluded after. Concluding late pays what concluding early would; the payout was " +
941
953
  "snapshotted at propose time.\n\n" +
942
954
  "Requires an unlocked wallet — caller pays gas, proposer gets the payout.",
943
955
  inputSchema: {
@@ -963,14 +975,14 @@ export function registerLegionTools(server) {
963
975
  }
964
976
  if (storyPhase === "expired") {
965
977
  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.`));
978
+ `${story.voteEnd + params.concludeWindow} and the tip is ${clock.height} ` +
979
+ `(u435). It can no longer be concluded and pays nobody. The proposer's weight ` +
980
+ `lock has already released itself.`));
969
981
  }
970
982
  if (storyPhase !== "concludable") {
971
- const vetoEnd = story.voteEnd + params.vetoWindow;
972
983
  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).`));
984
+ `${story.voteEnd} and the tip is ${clock.height} ` +
985
+ `(${story.voteEnd - clock.height} blocks to go, u408).`));
974
986
  }
975
987
  const prediction = predictOutcome(story, params, num(pool), clock.height);
976
988
  const result = await callContract(account, {
@@ -979,12 +991,12 @@ export function registerLegionTools(server) {
979
991
  functionName: "conclude",
980
992
  functionArgs: [uintCV(proposalId)],
981
993
  postConditionMode: PostConditionMode.Deny,
982
- // The treasury pays either the whole snapshotted draw or nothing at
994
+ // The treasury pays either the whole snapshotted payout or nothing at
983
995
  // all, and the outcome is decided on chain at mining time. A cap of
984
- // the draw covers both, and covers nothing larger.
996
+ // the payout covers both, and covers nothing larger.
985
997
  postConditions: [
986
- Pc.principal(LEGION_TREASURY_CONTRACT)
987
- .willSendLte(story.draw)
998
+ Pc.principal(LIVE_ERA.treasury)
999
+ .willSendLte(story.payout)
988
1000
  .ft(token.contract, token.assetName),
989
1001
  ],
990
1002
  });
@@ -1002,9 +1014,9 @@ export function registerLegionTools(server) {
1002
1014
  tally: {
1003
1015
  yes: story.yesWeight,
1004
1016
  no: story.noWeight,
1005
- veto: story.vetoWeight,
1006
1017
  cast: prediction.cast,
1007
- eligible: story.eligibleSnapshot,
1018
+ yesNeeded: prediction.yesWeightRequired,
1019
+ totalWeightAtOpen: story.totalWeightAtOpen,
1008
1020
  voters: story.voterCount,
1009
1021
  },
1010
1022
  note: "Expected outcome is computed from the tally as of now with the contract's own " +
@@ -1022,10 +1034,11 @@ export function registerLegionTools(server) {
1022
1034
  description: "Inscribe a news piece to a Bitcoin ordinal as markdown — STEP 1, the commit.\n\n" +
1023
1035
  "Content type is text/markdown; the title is prepended as an H1 unless the body already " +
1024
1036
  "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" +
1037
+ `SPENDS REAL BITCOIN on L1, on the Bitcoin network NETWORK names (currently ${NETWORK}) ` +
1038
+ `— this is native BTC from your UTXOs, not the sBTC the rest of the legion moves on ` +
1039
+ `Stacks ${LEGION_NETWORK}. A mainnet inscription needs \`confirmMainnetSpend: true\`; ` +
1040
+ "without it the tool prices the piece and refuses, so real BTC is never spent by " +
1041
+ "accident.\n\n" +
1029
1042
  "Optional `parentInscriptionId` files the piece as a child of a parent you own, binding " +
1030
1043
  "your pieces to one inscribed identity. That is authorship, not originality.\n\n" +
1031
1044
  "Pre-flights content size, fee estimate, parent ownership, funding and network before " +
@@ -1070,9 +1083,9 @@ export function registerLegionTools(server) {
1070
1083
  }, async ({ title, body, parentInscriptionId, dryRun, confirmMainnetSpend, allowNonMainnet, feeRate }) => {
1071
1084
  try {
1072
1085
  // 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.
1086
+ // link is read on mainnet ordinals.com, so the piece would be unreadable
1087
+ // to every voter. A JSON warning field was too easy to miss for
1088
+ // something that costs sats.
1076
1089
  if (NETWORK !== "mainnet" && !allowNonMainnet) {
1077
1090
  return createErrorResponse(new Error(`Bitcoin network is ${NETWORK}, and ordinals.com serves mainnet inscriptions ` +
1078
1091
  `only — voters could not open the link this produces. Set NETWORK=mainnet, ` +
@@ -1191,18 +1204,16 @@ export function registerLegionTools(server) {
1191
1204
  nextStep: "Nothing was signed. Re-call without dryRun to broadcast the commit.",
1192
1205
  });
1193
1206
  }
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.
1207
+ // This is native BTC leaving L1 UTXOs, which the SPEND_LIMIT_* sats rail
1208
+ // does not meter on this path — so consent is taken explicitly instead,
1209
+ // with the exact cost quoted.
1198
1210
  if (NETWORK === "mainnet" && !confirmMainnetSpend) {
1199
1211
  const total = commitResult.fee + commitResult.revealAmount;
1200
1212
  return createErrorResponse(new Error(`This would spend ${total} sats of REAL BITCOIN on mainnet — ` +
1201
1213
  `${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.`));
1214
+ `the reveal. This is native L1 BTC from your UTXOs, separate from the sBTC ` +
1215
+ `the legion's governance moves on Stacks. Nothing was signed. Re-call with ` +
1216
+ `confirmMainnetSpend: true to proceed, or dryRun: true for the full breakdown.`));
1206
1217
  }
1207
1218
  const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
1208
1219
  const commitTxid = await mempoolApi.broadcastTransaction(commitSigned.txHex);