@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,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AIBTC News Legion
|
|
2
|
+
* AIBTC News Legion chain reads.
|
|
3
3
|
*
|
|
4
4
|
* Everything here reads the deployed contracts rather than trusting constants:
|
|
5
|
-
* governance parameters, the timing mode
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* governance parameters, the timing mode, and the sBTC token the treasury
|
|
6
|
+
* holds. A doc that drifts from the contract is a doc; a read that drifts from
|
|
7
|
+
* the contract is a bug, so there is nothing to drift.
|
|
8
|
+
*
|
|
9
|
+
* The mainnet legion has NO VETO and NO QUORUM. A story is paid when enough
|
|
10
|
+
* distinct voters turn out, the yes share clears the threshold, AND the yes
|
|
11
|
+
* weight covers `yesMultiple` times the payout it releases — the last of these
|
|
12
|
+
* being what stops a small clique from voting money out of a large pool.
|
|
13
|
+
* Proposals are also blocked outright until the legion activates at
|
|
14
|
+
* `membersToActivate` members.
|
|
15
|
+
*
|
|
16
|
+
* Every cache is keyed by era, so a future generation cannot be served the
|
|
17
|
+
* previous one's answers.
|
|
9
18
|
*/
|
|
10
19
|
import { ClarityType, cvToJSON, deserializeCV, getAddressFromPrivateKey, principalCV, uintCV, } from "@stacks/transactions";
|
|
11
|
-
import {
|
|
20
|
+
import { LEGION_NETWORK, LIVE_ERA, ORDINALS_CONTENT_BASE, ORDINALS_INSCRIPTION_BASE, } from "../config/legion.js";
|
|
12
21
|
import { getHiroApi } from "./hiro-api.js";
|
|
13
22
|
import { getAccount } from "./x402.service.js";
|
|
14
23
|
// ---------------------------------------------------------------------------
|
|
@@ -79,71 +88,191 @@ export async function readLegionContract(contractId, functionName, functionArgs
|
|
|
79
88
|
: result.result;
|
|
80
89
|
return simplifyCV(deserializeCV(Buffer.from(hex, "hex")));
|
|
81
90
|
}
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Contract shape assertion
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/**
|
|
95
|
+
* Gov functions the tools call, with the arity they are called with.
|
|
96
|
+
*
|
|
97
|
+
* Contract ids are constants, so nothing stops a redeploy under the same name
|
|
98
|
+
* from having a different shape — the reason this legion moved chains in the
|
|
99
|
+
* first place. Without this check a renamed read surfaces as `undefined` deep
|
|
100
|
+
* inside a tally, and a changed `vote` arity surfaces as a broadcast that
|
|
101
|
+
* reverts after paying gas. Checked once per contract per process.
|
|
102
|
+
*/
|
|
103
|
+
const REQUIRED_GOV_FUNCTIONS = {
|
|
104
|
+
// reads
|
|
105
|
+
"get-params": 0,
|
|
106
|
+
"get-timing-mode": 0,
|
|
107
|
+
"get-phase": 1,
|
|
108
|
+
"get-story": 1,
|
|
109
|
+
"get-story-meta": 1,
|
|
110
|
+
"get-last-proposal-id": 0,
|
|
111
|
+
"get-vote-record": 2,
|
|
112
|
+
"get-weight": 1,
|
|
113
|
+
"get-free-weight": 1,
|
|
114
|
+
"get-locked-weight": 1,
|
|
115
|
+
"get-locked-until": 1,
|
|
116
|
+
"get-live-proposal": 1,
|
|
117
|
+
"get-total-weight": 0,
|
|
118
|
+
"get-next-propose-height": 0,
|
|
119
|
+
"get-member-count": 0,
|
|
120
|
+
"is-activated": 0,
|
|
121
|
+
"propose-status": 1,
|
|
122
|
+
"quote-payout": 0,
|
|
123
|
+
"quote-weight": 1,
|
|
124
|
+
// writes — arity mismatches here cost gas to discover on chain
|
|
125
|
+
contribute: 1,
|
|
126
|
+
"propose-story": 3,
|
|
127
|
+
vote: 3,
|
|
128
|
+
conclude: 1,
|
|
129
|
+
};
|
|
130
|
+
/** Treasury functions the tools call. `execute-payout` is gov's to call, not ours. */
|
|
131
|
+
const REQUIRED_TREASURY_FUNCTIONS = {
|
|
132
|
+
"get-balance": 0,
|
|
133
|
+
"get-weighted-balance": 0,
|
|
134
|
+
"get-min-sponsor": 0,
|
|
135
|
+
"get-gov": 0,
|
|
136
|
+
"get-token": 0,
|
|
137
|
+
"sponsor-in": 4,
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Which required functions a published interface fails to satisfy.
|
|
141
|
+
*
|
|
142
|
+
* Pure, so the comparison is testable without a chain. Extra functions are
|
|
143
|
+
* fine — a contract may offer more than we call. Only missing names and
|
|
144
|
+
* changed arities matter.
|
|
145
|
+
*/
|
|
146
|
+
export function diffContractShape(functions, required) {
|
|
147
|
+
const found = new Map(functions.map((f) => [f.name, f.args.length]));
|
|
148
|
+
const problems = [];
|
|
149
|
+
for (const [name, arity] of Object.entries(required)) {
|
|
150
|
+
const actual = found.get(name);
|
|
151
|
+
if (actual === undefined) {
|
|
152
|
+
problems.push(`missing "${name}"`);
|
|
153
|
+
}
|
|
154
|
+
else if (actual !== arity) {
|
|
155
|
+
problems.push(`"${name}" takes ${actual} args, expected ${arity}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return problems;
|
|
84
159
|
}
|
|
85
|
-
|
|
86
|
-
|
|
160
|
+
/** One in-flight or settled check per contract id. */
|
|
161
|
+
const shapeChecks = new Map();
|
|
162
|
+
async function assertContractShape(contractId, required, label) {
|
|
163
|
+
const cached = shapeChecks.get(contractId);
|
|
164
|
+
if (cached)
|
|
165
|
+
return cached;
|
|
166
|
+
const check = (async () => {
|
|
167
|
+
const iface = await getHiroApi(LEGION_NETWORK).getContractInterface(contractId);
|
|
168
|
+
const problems = diffContractShape(iface.functions, required);
|
|
169
|
+
if (problems.length > 0) {
|
|
170
|
+
throw new Error(`${contractId} is not the ${label} contract these tools expect: ` +
|
|
171
|
+
`${problems.join("; ")}. The contract id is a constant in ` +
|
|
172
|
+
`src/config/legion.ts — either it points at the wrong deployment, or the ` +
|
|
173
|
+
`legion redeployed with a changed interface and the tools need updating. ` +
|
|
174
|
+
`Nothing was read or signed.`);
|
|
175
|
+
}
|
|
176
|
+
})();
|
|
177
|
+
shapeChecks.set(contractId, check);
|
|
178
|
+
// A transient interface fetch failure must not poison the process; only a
|
|
179
|
+
// successful check is worth keeping.
|
|
180
|
+
check.catch(() => shapeChecks.delete(contractId));
|
|
181
|
+
return check;
|
|
182
|
+
}
|
|
183
|
+
export async function readGov(functionName, functionArgs = [], era = LIVE_ERA) {
|
|
184
|
+
await assertContractShape(era.gov, REQUIRED_GOV_FUNCTIONS, "governance");
|
|
185
|
+
return readLegionContract(era.gov, functionName, functionArgs);
|
|
87
186
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
187
|
+
export async function readTreasury(functionName, functionArgs = [], era = LIVE_ERA) {
|
|
188
|
+
await assertContractShape(era.treasury, REQUIRED_TREASURY_FUNCTIONS, "treasury");
|
|
189
|
+
return readLegionContract(era.treasury, functionName, functionArgs);
|
|
190
|
+
}
|
|
191
|
+
const paramsCache = new Map();
|
|
192
|
+
const timingModeCache = new Map();
|
|
193
|
+
const tokenCache = new Map();
|
|
91
194
|
/** Every governance parameter, straight from `get-params`. */
|
|
92
|
-
export async function getParams() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
195
|
+
export async function getParams(era = LIVE_ERA) {
|
|
196
|
+
const cached = paramsCache.get(era.gov);
|
|
197
|
+
if (cached)
|
|
198
|
+
return cached;
|
|
199
|
+
const raw = (await readGov("get-params", [], era));
|
|
200
|
+
const params = {
|
|
98
201
|
votingThreshold: num(raw.votingThreshold),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
202
|
+
minVoters: num(raw.minVoters),
|
|
203
|
+
membersToActivate: num(raw.membersToActivate),
|
|
204
|
+
yesMultiple: num(raw.yesMultiple),
|
|
205
|
+
minWeightToAct: num(raw.minWeightToAct),
|
|
206
|
+
minJoinSats: num(raw.minJoinSats),
|
|
207
|
+
payoutBps: num(raw.payoutBps),
|
|
208
|
+
voteDelay: num(raw.voteDelay),
|
|
105
209
|
voteWindow: num(raw.voteWindow),
|
|
106
|
-
vetoWindow: num(raw.vetoWindow),
|
|
107
210
|
concludeWindow: num(raw.concludeWindow),
|
|
108
|
-
|
|
211
|
+
globalProposeInterval: num(raw.globalProposeInterval),
|
|
212
|
+
};
|
|
213
|
+
paramsCache.set(era.gov, params);
|
|
214
|
+
return params;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether the legion has enough members to accept proposals at all.
|
|
218
|
+
*
|
|
219
|
+
* This is a gate no amount of weight can bypass: a single whale holding the
|
|
220
|
+
* entire pool still cannot propose until `membersToActivate` distinct
|
|
221
|
+
* principals have joined.
|
|
222
|
+
*/
|
|
223
|
+
export async function getMembership(era = LIVE_ERA) {
|
|
224
|
+
const [count, activated, params] = await Promise.all([
|
|
225
|
+
readGov("get-member-count", [], era),
|
|
226
|
+
readGov("is-activated", [], era),
|
|
227
|
+
getParams(era),
|
|
228
|
+
]);
|
|
229
|
+
const memberCount = num(count);
|
|
230
|
+
return {
|
|
231
|
+
memberCount,
|
|
232
|
+
membersToActivate: params.membersToActivate,
|
|
233
|
+
activated: Boolean(activated),
|
|
234
|
+
membersNeeded: Math.max(0, params.membersToActivate - memberCount),
|
|
109
235
|
};
|
|
110
|
-
return paramsCache;
|
|
111
236
|
}
|
|
112
237
|
/**
|
|
113
238
|
* Which clock the windows count on. `"TEST-STACKS-BLOCKS"` counts Stacks
|
|
114
239
|
* blocks; `"PROD-BURN"` counts Bitcoin burn blocks. Never assume — a window
|
|
115
240
|
* measured against the wrong tip is off by a factor of ten.
|
|
116
241
|
*/
|
|
117
|
-
export async function getTimingMode() {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
242
|
+
export async function getTimingMode(era = LIVE_ERA) {
|
|
243
|
+
const cached = timingModeCache.get(era.gov);
|
|
244
|
+
if (cached)
|
|
245
|
+
return cached;
|
|
246
|
+
const mode = (await readGov("get-timing-mode", [], era));
|
|
247
|
+
timingModeCache.set(era.gov, mode);
|
|
248
|
+
return mode;
|
|
122
249
|
}
|
|
123
250
|
/**
|
|
124
251
|
* The sBTC token the treasury actually moves, plus its fungible-token asset
|
|
125
252
|
* name — both needed to write an exact post-condition. Read from the treasury
|
|
126
253
|
* and its contract interface so a post-condition can never name the wrong asset.
|
|
127
254
|
*/
|
|
128
|
-
export async function getLegionToken() {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
255
|
+
export async function getLegionToken(era = LIVE_ERA) {
|
|
256
|
+
const cached = tokenCache.get(era.treasury);
|
|
257
|
+
if (cached)
|
|
258
|
+
return cached;
|
|
259
|
+
const contract = (await readTreasury("get-token", [], era));
|
|
132
260
|
const iface = await getHiroApi(LEGION_NETWORK).getContractInterface(contract);
|
|
133
261
|
const assetName = iface.fungible_tokens?.[0]?.name;
|
|
134
262
|
if (!assetName) {
|
|
135
263
|
throw new Error(`${contract} declares no fungible token — cannot build a post-condition for it.`);
|
|
136
264
|
}
|
|
137
|
-
|
|
138
|
-
|
|
265
|
+
const token = { contract, assetName };
|
|
266
|
+
tokenCache.set(era.treasury, token);
|
|
267
|
+
return token;
|
|
139
268
|
}
|
|
140
269
|
/**
|
|
141
270
|
* The height the contract's windows are measured against, on the clock
|
|
142
271
|
* `get-timing-mode` names.
|
|
143
272
|
*/
|
|
144
|
-
export async function getClockHeight() {
|
|
273
|
+
export async function getClockHeight(era = LIVE_ERA) {
|
|
145
274
|
const [timingMode, info] = await Promise.all([
|
|
146
|
-
getTimingMode(),
|
|
275
|
+
getTimingMode(era),
|
|
147
276
|
getHiroApi(LEGION_NETWORK).getCoreApiInfo(),
|
|
148
277
|
]);
|
|
149
278
|
const clock = timingMode === "PROD-BURN" ? "burn" : "stacks";
|
|
@@ -173,27 +302,26 @@ export async function getLegionAccount() {
|
|
|
173
302
|
network: LEGION_NETWORK,
|
|
174
303
|
};
|
|
175
304
|
}
|
|
176
|
-
export async function getStory(proposalId) {
|
|
177
|
-
const raw = (await readGov("get-story", [uintCV(proposalId)]));
|
|
305
|
+
export async function getStory(proposalId, era = LIVE_ERA) {
|
|
306
|
+
const raw = (await readGov("get-story", [uintCV(proposalId)], era));
|
|
178
307
|
if (!raw)
|
|
179
308
|
return null;
|
|
180
309
|
return {
|
|
181
310
|
proposer: String(raw.proposer),
|
|
182
|
-
|
|
183
|
-
|
|
311
|
+
lockedWeight: num(raw.lockedWeight),
|
|
312
|
+
payout: num(raw.payout),
|
|
184
313
|
createdAt: num(raw.createdAt),
|
|
185
314
|
voteEnd: num(raw.voteEnd),
|
|
186
|
-
|
|
315
|
+
totalWeightAtOpen: num(raw.totalWeightAtOpen),
|
|
187
316
|
yesWeight: num(raw.yesWeight),
|
|
188
317
|
noWeight: num(raw.noWeight),
|
|
189
|
-
vetoWeight: num(raw.vetoWeight),
|
|
190
318
|
voterCount: num(raw.voterCount),
|
|
191
319
|
status: num(raw.status),
|
|
192
320
|
reason: String(raw.reason ?? ""),
|
|
193
321
|
};
|
|
194
322
|
}
|
|
195
|
-
export async function getStoryMeta(proposalId) {
|
|
196
|
-
const raw = (await readGov("get-story-meta", [uintCV(proposalId)]));
|
|
323
|
+
export async function getStoryMeta(proposalId, era = LIVE_ERA) {
|
|
324
|
+
const raw = (await readGov("get-story-meta", [uintCV(proposalId)], era));
|
|
197
325
|
if (!raw)
|
|
198
326
|
return null;
|
|
199
327
|
return {
|
|
@@ -203,53 +331,56 @@ export async function getStoryMeta(proposalId) {
|
|
|
203
331
|
};
|
|
204
332
|
}
|
|
205
333
|
/**
|
|
206
|
-
* `"none" | "pending" | "voting" | "
|
|
207
|
-
* "
|
|
334
|
+
* `"none" | "pending" | "voting" | "concludable" | "expired" | "passed" |
|
|
335
|
+
* "failed"` — the contract's own view of where a piece stands.
|
|
208
336
|
*/
|
|
209
|
-
export async function getPhase(proposalId) {
|
|
210
|
-
return (await readGov("get-phase", [uintCV(proposalId)]));
|
|
337
|
+
export async function getPhase(proposalId, era = LIVE_ERA) {
|
|
338
|
+
return (await readGov("get-phase", [uintCV(proposalId)], era));
|
|
211
339
|
}
|
|
212
|
-
export async function getLastProposalId() {
|
|
213
|
-
return num(await readGov("get-last-proposal-id"));
|
|
340
|
+
export async function getLastProposalId(era = LIVE_ERA) {
|
|
341
|
+
return num(await readGov("get-last-proposal-id", [], era));
|
|
214
342
|
}
|
|
215
|
-
export async function getVoteRecord(proposalId, voter) {
|
|
343
|
+
export async function getVoteRecord(proposalId, voter, era = LIVE_ERA) {
|
|
216
344
|
const raw = (await readGov("get-vote-record", [
|
|
217
345
|
uintCV(proposalId),
|
|
218
346
|
principalCV(voter),
|
|
219
|
-
]));
|
|
347
|
+
], era));
|
|
220
348
|
if (!raw)
|
|
221
349
|
return null;
|
|
222
|
-
return {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
principalCV(voter),
|
|
228
|
-
]));
|
|
229
|
-
if (!raw)
|
|
230
|
-
return null;
|
|
231
|
-
return { weight: num(raw.weight) };
|
|
350
|
+
return {
|
|
351
|
+
support: Boolean(raw.support),
|
|
352
|
+
weight: num(raw.weight),
|
|
353
|
+
rationale: String(raw.rationale ?? ""),
|
|
354
|
+
};
|
|
232
355
|
}
|
|
233
356
|
/** Every propose precondition folded into one read, with the individual gates. */
|
|
234
|
-
export async function getProposeStatus(who) {
|
|
235
|
-
const raw = (await readGov("propose-status", [principalCV(who)]));
|
|
357
|
+
export async function getProposeStatus(who, era = LIVE_ERA) {
|
|
358
|
+
const raw = (await readGov("propose-status", [principalCV(who)], era));
|
|
236
359
|
return {
|
|
237
360
|
canPropose: Boolean(raw.canPropose),
|
|
238
361
|
eligible: Boolean(raw.eligible),
|
|
239
362
|
slotOpen: Boolean(raw.slotOpen),
|
|
240
363
|
noLiveProposal: Boolean(raw.noLiveProposal),
|
|
241
364
|
poolOk: Boolean(raw.poolOk),
|
|
365
|
+
membersOk: Boolean(raw.membersOk),
|
|
366
|
+
memberCount: num(raw.memberCount),
|
|
367
|
+
membersToActivate: num(raw.membersToActivate),
|
|
242
368
|
nextProposeHeight: num(raw.nextProposeHeight),
|
|
243
369
|
lockOnPropose: num(raw.lockOnPropose),
|
|
244
|
-
|
|
370
|
+
payout: num(raw.payout),
|
|
245
371
|
freeWeight: num(raw.freeWeight),
|
|
246
372
|
};
|
|
247
373
|
}
|
|
248
374
|
/** Why a propose would revert right now, in the caller's words. */
|
|
249
|
-
export function proposeBlockers(status,
|
|
375
|
+
export function proposeBlockers(status, minWeightToAct) {
|
|
250
376
|
const blockers = [];
|
|
377
|
+
if (!status.membersOk) {
|
|
378
|
+
blockers.push(`the legion is not activated — ${status.memberCount}/${status.membersToActivate} ` +
|
|
379
|
+
`members hold the minimum weight, so ${status.membersToActivate - status.memberCount} more must join before ANY story can be proposed (u441). ` +
|
|
380
|
+
`Nothing you do alone clears this gate`);
|
|
381
|
+
}
|
|
251
382
|
if (!status.eligible) {
|
|
252
|
-
blockers.push(`your weight is below
|
|
383
|
+
blockers.push(`your weight is below minWeightToAct (${minWeightToAct}) — call legion_contribute first`);
|
|
253
384
|
}
|
|
254
385
|
if (!status.noLiveProposal) {
|
|
255
386
|
blockers.push("you already have a live proposal — one per principal until it concludes or lapses");
|
|
@@ -258,21 +389,20 @@ export function proposeBlockers(status, minWeight) {
|
|
|
258
389
|
blockers.push(`the contract-wide propose interval has not elapsed — next open at height ${status.nextProposeHeight}`);
|
|
259
390
|
}
|
|
260
391
|
if (!status.poolOk) {
|
|
261
|
-
blockers.push("the pool is empty or the
|
|
392
|
+
blockers.push("the pool is empty or the payout would round to zero sats");
|
|
262
393
|
}
|
|
263
394
|
return blockers;
|
|
264
395
|
}
|
|
265
396
|
/** When each window opens and closes, on the clock the contract counts. */
|
|
266
397
|
export function buildTimeline(story, params, height, clock) {
|
|
267
|
-
const votingOpensAt = story.createdAt + params.
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
const nextBoundary = [votingOpensAt, story.voteEnd, vetoEnd, concludeDeadline].find((boundary) => boundary > height);
|
|
398
|
+
const votingOpensAt = story.createdAt + params.voteDelay;
|
|
399
|
+
const concludeDeadline = story.voteEnd + params.concludeWindow;
|
|
400
|
+
const nextBoundary = [votingOpensAt, story.voteEnd, concludeDeadline].find((boundary) => boundary > height);
|
|
271
401
|
return {
|
|
272
402
|
createdAt: story.createdAt,
|
|
273
403
|
votingOpensAt,
|
|
274
404
|
voteEnd: story.voteEnd,
|
|
275
|
-
|
|
405
|
+
concludeOpensAt: story.voteEnd,
|
|
276
406
|
concludeDeadline,
|
|
277
407
|
currentHeight: height,
|
|
278
408
|
clock,
|
|
@@ -291,24 +421,14 @@ export function buildTimeline(story, params, height, clock) {
|
|
|
291
421
|
*/
|
|
292
422
|
export function predictOutcome(story, params, poolBalance, height) {
|
|
293
423
|
const cast = story.yesWeight + story.noWeight;
|
|
294
|
-
const eligible = story.eligibleSnapshot;
|
|
295
|
-
const quorumPct = eligible > 0 ? Math.floor((cast * 100) / eligible) : 0;
|
|
296
424
|
const yesPct = cast > 0 ? Math.floor((story.yesWeight * 100) / cast) : 0;
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
425
|
+
const yesWeightRequired = story.payout * params.yesMultiple;
|
|
426
|
+
// The contract's three gates, in its order. There is no quorum here: nothing
|
|
427
|
+
// divides by totalWeightAtOpen, so a story cannot fail for turnout alone.
|
|
428
|
+
const votersMet = story.voterCount >= params.minVoters;
|
|
300
429
|
const thresholdMet = cast > 0 && yesPct >= params.votingThreshold;
|
|
301
|
-
const
|
|
302
|
-
const base = {
|
|
303
|
-
cast,
|
|
304
|
-
quorumPct,
|
|
305
|
-
yesPct,
|
|
306
|
-
vetoPct,
|
|
307
|
-
quorumMet,
|
|
308
|
-
thresholdMet,
|
|
309
|
-
vetoed,
|
|
310
|
-
participantsMet,
|
|
311
|
-
};
|
|
430
|
+
const yesMet = story.yesWeight >= yesWeightRequired;
|
|
431
|
+
const base = { cast, yesPct, yesWeightRequired, votersMet, thresholdMet, yesMet };
|
|
312
432
|
// Already terminal: report the record.
|
|
313
433
|
if (story.status !== 0) {
|
|
314
434
|
const settledOutcome = story.status === 1 ? "passed" : story.status === 3 ? "expired" : "failed";
|
|
@@ -317,11 +437,10 @@ export function predictOutcome(story, params, poolBalance, height) {
|
|
|
317
437
|
settled: true,
|
|
318
438
|
outcome: settledOutcome,
|
|
319
439
|
reason: story.reason || settledOutcome,
|
|
320
|
-
payout: story.status === 1 ? story.
|
|
440
|
+
payout: story.status === 1 ? story.payout : 0,
|
|
321
441
|
};
|
|
322
442
|
}
|
|
323
|
-
|
|
324
|
-
if (height >= concludeDeadline) {
|
|
443
|
+
if (height >= story.voteEnd + params.concludeWindow) {
|
|
325
444
|
return {
|
|
326
445
|
...base,
|
|
327
446
|
settled: false,
|
|
@@ -330,19 +449,20 @@ export function predictOutcome(story, params, poolBalance, height) {
|
|
|
330
449
|
payout: 0,
|
|
331
450
|
};
|
|
332
451
|
}
|
|
333
|
-
if (
|
|
334
|
-
return { ...base, settled: false, outcome: "failed", reason: "
|
|
335
|
-
}
|
|
336
|
-
if (!quorumMet) {
|
|
337
|
-
return { ...base, settled: false, outcome: "failed", reason: "no-quorum", payout: 0 };
|
|
452
|
+
if (!votersMet) {
|
|
453
|
+
return { ...base, settled: false, outcome: "failed", reason: "no-voters", payout: 0 };
|
|
338
454
|
}
|
|
339
455
|
if (!thresholdMet) {
|
|
340
456
|
return { ...base, settled: false, outcome: "failed", reason: "voted-down", payout: 0 };
|
|
341
457
|
}
|
|
342
|
-
if (
|
|
458
|
+
if (!yesMet) {
|
|
459
|
+
// Approved, but by too little weight to justify the sats it would release.
|
|
460
|
+
return { ...base, settled: false, outcome: "failed", reason: "yes-short", payout: 0 };
|
|
461
|
+
}
|
|
462
|
+
if (story.payout > poolBalance) {
|
|
343
463
|
return { ...base, settled: false, outcome: "failed", reason: "pool-short", payout: 0 };
|
|
344
464
|
}
|
|
345
|
-
return { ...base, settled: false, outcome: "passed", reason: "paid", payout: story.
|
|
465
|
+
return { ...base, settled: false, outcome: "passed", reason: "paid", payout: story.payout };
|
|
346
466
|
}
|
|
347
467
|
// ---------------------------------------------------------------------------
|
|
348
468
|
// Links and validation
|
|
@@ -1,4 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-on cumulative spending limit for the wallet.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server lets an LLM move real funds with no human click-through, so a
|
|
5
|
+
* single poisoned instruction (or a malicious x402 endpoint) could otherwise
|
|
6
|
+
* drain the wallet. This module is the safety rail: every outbound spend path
|
|
7
|
+
* (native STX transfer, BTC L1 transfer, x402/L402 auto-payments, Lightning
|
|
8
|
+
* pays, and contract calls carrying a bounded caller-owned post condition)
|
|
9
|
+
* routes through it, and the limiter blocks once cumulative outflow would
|
|
10
|
+
* exceed a per-session OR per-day cap.
|
|
11
|
+
*
|
|
12
|
+
* Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
|
|
13
|
+
* (`sats`); there is no cross-asset USD normalization. Daily totals are
|
|
14
|
+
* persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
|
|
15
|
+
* the cap survives process restarts; session totals live in memory and reset on
|
|
16
|
+
* wallet unlock/lock/switch.
|
|
17
|
+
*
|
|
18
|
+
* Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
|
|
19
|
+
* surfaces the remaining budget + the env var to raise. An LLM cannot set env
|
|
20
|
+
* vars mid-session, so it cannot raise its own ceiling.
|
|
21
|
+
*
|
|
22
|
+
* Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
|
|
23
|
+
* SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
|
|
24
|
+
*/
|
|
25
|
+
import type { PostCondition } from "@stacks/transactions";
|
|
1
26
|
export type SpendUnit = "ustx" | "sats";
|
|
27
|
+
/**
|
|
28
|
+
* Read spend ceilings out of a contract call's post conditions.
|
|
29
|
+
*
|
|
30
|
+
* A post condition already says "at most N of asset A leaves principal P", in
|
|
31
|
+
* native units, enforced by the chain — which is exactly the bound this rail
|
|
32
|
+
* wants, without taking a price-oracle dependency inside a safety rail.
|
|
33
|
+
*
|
|
34
|
+
* Only conditions that BOUND THE SPEND FROM ABOVE count: `eq`, `lt` and `lte`.
|
|
35
|
+
* A `gt`/`gte` condition is a floor, not a cap, so metering it would invent a
|
|
36
|
+
* ceiling that does not exist. Conditions on another principal are somebody
|
|
37
|
+
* else's funds, and an FT that is not sBTC has no ledger here — both are
|
|
38
|
+
* skipped rather than guessed at.
|
|
39
|
+
*
|
|
40
|
+
* `lt` is metered at its face value (one micro-unit above the true ceiling).
|
|
41
|
+
* Over-metering by one is the safe direction for a cap.
|
|
42
|
+
*/
|
|
43
|
+
export declare function boundedPostConditionSpends(postConditions: PostCondition[] | undefined, accountAddress: string): Array<{
|
|
44
|
+
unit: SpendUnit;
|
|
45
|
+
amount: bigint;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Collapse per-unit so one contract call is metered once per ledger, not once
|
|
49
|
+
* per post condition.
|
|
50
|
+
*/
|
|
51
|
+
export declare function totalBoundedSpends(postConditions: PostCondition[] | undefined, accountAddress: string): Array<{
|
|
52
|
+
unit: SpendUnit;
|
|
53
|
+
amount: bigint;
|
|
54
|
+
}>;
|
|
2
55
|
export declare class SpendLimitError extends Error {
|
|
3
56
|
readonly unit: SpendUnit;
|
|
4
57
|
readonly attempted: number;
|
|
@@ -1,29 +1,63 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { MAINNET_CONTRACTS, TESTNET_CONTRACTS } from "../config/contracts.js";
|
|
5
|
+
/** Asset ids this rail can meter. Any other FT has no ledger to bill. */
|
|
6
|
+
const SBTC_ASSETS = new Set([
|
|
7
|
+
`${MAINNET_CONTRACTS.SBTC_TOKEN}::sbtc-token`,
|
|
8
|
+
`${TESTNET_CONTRACTS.SBTC_TOKEN}::sbtc-token`,
|
|
9
|
+
]);
|
|
1
10
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* The MCP server lets an LLM move real funds with no human click-through, so a
|
|
5
|
-
* single poisoned instruction (or a malicious x402 endpoint) could otherwise
|
|
6
|
-
* drain the wallet. This module is the safety rail: every outbound spend path
|
|
7
|
-
* (native STX transfer, BTC L1 transfer, x402/L402 auto-payments, Lightning
|
|
8
|
-
* pays) routes through it, and the limiter blocks once cumulative outflow would
|
|
9
|
-
* exceed a per-session OR per-day cap.
|
|
11
|
+
* Read spend ceilings out of a contract call's post conditions.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the cap survives process restarts; session totals live in memory and reset on
|
|
15
|
-
* wallet unlock/lock/switch.
|
|
13
|
+
* A post condition already says "at most N of asset A leaves principal P", in
|
|
14
|
+
* native units, enforced by the chain — which is exactly the bound this rail
|
|
15
|
+
* wants, without taking a price-oracle dependency inside a safety rail.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Only conditions that BOUND THE SPEND FROM ABOVE count: `eq`, `lt` and `lte`.
|
|
18
|
+
* A `gt`/`gte` condition is a floor, not a cap, so metering it would invent a
|
|
19
|
+
* ceiling that does not exist. Conditions on another principal are somebody
|
|
20
|
+
* else's funds, and an FT that is not sBTC has no ledger here — both are
|
|
21
|
+
* skipped rather than guessed at.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* `lt` is metered at its face value (one micro-unit above the true ceiling).
|
|
24
|
+
* Over-metering by one is the safe direction for a cap.
|
|
23
25
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
export function boundedPostConditionSpends(postConditions, accountAddress) {
|
|
27
|
+
const spends = [];
|
|
28
|
+
for (const condition of postConditions ?? []) {
|
|
29
|
+
const pc = condition;
|
|
30
|
+
if (pc.address !== accountAddress)
|
|
31
|
+
continue;
|
|
32
|
+
if (pc.condition !== "eq" && pc.condition !== "lt" && pc.condition !== "lte")
|
|
33
|
+
continue;
|
|
34
|
+
if (pc.amount === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
const amount = BigInt(pc.amount);
|
|
37
|
+
if (amount < 0n)
|
|
38
|
+
continue;
|
|
39
|
+
if (pc.type === "stx-postcondition") {
|
|
40
|
+
spends.push({ unit: "ustx", amount });
|
|
41
|
+
}
|
|
42
|
+
else if (pc.type === "ft-postcondition" &&
|
|
43
|
+
pc.asset !== undefined &&
|
|
44
|
+
SBTC_ASSETS.has(pc.asset)) {
|
|
45
|
+
spends.push({ unit: "sats", amount });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return spends;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Collapse per-unit so one contract call is metered once per ledger, not once
|
|
52
|
+
* per post condition.
|
|
53
|
+
*/
|
|
54
|
+
export function totalBoundedSpends(postConditions, accountAddress) {
|
|
55
|
+
const totals = new Map();
|
|
56
|
+
for (const { unit, amount } of boundedPostConditionSpends(postConditions, accountAddress)) {
|
|
57
|
+
totals.set(unit, (totals.get(unit) ?? 0n) + amount);
|
|
58
|
+
}
|
|
59
|
+
return [...totals].map(([unit, amount]) => ({ unit, amount }));
|
|
60
|
+
}
|
|
27
61
|
const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
|
|
28
62
|
const DEFAULT_STATE_FILE = path.join(STORAGE_DIR, "spend-state.json");
|
|
29
63
|
/**
|
package/dist/tools/index.js
CHANGED
|
@@ -168,7 +168,7 @@ export function registerAllTools(server) {
|
|
|
168
168
|
registerInferenceMarketplaceTools(server);
|
|
169
169
|
// AIBTC News (signal feed, leaderboard, file signals)
|
|
170
170
|
registerNewsTools(server);
|
|
171
|
-
// AIBTC News Legion (news-gov
|
|
171
|
+
// AIBTC News Legion (mainnet aibtc-news-gov — inscribe, propose, vote, conclude)
|
|
172
172
|
registerLegionTools(server);
|
|
173
173
|
// Identity (ERC-8004 on-chain agent identity management)
|
|
174
174
|
registerIdentityTools(server);
|
|
@@ -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
|
*/
|