@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.
- package/dist/config/at-stake.d.ts +120 -0
- package/dist/config/at-stake.js +183 -0
- package/dist/services/at-stake.service.d.ts +144 -0
- package/dist/services/at-stake.service.js +272 -0
- package/dist/services/spend-limiter.d.ts +53 -0
- package/dist/services/spend-limiter.js +97 -39
- package/dist/services/state-lock.d.ts +73 -0
- package/dist/services/state-lock.js +229 -0
- package/dist/tools/at-stake-legion.tools.d.ts +31 -0
- package/dist/tools/at-stake-legion.tools.js +776 -0
- package/dist/tools/at-stake.tools.d.ts +29 -0
- package/dist/tools/at-stake.tools.js +684 -0
- package/dist/tools/competition.tools.d.ts +12 -11
- package/dist/tools/competition.tools.js +3 -115
- package/dist/tools/index.js +6 -3
- package/dist/tools/legion.tools.d.ts +4 -3
- package/dist/tools/legion.tools.js +9 -12
- package/dist/tools/ordinals.tools.js +28 -2
- package/dist/transactions/builder.js +14 -1
- package/package.json +1 -1
- package/skill/SKILL.md +37 -1
- package/skill/references/at-stake.md +214 -0
|
@@ -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
|