@aibtc/mcp-server 1.66.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.
- package/dist/config/legion.d.ts +53 -11
- package/dist/config/legion.js +77 -20
- package/dist/services/legion.service.d.ts +95 -43
- package/dist/services/legion.service.js +224 -104
- package/dist/tools/index.js +1 -1
- package/dist/tools/legion.tools.d.ts +14 -6
- package/dist/tools/legion.tools.js +312 -279
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AIBTC News Legion tools (news-gov
|
|
2
|
+
* AIBTC News Legion tools (mainnet `aibtc-news-gov`).
|
|
3
3
|
*
|
|
4
|
-
* Lifecycle: inscribe → reveal → propose → vote →
|
|
5
|
-
*
|
|
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
|
|
9
|
-
* against
|
|
10
|
-
*
|
|
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 {
|
|
20
|
-
import { assertAscii, buildTimeline, contentUrlFromLink, explainError, getClockHeight, getLastProposalId, getLegionAccount, getLegionToken, getParams, getPhase, getProposeStatus, getStory, getStoryMeta,
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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", "
|
|
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,
|
|
132
|
-
"
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
readTreasury("get-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
readGov("
|
|
146
|
-
|
|
147
|
-
readTreasury("get-
|
|
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:
|
|
174
|
-
treasury:
|
|
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
|
|
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
|
-
|
|
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,
|
|
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`, `
|
|
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+
|
|
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:
|
|
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
|
-
|
|
331
|
+
payoutSats: story.payout,
|
|
286
332
|
tally: {
|
|
287
333
|
yes: story.yesWeight,
|
|
288
334
|
no: story.noWeight,
|
|
289
|
-
|
|
335
|
+
yesNeeded: prediction.yesWeightRequired,
|
|
290
336
|
voters: story.voterCount,
|
|
291
|
-
|
|
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
|
|
330
|
-
"
|
|
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 ${
|
|
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,
|
|
357
|
-
getVoteRecord(proposalId, account.address),
|
|
358
|
-
|
|
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.
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
441
|
+
totalWeightAtOpen: story.totalWeightAtOpen,
|
|
398
442
|
},
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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}.
|
|
416
|
-
`Conclude must be called before
|
|
417
|
-
|
|
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,
|
|
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,
|
|
448
|
-
getParams(),
|
|
449
|
-
getClockHeight(),
|
|
450
|
-
readGov("get-weight", [principalCV(account.address)]),
|
|
451
|
-
readGov("get-free-weight", [principalCV(account.address)]),
|
|
452
|
-
readGov("locked-
|
|
453
|
-
readGov("get-
|
|
454
|
-
readGov("get-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
485
|
-
minWeightToVoteOrPropose: params.
|
|
486
|
-
|
|
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
|
|
540
|
-
"To fund the pool without a
|
|
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.
|
|
591
|
+
if (sats < params.minJoinSats) {
|
|
559
592
|
return createErrorResponse(new Error(`${sats} sats is below the legion's minimum contribution of ` +
|
|
560
|
-
`${params.
|
|
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
|
-
|
|
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:
|
|
628
|
+
expectedWeightAfter: weightAfter,
|
|
590
629
|
network: LEGION_NETWORK,
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
:
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
791
|
-
"vote on their own piece
|
|
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
|
-
`
|
|
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.
|
|
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
|
-
|
|
830
|
-
? "You can still legion_veto it."
|
|
831
|
-
: "Nothing was signed.")));
|
|
901
|
+
`Nothing was signed.`));
|
|
832
902
|
}
|
|
833
|
-
if (num(weight) < params.
|
|
834
|
-
return createErrorResponse(new Error(`Your weight is ${num(weight)}, below the
|
|
835
|
-
|
|
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: [
|
|
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
|
|
938
|
-
"
|
|
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
|
|
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.
|
|
967
|
-
|
|
968
|
-
`
|
|
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
|
-
`${
|
|
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
|
|
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
|
|
996
|
+
// the payout covers both, and covers nothing larger.
|
|
985
997
|
postConditions: [
|
|
986
|
-
Pc.principal(
|
|
987
|
-
.willSendLte(story.
|
|
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
|
-
|
|
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,8 +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
|
-
|
|
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" +
|
|
1027
1042
|
"Optional `parentInscriptionId` files the piece as a child of a parent you own, binding " +
|
|
1028
1043
|
"your pieces to one inscribed identity. That is authorship, not originality.\n\n" +
|
|
1029
1044
|
"Pre-flights content size, fee estimate, parent ownership, funding and network before " +
|
|
@@ -1050,6 +1065,11 @@ export function registerLegionTools(server) {
|
|
|
1050
1065
|
.optional()
|
|
1051
1066
|
.describe("Run every pre-flight check and price the inscription, but sign nothing. " +
|
|
1052
1067
|
"Use this to see the cost before spending it."),
|
|
1068
|
+
confirmMainnetSpend: z
|
|
1069
|
+
.boolean()
|
|
1070
|
+
.optional()
|
|
1071
|
+
.describe("Acknowledge that this spends real BTC. Required on mainnet — without it the " +
|
|
1072
|
+
"tool returns the exact cost and refuses."),
|
|
1053
1073
|
allowNonMainnet: z
|
|
1054
1074
|
.boolean()
|
|
1055
1075
|
.optional()
|
|
@@ -1060,12 +1080,12 @@ export function registerLegionTools(server) {
|
|
|
1060
1080
|
.optional()
|
|
1061
1081
|
.describe("Fee rate: 'fast', 'medium', 'slow', or a number in sat/vB (default: medium)"),
|
|
1062
1082
|
},
|
|
1063
|
-
}, async ({ title, body, parentInscriptionId, dryRun, allowNonMainnet, feeRate }) => {
|
|
1083
|
+
}, async ({ title, body, parentInscriptionId, dryRun, confirmMainnetSpend, allowNonMainnet, feeRate }) => {
|
|
1064
1084
|
try {
|
|
1065
1085
|
// A testnet inscription is almost always a misconfiguration here: the
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
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.
|
|
1069
1089
|
if (NETWORK !== "mainnet" && !allowNonMainnet) {
|
|
1070
1090
|
return createErrorResponse(new Error(`Bitcoin network is ${NETWORK}, and ordinals.com serves mainnet inscriptions ` +
|
|
1071
1091
|
`only — voters could not open the link this produces. Set NETWORK=mainnet, ` +
|
|
@@ -1184,6 +1204,17 @@ export function registerLegionTools(server) {
|
|
|
1184
1204
|
nextStep: "Nothing was signed. Re-call without dryRun to broadcast the commit.",
|
|
1185
1205
|
});
|
|
1186
1206
|
}
|
|
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.
|
|
1210
|
+
if (NETWORK === "mainnet" && !confirmMainnetSpend) {
|
|
1211
|
+
const total = commitResult.fee + commitResult.revealAmount;
|
|
1212
|
+
return createErrorResponse(new Error(`This would spend ${total} sats of REAL BITCOIN on mainnet — ` +
|
|
1213
|
+
`${commitResult.fee} commit fee plus ${commitResult.revealAmount} locked for ` +
|
|
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.`));
|
|
1217
|
+
}
|
|
1187
1218
|
const commitSigned = signBtcTransaction(commitResult.tx, account.btcPrivateKey);
|
|
1188
1219
|
const commitTxid = await mempoolApi.broadcastTransaction(commitSigned.txHex);
|
|
1189
1220
|
return createJsonResponse({
|
|
@@ -1225,6 +1256,8 @@ export function registerLegionTools(server) {
|
|
|
1225
1256
|
"derived from all three. The commit's actual output is checked against the script this " +
|
|
1226
1257
|
"content derives, so a mismatch is refused before signing rather than losing the sats.\n\n" +
|
|
1227
1258
|
"Returns the inscription id and link for legion_propose_story.\n\n" +
|
|
1259
|
+
"No mainnet confirmation is asked for here, unlike the commit: those sats are already " +
|
|
1260
|
+
"committed, and refusing the reveal would strand them rather than save them.\n\n" +
|
|
1228
1261
|
"Requires an unlocked managed wallet.",
|
|
1229
1262
|
inputSchema: {
|
|
1230
1263
|
commitTxid: z
|