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