@aibtc/mcp-server 1.68.0 → 1.70.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.
@@ -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