@aibtc/mcp-server 1.69.0 → 1.70.1

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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Chain reads for the At Stake market and its two side legions.
3
+ *
4
+ * Every read here goes to the chain the contracts live on, not the chain the
5
+ * server is configured for — see the note in `config/at-stake.ts`.
6
+ *
7
+ * Clarity values come back through `cvToJSON`, whose shape is nested and
8
+ * stringly typed. The unwrapping lives here so the tool layer reads as the
9
+ * market's own vocabulary rather than as tuple spelunking.
10
+ */
11
+ import { cvToJSON, deserializeCV, getAddressFromPrivateKey, principalCV, uintCV, } from "@stacks/transactions";
12
+ import { getHiroApi } from "./hiro-api.js";
13
+ import { getAccount } from "./x402.service.js";
14
+ import { AT_STAKE_NETWORK, CLOSE_HEIGHT, LEGION_ERRORS, MARKET_ADDRESS, MARKET_CONTRACT, MARKET_ERRORS, MARKET_STATUS, PROPOSAL_STATUS, STATUS_OPEN, } from "../config/at-stake.js";
15
+ /**
16
+ * The wallet, re-derived for the legion's chain.
17
+ *
18
+ * A testnet-configured server still holds the same private key; only the
19
+ * c32 encoding of the address differs. Re-deriving means a mainnet call is
20
+ * signed by the mainnet form of the same wallet rather than failing, or worse,
21
+ * quoting one address while signing as another.
22
+ */
23
+ export async function getAtStakeAccount() {
24
+ const base = await getAccount();
25
+ if (base.network === AT_STAKE_NETWORK)
26
+ return base;
27
+ return {
28
+ ...base,
29
+ address: getAddressFromPrivateKey(base.privateKey, AT_STAKE_NETWORK),
30
+ network: AT_STAKE_NETWORK,
31
+ };
32
+ }
33
+ /** Call a read-only function on either contract and return its JSON form. */
34
+ export async function readContract(contractId, functionName, args = []) {
35
+ const hiro = getHiroApi(AT_STAKE_NETWORK);
36
+ const result = await hiro.callReadOnlyFunction(contractId, functionName, args, MARKET_ADDRESS);
37
+ if (!result.okay) {
38
+ throw new Error(`${contractId} ${functionName} failed: ${result.cause ?? "unknown error"}`);
39
+ }
40
+ if (!result.result)
41
+ return null;
42
+ const hex = result.result.startsWith("0x")
43
+ ? result.result.slice(2)
44
+ : result.result;
45
+ return cvToJSON(deserializeCV(Buffer.from(hex, "hex")));
46
+ }
47
+ /** Read a market function. */
48
+ export function readMarket(functionName, args = []) {
49
+ return readContract(MARKET_CONTRACT, functionName, args);
50
+ }
51
+ /**
52
+ * Flatten one layer of `cvToJSON`'s `{type, value}` nesting.
53
+ *
54
+ * A Clarity tuple arrives as `{type, value: {field: {type, value}}}`, and an
55
+ * optional wraps that again. Callers want the field, not the envelope.
56
+ */
57
+ function unwrap(cv) {
58
+ if (cv && typeof cv === "object" && "value" in cv) {
59
+ return cv.value;
60
+ }
61
+ return cv;
62
+ }
63
+ /** Read a uint out of a `cvToJSON` node. */
64
+ export function num(cv) {
65
+ const v = unwrap(cv);
66
+ if (v === null || v === undefined)
67
+ return 0;
68
+ return Number(v);
69
+ }
70
+ /** Read a bool out of a `cvToJSON` node. */
71
+ function bool(cv) {
72
+ return unwrap(cv) === true || unwrap(cv) === "true";
73
+ }
74
+ /** Read a string out of a `cvToJSON` node. */
75
+ function str(cv) {
76
+ const v = unwrap(cv);
77
+ return v === null || v === undefined ? "" : String(v);
78
+ }
79
+ /** Fields of a tuple node, or `null` for a `none`. */
80
+ function fields(cv) {
81
+ const v = unwrap(cv);
82
+ if (v === null || v === undefined)
83
+ return null;
84
+ // An optional's payload is itself a {type, value} node.
85
+ if (typeof v === "object" && "value" in v && "type" in v) {
86
+ return fields(v);
87
+ }
88
+ return v;
89
+ }
90
+ export async function getMarket() {
91
+ const f = fields(await readMarket("get-market"));
92
+ if (!f)
93
+ throw new Error("get-market returned nothing.");
94
+ const status = num(f["status"]);
95
+ return {
96
+ title: str(f["title"]),
97
+ opened: bool(f["opened"]),
98
+ status,
99
+ statusLabel: MARKET_STATUS[status] ?? `unknown(${status})`,
100
+ closeHeight: num(f["close-height"]),
101
+ vault: num(f["vault"]),
102
+ idleCirc: num(f["idle-circ"]),
103
+ bondedCirc: num(f["bonded-circ"]),
104
+ createdAt: num(f["created-at"]),
105
+ };
106
+ }
107
+ export async function getPosition(who) {
108
+ const f = fields(await readMarket("get-position", [principal(who)]));
109
+ if (!f)
110
+ return { idle: 0, bonded: 0 };
111
+ return { idle: num(f["idle"]), bonded: num(f["bonded"]) };
112
+ }
113
+ export async function getBid(who, side) {
114
+ const f = fields(await readMarket("get-bid", [principal(who), uint(side.side)]));
115
+ if (!f)
116
+ return null;
117
+ return { amount: num(f["amount"]), escrow: num(f["escrow"]) };
118
+ }
119
+ /** Current burn height — the clock every window in both contracts uses. */
120
+ export async function getBurnHeight() {
121
+ const info = await getHiroApi(AT_STAKE_NETWORK).getCoreApiInfo();
122
+ return info.burn_block_height;
123
+ }
124
+ /**
125
+ * Whether the market still accepts trades.
126
+ *
127
+ * Mirrors the contract's own `assert-tradeable`: open status AND the close
128
+ * height not yet passed. Derived rather than read so a caller can be told
129
+ * which of the two conditions failed.
130
+ */
131
+ export function isTradeable(market, burnHeight) {
132
+ return market.status === STATUS_OPEN && burnHeight <= CLOSE_HEIGHT;
133
+ }
134
+ export async function getLegionParams(side) {
135
+ const f = fields(await readContract(side.legion, "get-params"));
136
+ if (!f)
137
+ throw new Error(`${side.legion} get-params returned nothing.`);
138
+ return {
139
+ minPosition: num(f["minPosition"]),
140
+ payout: num(f["payout"]),
141
+ minVoters: num(f["minVoters"]),
142
+ proposerCooldown: num(f["proposerCooldown"]),
143
+ votingThreshold: num(f["votingThreshold"]),
144
+ voteDelay: num(f["voteDelay"]),
145
+ voteWindow: num(f["voteWindow"]),
146
+ concludeWindow: num(f["concludeWindow"]),
147
+ globalProposeInterval: num(f["globalProposeInterval"]),
148
+ };
149
+ }
150
+ export async function getProposeStatus(side, who) {
151
+ const f = fields(await readContract(side.legion, "propose-status", [principal(who)]));
152
+ if (!f)
153
+ throw new Error(`${side.legion} propose-status returned nothing.`);
154
+ return {
155
+ canPropose: bool(f["canPropose"]),
156
+ eligible: bool(f["eligible"]),
157
+ slotOpen: bool(f["slotOpen"]),
158
+ cooledDown: bool(f["cooledDown"]),
159
+ noLiveProposal: bool(f["noLiveProposal"]),
160
+ potOk: bool(f["potOk"]),
161
+ marketTradeable: bool(f["marketTradeable"]),
162
+ weight: num(f["weight"]),
163
+ vault: num(f["vault"]),
164
+ winsLeft: num(f["winsLeft"]),
165
+ payout: num(f["payout"]),
166
+ votable: num(f["votable"]),
167
+ nextProposeHeight: num(f["nextProposeHeight"]),
168
+ proposerNextHeight: num(f["proposerNextHeight"]),
169
+ };
170
+ }
171
+ export async function getProposal(side, proposalId) {
172
+ const f = fields(await readContract(side.legion, "get-proposal", [uint(proposalId)]));
173
+ if (!f)
174
+ return null;
175
+ const status = num(f["status"]);
176
+ return {
177
+ proposer: str(f["proposer"]),
178
+ payout: num(f["payout"]),
179
+ createdAt: num(f["createdAt"]),
180
+ voteEnd: num(f["voteEnd"]),
181
+ votableAtOpen: num(f["votableAtOpen"]),
182
+ yesWeight: num(f["yesWeight"]),
183
+ noWeight: num(f["noWeight"]),
184
+ voterCount: num(f["voterCount"]),
185
+ yesVoterCount: num(f["yesVoterCount"]),
186
+ status,
187
+ statusLabel: PROPOSAL_STATUS[status] ?? `unknown(${status})`,
188
+ reason: str(f["reason"]),
189
+ paidInShares: bool(f["paidInShares"]),
190
+ };
191
+ }
192
+ export async function getProposalMeta(side, proposalId) {
193
+ const f = fields(await readContract(side.legion, "get-proposal-meta", [uint(proposalId)]));
194
+ if (!f)
195
+ return null;
196
+ return {
197
+ title: str(f["title"]),
198
+ description: str(f["description"]),
199
+ link: str(f["link"]),
200
+ };
201
+ }
202
+ /** The contract's own phase word for a proposal. */
203
+ export async function getPhase(side, proposalId) {
204
+ return str(await readContract(side.legion, "get-phase", [uint(proposalId)]));
205
+ }
206
+ export async function getSettlement(side) {
207
+ const f = fields(await readContract(side.legion, "get-settlement"));
208
+ if (!f)
209
+ throw new Error(`${side.legion} get-settlement returned nothing.`);
210
+ return {
211
+ redeemed: bool(f["redeemed"]),
212
+ redeemedSats: num(f["redeemedSats"]),
213
+ paidSats: num(f["paidSats"]),
214
+ unpaidSats: num(f["unpaidSats"]),
215
+ totalCredits: num(f["totalCredits"]),
216
+ won: bool(f["won"]),
217
+ };
218
+ }
219
+ /**
220
+ * Predict what `conclude` will decide, in the contract's own decision order.
221
+ *
222
+ * The chain decides for real at the block that mines the transaction; this is
223
+ * the tally as of now, so a caller can see that they are about to burn gas on
224
+ * a proposal that fails for want of one more voter.
225
+ */
226
+ export function predictOutcome(proposal, params, proposerStillHolding, vault, totalCredits, tradeable) {
227
+ const cast = proposal.yesWeight + proposal.noWeight;
228
+ if (proposal.yesVoterCount < params.minVoters) {
229
+ return { outcome: "failed", reason: "no-voters", payout: 0 };
230
+ }
231
+ if (cast === 0 || (proposal.yesWeight * 100) / cast < params.votingThreshold) {
232
+ return { outcome: "failed", reason: "voted-down", payout: 0 };
233
+ }
234
+ if (!proposerStillHolding) {
235
+ return { outcome: "failed", reason: "not-holding", payout: 0 };
236
+ }
237
+ if (vault < totalCredits + params.payout) {
238
+ return { outcome: "failed", reason: "pot-short", payout: 0 };
239
+ }
240
+ return {
241
+ outcome: "passed",
242
+ reason: tradeable ? "paid-shares" : "credited",
243
+ payout: params.payout,
244
+ };
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // Clarity argument helpers and error translation
248
+ // ---------------------------------------------------------------------------
249
+ function principal(address) {
250
+ return principalCV(address);
251
+ }
252
+ function uint(value) {
253
+ return uintCV(value);
254
+ }
255
+ /**
256
+ * Turn a contract abort into the sentence behind its code.
257
+ *
258
+ * The two contracts use disjoint ranges — the market is 100-313, the legions
259
+ * 401-450 — so one lookup across both tables cannot collide.
260
+ */
261
+ export function explainAbort(error) {
262
+ const message = error instanceof Error ? error.message : String(error);
263
+ const match = message.match(/\bu(\d+)\b/);
264
+ if (!match)
265
+ return error instanceof Error ? error : new Error(message);
266
+ const code = Number(match[1]);
267
+ const explanation = MARKET_ERRORS[code] ?? LEGION_ERRORS[code];
268
+ if (!explanation)
269
+ return error instanceof Error ? error : new Error(message);
270
+ return new Error(`${explanation} (u${code})\n\nRaw: ${message}`);
271
+ }
272
+ //# sourceMappingURL=at-stake.service.js.map
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
+ import { withSharedStateLock } from "./state-lock.js";
4
5
  import { MAINNET_CONTRACTS, TESTNET_CONTRACTS } from "../config/contracts.js";
5
6
  /** Asset ids this rail can meter. Any other FT has no ledger to bill. */
6
7
  const SBTC_ASSETS = new Set([
@@ -195,27 +196,50 @@ class SpendLimiter {
195
196
  return;
196
197
  const amt = Number(amount);
197
198
  this.getSession(addr)[unit] += amt;
198
- // Serialize read-modify-write to avoid lost updates under concurrency.
199
- this.writeLock = this.writeLock.then(async () => {
200
- const state = await this.readState();
201
- const today = todayKey();
202
- // Prune stale days so the file does not grow unbounded.
203
- for (const a of Object.keys(state)) {
204
- for (const day of Object.keys(state[a])) {
205
- if (day !== today)
206
- delete state[a][day];
199
+ // Two locks, for two different races.
200
+ //
201
+ // The promise chain serializes concurrent record() calls INSIDE this
202
+ // process. The file lease excludes the OTHER process writing this file:
203
+ // the skills engine's direct x402 path shares spend-state.json so one
204
+ // wallet has one daily cap. Without the lease, its writes and ours
205
+ // overwrite each other and the day total under-counts, which is the
206
+ // direction that permits spending past the cap.
207
+ //
208
+ // Note the prune below walks EVERY address, so an unlocked concurrent
209
+ // write can lose another wallet's day too, not just this one's.
210
+ const write = this.writeLock.then(async () => {
211
+ const run = await withSharedStateLock(this.stateFile, async () => {
212
+ const state = await this.readState();
213
+ const today = todayKey();
214
+ // Prune stale days so the file does not grow unbounded.
215
+ for (const a of Object.keys(state)) {
216
+ for (const day of Object.keys(state[a])) {
217
+ if (day !== today)
218
+ delete state[a][day];
219
+ }
220
+ if (Object.keys(state[a]).length === 0)
221
+ delete state[a];
207
222
  }
208
- if (Object.keys(state[a]).length === 0)
209
- delete state[a];
223
+ if (!state[addr])
224
+ state[addr] = {};
225
+ if (!state[addr][today])
226
+ state[addr][today] = { ustx: 0, sats: 0 };
227
+ state[addr][today][unit] += amt;
228
+ await this.writeState(state);
229
+ });
230
+ // The spend already happened on chain by the time record() is called, so
231
+ // an unavailable lease must not throw: that would turn a broadcast
232
+ // transaction into an unrecorded one. Write anyway and say so, because a
233
+ // silent under-count is the failure this whole lease exists to prevent.
234
+ if (!run.held) {
235
+ console.error(`[spend-limit] Recorded ${amt} ${unit} for ${addr} WITHOUT the shared lock: ` +
236
+ `${run.unavailable} The daily total may under-count if another tool wrote ` +
237
+ `concurrently.`);
210
238
  }
211
- if (!state[addr])
212
- state[addr] = {};
213
- if (!state[addr][today])
214
- state[addr][today] = { ustx: 0, sats: 0 };
215
- state[addr][today][unit] += amt;
216
- await this.writeState(state);
217
239
  });
218
- await this.writeLock;
240
+ // A failed write must not poison the chain for every later record().
241
+ this.writeLock = write.catch(() => { });
242
+ await write;
219
243
  }
220
244
  /** Remaining session/day budget for status reporting. */
221
245
  async status(addr) {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cross-process lock for the shared `~/.aibtc` state files.
3
+ *
4
+ * `spend-state.json` has TWO writers: this server, and the aibtc skills engine's
5
+ * direct x402 payment path (aibtcdev/skills#423), which reads and writes the
6
+ * same file in the same shape so one wallet has one daily cap no matter which
7
+ * tool spends from it. Sharing the file is the point; sharing it without a lock
8
+ * is a lost update:
9
+ *
10
+ * 1. this server reads the day total and sees 100
11
+ * 2. the other tool takes its lock, reads 100, writes 150, releases
12
+ * 3. this server writes 130 from the copy it read in step 1
13
+ *
14
+ * The other tool's 50 is gone and the daily cap now UNDER-counts, which is the
15
+ * direction that lets a wallet spend past the cap it was supposed to share. An
16
+ * in-process promise chain cannot fix this: the other writer is another process.
17
+ *
18
+ * THE PROTOCOL HERE IS NOT OURS TO CHANGE UNILATERALLY. It is byte-compatible
19
+ * with `x402-guards.ts` in the skills engine so the two tools' leases are
20
+ * interchangeable — same lock directory name, same `owner` file format, same
21
+ * mtime heartbeat, same staleness window. A lock only excludes writers that
22
+ * agree on where it lives, so "x402-guards.lock" is deliberately kept even
23
+ * though nothing here is x402: renaming it on this side would silently return
24
+ * both tools to the race above while each believed it held a lock.
25
+ *
26
+ * A STALE LEASE IS NEVER RECLAIMED AUTOMATICALLY. Deleting a lock this process
27
+ * did not create needs an atomic "remove only if unchanged since I looked",
28
+ * which the filesystem does not offer; guessing wrong there is precisely the
29
+ * concurrent write the lock exists to prevent. A dead holder is reported with
30
+ * its pid and left for an operator, who can see whether it is really dead.
31
+ */
32
+ /** Mutable so tests do not have to wait real seconds. */
33
+ declare const timing: {
34
+ maxWaitMs: number;
35
+ staleMs: number;
36
+ heartbeatMs: number;
37
+ retryMs: number;
38
+ };
39
+ /** Test seam. Not part of the public contract. */
40
+ export declare const _stateLockTesting: {
41
+ set(overrides: Partial<typeof timing>): void;
42
+ reset(): void;
43
+ lockDirFor: typeof lockDirFor;
44
+ };
45
+ /**
46
+ * The lease guarding a state file, derived from its directory so a test that
47
+ * redirects the state file redirects the lock with it.
48
+ */
49
+ declare function lockDirFor(stateFile: string): string;
50
+ export interface LockedRun<T> {
51
+ /** False when the body ran WITHOUT the lease. */
52
+ held: boolean;
53
+ /** Set when `held` is false: why the lease could not be taken. */
54
+ unavailable?: string;
55
+ value: T;
56
+ }
57
+ /**
58
+ * Run `fn` under the shared lease for `stateFile`.
59
+ *
60
+ * `fn` RUNS EITHER WAY. This guards a read-modify-write that records something
61
+ * which has ALREADY HAPPENED on chain, so refusing to run it would turn a
62
+ * broadcast transaction into an unrecorded one — strictly worse than the race
63
+ * it is trying to avoid. The caller is told which case it got and is expected
64
+ * to say so loudly rather than let an under-count pass silently.
65
+ *
66
+ * That is the deliberate difference from the skills engine, which takes this
67
+ * same lease and DOES refuse: there the lock guards a payment not yet signed,
68
+ * so refusing costs nothing but a retry. Authorising a future spend and
69
+ * recording a past one fail in opposite directions.
70
+ */
71
+ export declare function withSharedStateLock<T>(stateFile: string, fn: () => Promise<T>): Promise<LockedRun<T>>;
72
+ export {};
73
+ //# sourceMappingURL=state-lock.d.ts.map
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Cross-process lock for the shared `~/.aibtc` state files.
3
+ *
4
+ * `spend-state.json` has TWO writers: this server, and the aibtc skills engine's
5
+ * direct x402 payment path (aibtcdev/skills#423), which reads and writes the
6
+ * same file in the same shape so one wallet has one daily cap no matter which
7
+ * tool spends from it. Sharing the file is the point; sharing it without a lock
8
+ * is a lost update:
9
+ *
10
+ * 1. this server reads the day total and sees 100
11
+ * 2. the other tool takes its lock, reads 100, writes 150, releases
12
+ * 3. this server writes 130 from the copy it read in step 1
13
+ *
14
+ * The other tool's 50 is gone and the daily cap now UNDER-counts, which is the
15
+ * direction that lets a wallet spend past the cap it was supposed to share. An
16
+ * in-process promise chain cannot fix this: the other writer is another process.
17
+ *
18
+ * THE PROTOCOL HERE IS NOT OURS TO CHANGE UNILATERALLY. It is byte-compatible
19
+ * with `x402-guards.ts` in the skills engine so the two tools' leases are
20
+ * interchangeable — same lock directory name, same `owner` file format, same
21
+ * mtime heartbeat, same staleness window. A lock only excludes writers that
22
+ * agree on where it lives, so "x402-guards.lock" is deliberately kept even
23
+ * though nothing here is x402: renaming it on this side would silently return
24
+ * both tools to the race above while each believed it held a lock.
25
+ *
26
+ * A STALE LEASE IS NEVER RECLAIMED AUTOMATICALLY. Deleting a lock this process
27
+ * did not create needs an atomic "remove only if unchanged since I looked",
28
+ * which the filesystem does not offer; guessing wrong there is precisely the
29
+ * concurrent write the lock exists to prevent. A dead holder is reported with
30
+ * its pid and left for an operator, who can see whether it is really dead.
31
+ */
32
+ import os from "os";
33
+ import path from "path";
34
+ import { mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync, } from "fs";
35
+ import { randomBytes } from "crypto";
36
+ /** No heartbeat for this long means the holder is probably dead. */
37
+ const LOCK_STALE_MS = 60_000;
38
+ /** A live holder refreshes its lease this often. */
39
+ const LOCK_HEARTBEAT_MS = 10_000;
40
+ /** Gap between acquisition attempts. */
41
+ const LOCK_RETRY_MS = 250;
42
+ /** Mutable so tests do not have to wait real seconds. */
43
+ const timing = {
44
+ maxWaitMs: 15_000,
45
+ staleMs: LOCK_STALE_MS,
46
+ heartbeatMs: LOCK_HEARTBEAT_MS,
47
+ retryMs: LOCK_RETRY_MS,
48
+ };
49
+ /** Test seam. Not part of the public contract. */
50
+ export const _stateLockTesting = {
51
+ set(overrides) {
52
+ Object.assign(timing, overrides);
53
+ },
54
+ reset() {
55
+ timing.maxWaitMs = 15_000;
56
+ timing.staleMs = LOCK_STALE_MS;
57
+ timing.heartbeatMs = LOCK_HEARTBEAT_MS;
58
+ timing.retryMs = LOCK_RETRY_MS;
59
+ },
60
+ lockDirFor,
61
+ };
62
+ /**
63
+ * The lease guarding a state file, derived from its directory so a test that
64
+ * redirects the state file redirects the lock with it.
65
+ */
66
+ function lockDirFor(stateFile) {
67
+ return path.join(path.dirname(stateFile), "x402-guards.lock");
68
+ }
69
+ function tryLock(dir, token) {
70
+ try {
71
+ mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 });
72
+ // mkdir of an existing directory throws: that is the atomic test-and-set.
73
+ mkdirSync(dir);
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ try {
79
+ writeFileSync(path.join(dir, "owner"), `${process.pid} ${token}`, {
80
+ mode: 0o600,
81
+ });
82
+ return true;
83
+ }
84
+ catch {
85
+ // We created the directory but could not claim it. Remove it rather than
86
+ // strand an ownerless lease that every writer would wait on.
87
+ rmSync(dir, { recursive: true, force: true });
88
+ return false;
89
+ }
90
+ }
91
+ function lockIsStale(dir) {
92
+ try {
93
+ return Date.now() - statSync(dir).mtimeMs > timing.staleMs;
94
+ }
95
+ catch {
96
+ // Gone between the failed mkdir and this stat: not stale, just released.
97
+ return false;
98
+ }
99
+ }
100
+ /** Touch the lease so a slow-but-live holder is never mistaken for a dead one. */
101
+ function heartbeat(dir) {
102
+ try {
103
+ const now = new Date();
104
+ utimesSync(dir, now, now);
105
+ }
106
+ catch {
107
+ // If the directory is gone we no longer hold it; the owner check on
108
+ // release handles that case.
109
+ }
110
+ }
111
+ function ownedBy(dir, token) {
112
+ try {
113
+ return readFileSync(path.join(dir, "owner"), "utf8").endsWith(` ${token}`);
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ function describeHolder(dir) {
120
+ try {
121
+ const [pid] = readFileSync(path.join(dir, "owner"), "utf8").split(" ");
122
+ let alive = true;
123
+ try {
124
+ process.kill(Number(pid), 0);
125
+ }
126
+ catch {
127
+ alive = false;
128
+ }
129
+ return `owner pid ${pid}${alive ? "" : " (not running)"}`;
130
+ }
131
+ catch {
132
+ return "owner unknown";
133
+ }
134
+ }
135
+ /**
136
+ * Release a lease this process created. The owner check guards the one way the
137
+ * directory could be someone else's by now: an operator removed our lock by
138
+ * hand and another writer took the path meanwhile.
139
+ */
140
+ function release(dir, token) {
141
+ try {
142
+ if (ownedBy(dir, token))
143
+ rmSync(dir, { recursive: true, force: true });
144
+ }
145
+ catch {
146
+ // Already gone; nothing to release.
147
+ }
148
+ }
149
+ /** Leases held right now, so a signal can drop them instead of stranding them. */
150
+ const heldLocks = new Map();
151
+ let exitHooksInstalled = false;
152
+ function installExitHooks() {
153
+ if (exitHooksInstalled)
154
+ return;
155
+ exitHooksInstalled = true;
156
+ const releaseAll = () => {
157
+ for (const [dir, token] of heldLocks)
158
+ release(dir, token);
159
+ heldLocks.clear();
160
+ };
161
+ process.once("exit", releaseAll);
162
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
163
+ process.once(signal, () => {
164
+ releaseAll();
165
+ // Leave shutdown to any other handler (e.g. yield-hunter's graceful
166
+ // stop); only exit ourselves when we displaced the default behaviour.
167
+ if (process.listenerCount(signal) === 0) {
168
+ process.exit(128 + os.constants.signals[signal]);
169
+ }
170
+ });
171
+ }
172
+ }
173
+ async function acquire(dir, token) {
174
+ const deadline = Date.now() + timing.maxWaitMs;
175
+ for (;;) {
176
+ if (tryLock(dir, token))
177
+ return null;
178
+ if (lockIsStale(dir)) {
179
+ return {
180
+ reason: `the lock at ${dir} has had no heartbeat for over ${Math.round(timing.staleMs / 1000)}s ` +
181
+ `(${describeHolder(dir)}). If that process is dead, remove the directory; do not remove ` +
182
+ `it while a write may still be in progress.`,
183
+ };
184
+ }
185
+ if (Date.now() >= deadline) {
186
+ return {
187
+ reason: `another writer held the lock at ${dir} for the whole ` +
188
+ `${Math.round(timing.maxWaitMs / 1000)}s wait (${describeHolder(dir)}).`,
189
+ };
190
+ }
191
+ await new Promise((r) => setTimeout(r, timing.retryMs));
192
+ }
193
+ }
194
+ /**
195
+ * Run `fn` under the shared lease for `stateFile`.
196
+ *
197
+ * `fn` RUNS EITHER WAY. This guards a read-modify-write that records something
198
+ * which has ALREADY HAPPENED on chain, so refusing to run it would turn a
199
+ * broadcast transaction into an unrecorded one — strictly worse than the race
200
+ * it is trying to avoid. The caller is told which case it got and is expected
201
+ * to say so loudly rather than let an under-count pass silently.
202
+ *
203
+ * That is the deliberate difference from the skills engine, which takes this
204
+ * same lease and DOES refuse: there the lock guards a payment not yet signed,
205
+ * so refusing costs nothing but a retry. Authorising a future spend and
206
+ * recording a past one fail in opposite directions.
207
+ */
208
+ export async function withSharedStateLock(stateFile, fn) {
209
+ const dir = lockDirFor(stateFile);
210
+ const token = randomBytes(12).toString("hex");
211
+ const unavailable = await acquire(dir, token);
212
+ if (unavailable) {
213
+ return { held: false, unavailable: unavailable.reason, value: await fn() };
214
+ }
215
+ installExitHooks();
216
+ heldLocks.set(dir, token);
217
+ const beat = setInterval(() => heartbeat(dir), timing.heartbeatMs);
218
+ // Never keep the process alive for a heartbeat.
219
+ beat.unref?.();
220
+ try {
221
+ return { held: true, value: await fn() };
222
+ }
223
+ finally {
224
+ clearInterval(beat);
225
+ heldLocks.delete(dir);
226
+ release(dir, token);
227
+ }
228
+ }
229
+ //# sourceMappingURL=state-lock.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * At Stake legion tools — the two side DAOs at aibtc.com/legions.
3
+ *
4
+ * Each side of the El Salvador market has a legion, and VOTING WEIGHT IS THE
5
+ * SHARE BALANCE, read live out of the market at the moment of every call. So a
6
+ * legion's electorate is exactly the set of people exposed to the claim it
7
+ * argues: the yes legion is bonded holders, the no legion is idle holders. You
8
+ * join by minting shares, not by registering.
9
+ *
10
+ * Lifecycle: propose → (2-block delay) → vote for 30 blocks → conclude inside a
11
+ * 12-block window. A proposal nobody concludes in that window EXPIRES and pays
12
+ * nobody, permanently. There is no veto: voting no while the window is open is
13
+ * the only way to stop a proposal.
14
+ *
15
+ * A passing proposal pays 3,000 shares of its own side out of the legion vault
16
+ * while the market still trades. Once the market resolves the vault can no
17
+ * longer move shares, so a pass becomes an sBTC CREDIT instead, claimable after
18
+ * someone calls `atstake_legion_redeem_vault`.
19
+ *
20
+ * Weight is liquid and cannot be locked, so `conclude` re-reads the proposer's
21
+ * balance: buy in, propose, sell, get paid does not work — it settles
22
+ * "not-holding" and pays nothing.
23
+ *
24
+ * NOT THE SAME TOOLS AS `legion_*`. Those govern `aibtc-news-gov`, where weight
25
+ * is bought with sBTC contributions and a proposal names a Bitcoin ordinal.
26
+ * These govern the El Salvador market's two sides. Same word, different
27
+ * contracts, different way of getting a vote.
28
+ */
29
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
+ export declare function registerAtStakeLegionTools(server: McpServer): void;
31
+ //# sourceMappingURL=at-stake-legion.tools.d.ts.map