@aibtc/mcp-server 1.67.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,23 @@
1
1
  /**
2
- * AIBTC News Legion (news-gov-v5) chain reads.
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 (which clock the windows count on),
6
- * and the sBTC token the treasury actually holds all come off chain and are
7
- * cached per process. A doc that drifts from the contract is a doc; a read that
8
- * drifts from the contract is a bug, so there is nothing to drift.
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 { LEGION_GOV_CONTRACT, LEGION_NETWORK, LEGION_TREASURY_CONTRACT, ORDINALS_CONTENT_BASE, ORDINALS_INSCRIPTION_BASE, } from "../config/legion.js";
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
- export function readGov(functionName, functionArgs = []) {
83
- return readLegionContract(LEGION_GOV_CONTRACT, functionName, functionArgs);
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
- export function readTreasury(functionName, functionArgs = []) {
86
- return readLegionContract(LEGION_TREASURY_CONTRACT, functionName, functionArgs);
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
- let paramsCache = null;
89
- let timingModeCache = null;
90
- let tokenCache = null;
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
- if (paramsCache)
94
- return paramsCache;
95
- const raw = (await readGov("get-params"));
96
- paramsCache = {
97
- votingQuorum: num(raw.votingQuorum),
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
- vetoQuorum: num(raw.vetoQuorum),
100
- minParticipants: num(raw.minParticipants),
101
- minWeight: num(raw.minWeight),
102
- minContribution: num(raw.minContribution),
103
- drawBps: num(raw.drawBps),
104
- votingDelay: num(raw.votingDelay),
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
- proposeInterval: num(raw.proposeInterval),
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
- if (timingModeCache)
119
- return timingModeCache;
120
- timingModeCache = (await readGov("get-timing-mode"));
121
- return timingModeCache;
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
- if (tokenCache)
130
- return tokenCache;
131
- const contract = (await readTreasury("get-token"));
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
- tokenCache = { contract, assetName };
138
- return tokenCache;
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
- bond: num(raw.bond),
183
- draw: num(raw.draw),
311
+ lockedWeight: num(raw.lockedWeight),
312
+ payout: num(raw.payout),
184
313
  createdAt: num(raw.createdAt),
185
314
  voteEnd: num(raw.voteEnd),
186
- eligibleSnapshot: num(raw.eligibleSnapshot),
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" | "veto" | "concludable" | "expired" |
207
- * "passed" | "failed"` — the contract's own view of where a piece stands.
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 { support: Boolean(raw.support), weight: num(raw.weight) };
223
- }
224
- export async function getVetoRecord(proposalId, voter) {
225
- const raw = (await readGov("get-veto-record", [
226
- uintCV(proposalId),
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
- draw: num(raw.draw),
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, minWeight) {
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 minWeight (${minWeight}) — call legion_contribute first`);
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 draw would round to zero sats");
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.votingDelay;
268
- const vetoEnd = story.voteEnd + params.vetoWindow;
269
- const concludeDeadline = vetoEnd + params.concludeWindow;
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
- vetoEnd,
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 vetoPct = eligible > 0 ? Math.floor((story.vetoWeight * 100) / eligible) : 0;
298
- const participantsMet = story.voterCount >= params.minParticipants;
299
- const quorumMet = eligible > 0 && participantsMet && quorumPct >= params.votingQuorum;
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 vetoed = eligible > 0 && vetoPct >= params.vetoQuorum;
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.draw : 0,
440
+ payout: story.status === 1 ? story.payout : 0,
321
441
  };
322
442
  }
323
- const concludeDeadline = story.voteEnd + params.vetoWindow + params.concludeWindow;
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 (vetoed) {
334
- return { ...base, settled: false, outcome: "failed", reason: "vetoed", payout: 0 };
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 (story.draw > poolBalance) {
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.draw };
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
- * 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) 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
- * Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
12
- * (`sats`); there is no cross-asset USD normalization. Daily totals are
13
- * persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
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
- * Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
18
- * surfaces the remaining budget + the env var to raise. An LLM cannot set env
19
- * vars mid-session, so it cannot raise its own ceiling.
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
- * Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
22
- * SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
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
- import { promises as fs } from "fs";
25
- import os from "os";
26
- import path from "path";
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
  /**
@@ -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-v5 governance — inscribe, propose, vote, veto, conclude)
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-v5).
2
+ * AIBTC News Legion tools (mainnet `aibtc-news-gov`).
3
3
  *
4
- * Lifecycle: inscribe → reveal → propose → vote → veto → conclude, plus
5
- * contribute (buys weight) and sponsor (does not).
4
+ * Lifecycle: inscribe → reveal → propose → vote → conclude, plus contribute
5
+ * (buys weight) and sponsor (does not). There is no veto in this era: voting no
6
+ * while the window is open is the only way to stop a piece.
7
+ *
8
+ * EVERYTHING HERE MOVES REAL VALUE. The legion is on Stacks mainnet holding
9
+ * real sBTC, and neither `contribute` nor `sponsor` has a withdrawal path, so
10
+ * both meter against the wallet's `sats` spending rail before signing. That
11
+ * metering happens inside `callContract`, which reads the exact sBTC
12
+ * post-condition these calls already sign in DENY mode — so it must NOT be
13
+ * repeated here, or every contribute would bill the rail twice.
6
14
  *
7
15
  * These tools pin their own network: `getLegionAccount()` re-derives the
8
- * unlocked wallet for the legion's chain, so a mainnet-configured server signs
9
- * against testnet. Inscription is the exception — real BTC on whatever chain
10
- * NETWORK names.
16
+ * unlocked wallet for the legion's chain, so a testnet-configured server still
17
+ * signs against the mainnet contracts rather than quietly reading a chain the
18
+ * legion does not live on. Inscription is the exception — it follows the global
19
+ * NETWORK, because ordinals.com indexes Bitcoin mainnet only.
11
20
  *
12
21
  * Fund-moving calls sign in DENY mode with an exact post-condition.
13
22
  */