@aibtc/mcp-server 1.74.0 → 1.76.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/README.md +2 -2
- package/dist/services/btc-spend.d.ts +4 -3
- package/dist/services/btc-spend.js +14 -6
- package/dist/services/mempool-api.d.ts +4 -0
- package/dist/services/mempool-api.js +15 -1
- package/dist/services/spend-limiter.d.ts +49 -5
- package/dist/services/spend-limiter.js +132 -68
- package/dist/services/state-lock.d.ts +22 -21
- package/dist/services/state-lock.js +106 -24
- package/dist/services/x402.service.d.ts +8 -0
- package/dist/services/x402.service.js +63 -28
- package/dist/tools/endpoint.tools.js +2 -3
- package/dist/tools/inbox-x402.tools.d.ts +7 -5
- package/dist/tools/inbox-x402.tools.js +60 -25
- package/dist/tools/inbox.tools.js +7 -11
- package/dist/tools/index.js +1 -1
- package/dist/tools/lightning.tools.js +4 -2
- package/dist/tools/profiles.d.ts +2 -2
- package/dist/tools/profiles.js +2 -2
- package/dist/tools/psbt.tools.js +1 -2
- package/dist/tools/sbtc.tools.js +1 -1
- package/dist/tools/yield-dashboard.tools.js +73 -107
- package/dist/transactions/builder.js +51 -34
- package/dist/transactions/sponsor-builder.js +59 -40
- package/package.json +1 -1
- package/skill/SKILL.md +4 -3
- package/skill/references/x402-inbox.md +14 -9
package/README.md
CHANGED
|
@@ -141,8 +141,8 @@ npx @aibtc/mcp-server@latest --install --profile full # writes AIBTC_TOOLS=all
|
|
|
141
141
|
| `identity` | ERC-8004 identity and reputation, message signing |
|
|
142
142
|
| `social` | Nostr, AIBTC inbox |
|
|
143
143
|
| `earn` | Bounty board (create, accept, pay, my views) |
|
|
144
|
-
| `legion` | AIBTC News Legion |
|
|
145
|
-
| `markets` | Stacks prediction market, At Stake |
|
|
144
|
+
| `legion` | AIBTC News Legion, At Stake side legions (El Salvador) |
|
|
145
|
+
| `markets` | Stacks prediction market, At Stake market |
|
|
146
146
|
| `dev` | Scaffolding, OpenRouter, settings, inference marketplace, arXiv |
|
|
147
147
|
|
|
148
148
|
The server's instructions list the groups that are off, so the agent can tell you which one to enable. The OpenRouter `bridge` starts from the full set and narrows it with its own `--read-only`/`--allow`/`--block` flags.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* builder produced it.
|
|
9
9
|
*/
|
|
10
10
|
import * as btc from "@scure/btc-signer";
|
|
11
|
-
import type
|
|
11
|
+
import { type MempoolApi } from "./mempool-api.js";
|
|
12
12
|
import type { Network } from "../config/networks.js";
|
|
13
13
|
/** The wallet a Bitcoin spend is billed to. */
|
|
14
14
|
export interface BtcSpender {
|
|
@@ -32,8 +32,9 @@ export declare function btcTxOutflowSats(api: MempoolApi, txHex: string, spender
|
|
|
32
32
|
export declare function psbtOutflowSats(tx: btc.Transaction, signedInputs: number[], spender: BtcSpender, network: Network): bigint;
|
|
33
33
|
/**
|
|
34
34
|
* Broadcast a signed transaction that spends from this wallet, metered by the
|
|
35
|
-
* spending limit:
|
|
36
|
-
*
|
|
35
|
+
* spending limit: booked before broadcast (refused when it would exceed the
|
|
36
|
+
* cap), given back only when the node rejects it. A network error leaves it
|
|
37
|
+
* unknown whether the transaction was relayed, so the booking stands.
|
|
37
38
|
*/
|
|
38
39
|
export declare function meteredBtcBroadcast(api: MempoolApi, txHex: string, spender: BtcSpender, network: Network): Promise<string>;
|
|
39
40
|
//# sourceMappingURL=btc-spend.d.ts.map
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as btc from "@scure/btc-signer";
|
|
11
11
|
import { hex } from "@scure/base";
|
|
12
12
|
import { getBtcNetwork } from "../transactions/bitcoin-builder.js";
|
|
13
|
+
import { BroadcastRejectedError } from "./mempool-api.js";
|
|
13
14
|
import { getSpendLimiter } from "./spend-limiter.js";
|
|
14
15
|
function ownAddresses(spender) {
|
|
15
16
|
return [spender.btcAddress, spender.taprootAddress].filter((a) => !!a);
|
|
@@ -86,14 +87,21 @@ export function psbtOutflowSats(tx, signedInputs, spender, network) {
|
|
|
86
87
|
}
|
|
87
88
|
/**
|
|
88
89
|
* Broadcast a signed transaction that spends from this wallet, metered by the
|
|
89
|
-
* spending limit:
|
|
90
|
-
*
|
|
90
|
+
* spending limit: booked before broadcast (refused when it would exceed the
|
|
91
|
+
* cap), given back only when the node rejects it. A network error leaves it
|
|
92
|
+
* unknown whether the transaction was relayed, so the booking stands.
|
|
91
93
|
*/
|
|
92
94
|
export async function meteredBtcBroadcast(api, txHex, spender, network) {
|
|
93
95
|
const amount = await btcTxOutflowSats(api, txHex, spender, network);
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const limiter = getSpendLimiter();
|
|
97
|
+
const reservation = await limiter.reserve([{ unit: "sats", amount }], spender.address);
|
|
98
|
+
try {
|
|
99
|
+
return await api.broadcastTransaction(txHex);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof BroadcastRejectedError)
|
|
103
|
+
await limiter.release(reservation);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
98
106
|
}
|
|
99
107
|
//# sourceMappingURL=btc-spend.js.map
|
|
@@ -11,6 +11,10 @@ import type { Network } from "../config/networks.js";
|
|
|
11
11
|
/**
|
|
12
12
|
* UTXO (Unspent Transaction Output) from mempool.space API
|
|
13
13
|
*/
|
|
14
|
+
/** mempool.space answered the broadcast with a 4xx: the transaction was refused, not relayed. */
|
|
15
|
+
export declare class BroadcastRejectedError extends Error {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
14
18
|
export interface UTXO {
|
|
15
19
|
/**
|
|
16
20
|
* Transaction ID containing this UTXO
|
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Documentation: https://mempool.space/docs/api
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* UTXO (Unspent Transaction Output) from mempool.space API
|
|
12
|
+
*/
|
|
13
|
+
/** mempool.space answered the broadcast with a 4xx: the transaction was refused, not relayed. */
|
|
14
|
+
export class BroadcastRejectedError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "BroadcastRejectedError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
/**
|
|
11
21
|
* Get the mempool.space API base URL for a network
|
|
12
22
|
*/
|
|
@@ -157,7 +167,11 @@ export class MempoolApi {
|
|
|
157
167
|
});
|
|
158
168
|
if (!response.ok) {
|
|
159
169
|
const errorText = await response.text().catch(() => "Unknown error");
|
|
160
|
-
|
|
170
|
+
const message = `Failed to broadcast transaction: ${response.status} ${response.statusText} - ${errorText}`;
|
|
171
|
+
// A 4xx is the node refusing the transaction: it was not relayed.
|
|
172
|
+
if (response.status < 500)
|
|
173
|
+
throw new BroadcastRejectedError(message);
|
|
174
|
+
throw new Error(message);
|
|
161
175
|
}
|
|
162
176
|
// Response is the txid as plain text
|
|
163
177
|
const txid = await response.text();
|
|
@@ -91,6 +91,15 @@ export declare class SpendLimitError extends Error {
|
|
|
91
91
|
readonly scope: "session" | "day";
|
|
92
92
|
constructor(message: string, unit: SpendUnit, attempted: number, remaining: number, scope: "session" | "day");
|
|
93
93
|
}
|
|
94
|
+
/** What `reserve()` booked, for `release()` to give back. */
|
|
95
|
+
export interface SpendReservation {
|
|
96
|
+
addr: string;
|
|
97
|
+
day: string;
|
|
98
|
+
spends: Array<{
|
|
99
|
+
unit: SpendUnit;
|
|
100
|
+
amount: number;
|
|
101
|
+
}>;
|
|
102
|
+
}
|
|
94
103
|
declare class SpendLimiter {
|
|
95
104
|
private static instance;
|
|
96
105
|
private stateFile;
|
|
@@ -105,12 +114,47 @@ declare class SpendLimiter {
|
|
|
105
114
|
/** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
|
|
106
115
|
resetSession(addr: string): void;
|
|
107
116
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
117
|
+
* Book `spends` against the session and day ledgers, or throw
|
|
118
|
+
* SpendLimitError if any of them would push a total over its cap. Call this
|
|
119
|
+
* BEFORE signing, and `release()` the result if the spend then fails before
|
|
120
|
+
* it leaves this process.
|
|
121
|
+
*
|
|
122
|
+
* The cap check and the booking are one step under the shared file lease.
|
|
123
|
+
* A separate check and record let two processes both pass the check against
|
|
124
|
+
* the same day total and then both spend (#683). Booked here, a second
|
|
125
|
+
* process sees this spend in the total before it signs. A process that dies
|
|
126
|
+
* between reserve and broadcast leaves the spend booked: over-counting is
|
|
127
|
+
* the safe direction, and the skills engine does the same.
|
|
128
|
+
*
|
|
129
|
+
* All units are checked before any is booked, so a contract call moving STX
|
|
130
|
+
* and sBTC is booked whole or not at all.
|
|
131
|
+
*/
|
|
132
|
+
reserve(spends: Array<{
|
|
133
|
+
unit: SpendUnit;
|
|
134
|
+
amount: bigint;
|
|
135
|
+
}>, addr: string): Promise<SpendReservation>;
|
|
136
|
+
/**
|
|
137
|
+
* Give back a reservation whose spend never left this process (signing or
|
|
138
|
+
* broadcast failed, or a fallback path books its own). Without the lease
|
|
139
|
+
* the day ledger is left as is: an unlocked write could drop another
|
|
140
|
+
* process's booking, while skipping only over-counts.
|
|
141
|
+
*
|
|
142
|
+
* Idempotent: a reservation is given back at most once, so a caller that
|
|
143
|
+
* releases before a fallback and again when that fallback throws does not
|
|
144
|
+
* subtract twice.
|
|
145
|
+
*
|
|
146
|
+
* Never throws. Callers release on their way to rethrowing the real failure
|
|
147
|
+
* or into a fallback; a ledger write error here must not replace either.
|
|
148
|
+
* Failing to give budget back only over-counts.
|
|
149
|
+
*/
|
|
150
|
+
release(reservation: SpendReservation): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Serialize ledger updates inside this process. The file lease excludes the
|
|
153
|
+
* OTHER process (the skills engine's direct x402 path shares
|
|
154
|
+
* spend-state.json so one wallet has one daily cap); this chain keeps two
|
|
155
|
+
* calls in this process from interleaving their read-modify-writes.
|
|
110
156
|
*/
|
|
111
|
-
|
|
112
|
-
/** Record a completed spend against both the session and day ledgers. */
|
|
113
|
-
record(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
|
|
157
|
+
private serialized;
|
|
114
158
|
/** Remaining session/day budget for status reporting. */
|
|
115
159
|
status(addr: string): Promise<{
|
|
116
160
|
enabled: boolean;
|
|
@@ -182,6 +182,21 @@ export class SpendLimitError extends Error {
|
|
|
182
182
|
this.name = "SpendLimitError";
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Drop every day but `today`, for every wallet, so the file does not grow
|
|
187
|
+
* unbounded. Runs only under the lease: it walks EVERY address, so an
|
|
188
|
+
* unlocked write could lose another wallet's day too.
|
|
189
|
+
*/
|
|
190
|
+
function pruneOtherDays(state, today) {
|
|
191
|
+
for (const a of Object.keys(state)) {
|
|
192
|
+
for (const day of Object.keys(state[a])) {
|
|
193
|
+
if (day !== today)
|
|
194
|
+
delete state[a][day];
|
|
195
|
+
}
|
|
196
|
+
if (Object.keys(state[a]).length === 0)
|
|
197
|
+
delete state[a];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
185
200
|
class SpendLimiter {
|
|
186
201
|
static instance;
|
|
187
202
|
stateFile = DEFAULT_STATE_FILE;
|
|
@@ -226,80 +241,129 @@ class SpendLimiter {
|
|
|
226
241
|
this.session.delete(addr);
|
|
227
242
|
}
|
|
228
243
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
244
|
+
* Book `spends` against the session and day ledgers, or throw
|
|
245
|
+
* SpendLimitError if any of them would push a total over its cap. Call this
|
|
246
|
+
* BEFORE signing, and `release()` the result if the spend then fails before
|
|
247
|
+
* it leaves this process.
|
|
248
|
+
*
|
|
249
|
+
* The cap check and the booking are one step under the shared file lease.
|
|
250
|
+
* A separate check and record let two processes both pass the check against
|
|
251
|
+
* the same day total and then both spend (#683). Booked here, a second
|
|
252
|
+
* process sees this spend in the total before it signs. A process that dies
|
|
253
|
+
* between reserve and broadcast leaves the spend booked: over-counting is
|
|
254
|
+
* the safe direction, and the skills engine does the same.
|
|
255
|
+
*
|
|
256
|
+
* All units are checked before any is booked, so a contract call moving STX
|
|
257
|
+
* and sBTC is booked whole or not at all.
|
|
231
258
|
*/
|
|
232
|
-
async
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
259
|
+
async reserve(spends, addr) {
|
|
260
|
+
const booked = spends
|
|
261
|
+
.filter((s) => s.amount > 0n)
|
|
262
|
+
.map((s) => ({ unit: s.unit, amount: Number(s.amount) }));
|
|
263
|
+
if (!isEnabled() || booked.length === 0)
|
|
264
|
+
return { addr, day: todayKey(), spends: [] };
|
|
265
|
+
const totals = { ustx: 0, sats: 0 };
|
|
266
|
+
for (const { unit, amount } of booked)
|
|
267
|
+
totals[unit] += amount;
|
|
268
|
+
const run = await this.serialized(() => withSharedStateLock(this.stateFile, async () => {
|
|
269
|
+
// Read the day under the lease: waiting for it can cross UTC midnight,
|
|
270
|
+
// and pruning against a stale day would delete the new one.
|
|
271
|
+
const day = todayKey();
|
|
272
|
+
const session = this.getSession(addr);
|
|
273
|
+
const state = await this.readState();
|
|
274
|
+
for (const unit of ["ustx", "sats"]) {
|
|
275
|
+
const amt = totals[unit];
|
|
276
|
+
if (amt === 0)
|
|
277
|
+
continue;
|
|
278
|
+
const { daily, session: sessionCap } = capsFor(unit);
|
|
279
|
+
if (session[unit] + amt > sessionCap) {
|
|
280
|
+
const remaining = Math.max(0, sessionCap - session[unit]);
|
|
281
|
+
throw new SpendLimitError(this.message(unit, amt, remaining, "session", sessionCap), unit, amt, remaining, "session");
|
|
282
|
+
}
|
|
283
|
+
const daySpent = state[addr]?.[day]?.[unit] ?? 0;
|
|
284
|
+
if (daySpent + amt > daily) {
|
|
285
|
+
const remaining = Math.max(0, daily - daySpent);
|
|
286
|
+
throw new SpendLimitError(this.message(unit, amt, remaining, "day", daily), unit, amt, remaining, "day");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
pruneOtherDays(state, day);
|
|
290
|
+
if (!state[addr])
|
|
291
|
+
state[addr] = {};
|
|
292
|
+
if (!state[addr][day])
|
|
293
|
+
state[addr][day] = { ustx: 0, sats: 0 };
|
|
294
|
+
for (const unit of ["ustx", "sats"]) {
|
|
295
|
+
state[addr][day][unit] += totals[unit];
|
|
296
|
+
}
|
|
297
|
+
await this.writeState(state);
|
|
298
|
+
session.ustx += totals.ustx;
|
|
299
|
+
session.sats += totals.sats;
|
|
300
|
+
return day;
|
|
301
|
+
}));
|
|
302
|
+
if (!run.held) {
|
|
303
|
+
throw new Error(`Spend refused: could not take the spending-limit lock, so the daily cap cannot be ` +
|
|
304
|
+
`checked safely: ${run.unavailable}`);
|
|
249
305
|
}
|
|
306
|
+
return { addr, day: run.value, spends: booked };
|
|
250
307
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Give back a reservation whose spend never left this process (signing or
|
|
310
|
+
* broadcast failed, or a fallback path books its own). Without the lease
|
|
311
|
+
* the day ledger is left as is: an unlocked write could drop another
|
|
312
|
+
* process's booking, while skipping only over-counts.
|
|
313
|
+
*
|
|
314
|
+
* Idempotent: a reservation is given back at most once, so a caller that
|
|
315
|
+
* releases before a fallback and again when that fallback throws does not
|
|
316
|
+
* subtract twice.
|
|
317
|
+
*
|
|
318
|
+
* Never throws. Callers release on their way to rethrowing the real failure
|
|
319
|
+
* or into a fallback; a ledger write error here must not replace either.
|
|
320
|
+
* Failing to give budget back only over-counts.
|
|
321
|
+
*/
|
|
322
|
+
async release(reservation) {
|
|
323
|
+
if (reservation.spends.length === 0)
|
|
256
324
|
return;
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// overwrite each other and the day total under-counts, which is the
|
|
266
|
-
// direction that permits spending past the cap.
|
|
267
|
-
//
|
|
268
|
-
// Note the prune below walks EVERY address, so an unlocked concurrent
|
|
269
|
-
// write can lose another wallet's day too, not just this one's.
|
|
270
|
-
const write = this.writeLock.then(async () => {
|
|
271
|
-
const run = await withSharedStateLock(this.stateFile, async () => {
|
|
325
|
+
const { addr, day } = reservation;
|
|
326
|
+
const totals = { ustx: 0, sats: 0 };
|
|
327
|
+
for (const { unit, amount } of reservation.spends)
|
|
328
|
+
totals[unit] += amount;
|
|
329
|
+
reservation.spends = [];
|
|
330
|
+
let run;
|
|
331
|
+
try {
|
|
332
|
+
run = await this.serialized(() => withSharedStateLock(this.stateFile, async () => {
|
|
272
333
|
const state = await this.readState();
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
delete state[a][day];
|
|
279
|
-
}
|
|
280
|
-
if (Object.keys(state[a]).length === 0)
|
|
281
|
-
delete state[a];
|
|
334
|
+
const ledger = state[addr]?.[day];
|
|
335
|
+
if (ledger) {
|
|
336
|
+
ledger.ustx = Math.max(0, ledger.ustx - totals.ustx);
|
|
337
|
+
ledger.sats = Math.max(0, ledger.sats - totals.sats);
|
|
338
|
+
await this.writeState(state);
|
|
282
339
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
340
|
+
const session = this.getSession(addr);
|
|
341
|
+
session.ustx = Math.max(0, session.ustx - totals.ustx);
|
|
342
|
+
session.sats = Math.max(0, session.sats - totals.sats);
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
console.error(`[spend-limit] Could not release ${totals.ustx} uSTX / ${totals.sats} sats for ${addr}: ` +
|
|
347
|
+
`${error instanceof Error ? error.message : String(error)}. The day total stays ` +
|
|
348
|
+
`over-counted by that amount.`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!run.held) {
|
|
352
|
+
console.error(`[spend-limit] Could not release ${totals.ustx} uSTX / ${totals.sats} sats for ${addr}: ` +
|
|
353
|
+
`${run.unavailable} The day total stays over-counted by that amount.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Serialize ledger updates inside this process. The file lease excludes the
|
|
358
|
+
* OTHER process (the skills engine's direct x402 path shares
|
|
359
|
+
* spend-state.json so one wallet has one daily cap); this chain keeps two
|
|
360
|
+
* calls in this process from interleaving their read-modify-writes.
|
|
361
|
+
*/
|
|
362
|
+
serialized(fn) {
|
|
363
|
+
const run = this.writeLock.then(fn);
|
|
364
|
+
// A failed update must not poison the chain for every later one.
|
|
365
|
+
this.writeLock = run.then(() => { }, () => { });
|
|
366
|
+
return run;
|
|
303
367
|
}
|
|
304
368
|
/** Remaining session/day budget for status reporting. */
|
|
305
369
|
async status(addr) {
|
|
@@ -23,11 +23,13 @@
|
|
|
23
23
|
* though nothing here is x402: renaming it on this side would silently return
|
|
24
24
|
* both tools to the race above while each believed it held a lock.
|
|
25
25
|
*
|
|
26
|
-
* A STALE LEASE IS
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* A STALE LEASE IS RECLAIMED ONLY WHEN ITS HOLDER IS PROVABLY DEAD, by the
|
|
27
|
+
* same protocol the skills engine uses: a second mkdir lock (`<lock>.reclaim`)
|
|
28
|
+
* serializes the removal, and staleness and owner are re-read while holding
|
|
29
|
+
* it. A dead holder cannot release or heartbeat, so the lease inspected is the
|
|
30
|
+
* lease removed. Without this, one SIGKILLed holder would refuse every spend
|
|
31
|
+
* until an operator stepped in. A stale lease whose holder may still be alive
|
|
32
|
+
* is reported with its pid and left alone.
|
|
31
33
|
*/
|
|
32
34
|
/** Mutable so tests do not have to wait real seconds. */
|
|
33
35
|
declare const timing: {
|
|
@@ -47,26 +49,25 @@ export declare const _stateLockTesting: {
|
|
|
47
49
|
* redirects the state file redirects the lock with it.
|
|
48
50
|
*/
|
|
49
51
|
declare function lockDirFor(stateFile: string): string;
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
held: boolean;
|
|
53
|
-
/** Set when `held` is false: why the lease could not be taken. */
|
|
54
|
-
unavailable?: string;
|
|
52
|
+
export type LockedRun<T> = {
|
|
53
|
+
held: true;
|
|
55
54
|
value: T;
|
|
56
55
|
}
|
|
56
|
+
/** The lease could not be taken, so `fn` did NOT run. */
|
|
57
|
+
| {
|
|
58
|
+
held: false;
|
|
59
|
+
unavailable: string;
|
|
60
|
+
};
|
|
57
61
|
/**
|
|
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.
|
|
62
|
+
* Run `fn` under the shared lease for `stateFile`, or not at all.
|
|
65
63
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
64
|
+
* Every write to the spend ledger happens before the money moves: a
|
|
65
|
+
* reservation authorizes a spend not yet signed, and a release gives back one
|
|
66
|
+
* that never left. Neither is worth writing without the lease. A reservation
|
|
67
|
+
* that cannot take it is refused, which costs a retry; a release that cannot
|
|
68
|
+
* take it is skipped, which leaves the ledger over-counting. Both fail in the
|
|
69
|
+
* direction that keeps the cap. The skills engine refuses on the same lease
|
|
70
|
+
* for the same reason.
|
|
70
71
|
*/
|
|
71
72
|
export declare function withSharedStateLock<T>(stateFile: string, fn: () => Promise<T>): Promise<LockedRun<T>>;
|
|
72
73
|
export {};
|
|
@@ -23,15 +23,17 @@
|
|
|
23
23
|
* though nothing here is x402: renaming it on this side would silently return
|
|
24
24
|
* both tools to the race above while each believed it held a lock.
|
|
25
25
|
*
|
|
26
|
-
* A STALE LEASE IS
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* A STALE LEASE IS RECLAIMED ONLY WHEN ITS HOLDER IS PROVABLY DEAD, by the
|
|
27
|
+
* same protocol the skills engine uses: a second mkdir lock (`<lock>.reclaim`)
|
|
28
|
+
* serializes the removal, and staleness and owner are re-read while holding
|
|
29
|
+
* it. A dead holder cannot release or heartbeat, so the lease inspected is the
|
|
30
|
+
* lease removed. Without this, one SIGKILLed holder would refuse every spend
|
|
31
|
+
* until an operator stepped in. A stale lease whose holder may still be alive
|
|
32
|
+
* is reported with its pid and left alone.
|
|
31
33
|
*/
|
|
32
34
|
import os from "os";
|
|
33
35
|
import path from "path";
|
|
34
|
-
import { mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync, } from "fs";
|
|
36
|
+
import { mkdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync, } from "fs";
|
|
35
37
|
import { randomBytes } from "crypto";
|
|
36
38
|
/** No heartbeat for this long means the holder is probably dead. */
|
|
37
39
|
const LOCK_STALE_MS = 60_000;
|
|
@@ -116,6 +118,70 @@ function ownedBy(dir, token) {
|
|
|
116
118
|
return false;
|
|
117
119
|
}
|
|
118
120
|
}
|
|
121
|
+
/** "<pid> <token>" from the lease's owner file, or null if it cannot be read. */
|
|
122
|
+
function readOwner(dir) {
|
|
123
|
+
try {
|
|
124
|
+
const match = /^(\d+) (\S+)$/.exec(readFileSync(path.join(dir, "owner"), "utf8"));
|
|
125
|
+
return match ? { pid: Number(match[1]), token: match[2] } : null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* True only when the holder is provably not running on this host: the pid
|
|
133
|
+
* does not exist (ESRCH), or it is our own pid with a token this process does
|
|
134
|
+
* not hold (a previous incarnation, e.g. a restarted container reusing pid 1).
|
|
135
|
+
* EPERM and pid reuse by an unrelated process read as alive, which errs
|
|
136
|
+
* toward refusing.
|
|
137
|
+
*/
|
|
138
|
+
function holderIsDead(owner) {
|
|
139
|
+
if (owner.pid === process.pid) {
|
|
140
|
+
return ![...heldLocks.values()].includes(owner.token);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
process.kill(owner.pid, 0);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return error.code === "ESRCH";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Remove a stale lease whose holder is dead. Byte-compatible with
|
|
152
|
+
* `reclaimDeadLock` in the skills engine's x402-guards.ts. An ownerless lease
|
|
153
|
+
* (created, then the creator died before writing its owner) is reclaimable
|
|
154
|
+
* once stale, since a live creator writes the owner immediately.
|
|
155
|
+
*/
|
|
156
|
+
function reclaimDeadLock(dir) {
|
|
157
|
+
const reclaimDir = `${dir}.reclaim`;
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(reclaimDir);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false; // another process is reclaiming; keep waiting
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (!lockIsStale(dir))
|
|
166
|
+
return false;
|
|
167
|
+
const owner = readOwner(dir);
|
|
168
|
+
if (owner && !holderIsDead(owner))
|
|
169
|
+
return false;
|
|
170
|
+
rmSync(dir, { recursive: true, force: true });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
try {
|
|
178
|
+
rmdirSync(reclaimDir);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// already gone
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
119
185
|
function describeHolder(dir) {
|
|
120
186
|
try {
|
|
121
187
|
const [pid] = readFileSync(path.join(dir, "owner"), "utf8").split(" ");
|
|
@@ -170,47 +236,63 @@ function installExitHooks() {
|
|
|
170
236
|
});
|
|
171
237
|
}
|
|
172
238
|
}
|
|
239
|
+
function reclaimInProgress(dir) {
|
|
240
|
+
try {
|
|
241
|
+
statSync(`${dir}.reclaim`);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
173
248
|
async function acquire(dir, token) {
|
|
174
249
|
const deadline = Date.now() + timing.maxWaitMs;
|
|
175
250
|
for (;;) {
|
|
176
251
|
if (tryLock(dir, token))
|
|
177
252
|
return null;
|
|
178
253
|
if (lockIsStale(dir)) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
254
|
+
if (reclaimDeadLock(dir))
|
|
255
|
+
continue;
|
|
256
|
+
if (lockIsStale(`${dir}.reclaim`)) {
|
|
257
|
+
return {
|
|
258
|
+
reason: `a lock reclaim at ${dir}.reclaim was interrupted over ` +
|
|
259
|
+
`${Math.round(timing.staleMs / 1000)}s ago. Remove that directory (not the lock itself).`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (lockIsStale(dir) && !reclaimInProgress(dir)) {
|
|
263
|
+
return {
|
|
264
|
+
reason: `the lock at ${dir} has had no heartbeat for over ${Math.round(timing.staleMs / 1000)}s ` +
|
|
265
|
+
`(${describeHolder(dir)}) and its holder may still be running. If that process is ` +
|
|
266
|
+
`dead, remove the directory; do not remove it while a write may still be in progress.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
184
269
|
}
|
|
185
270
|
if (Date.now() >= deadline) {
|
|
186
271
|
return {
|
|
187
272
|
reason: `another writer held the lock at ${dir} for the whole ` +
|
|
188
|
-
`${Math.round(timing.maxWaitMs / 1000)}s wait (${describeHolder(dir)}).`,
|
|
273
|
+
`${Math.round(timing.maxWaitMs / 1000)}s wait (${describeHolder(dir)}). Retry once it finishes.`,
|
|
189
274
|
};
|
|
190
275
|
}
|
|
191
276
|
await new Promise((r) => setTimeout(r, timing.retryMs));
|
|
192
277
|
}
|
|
193
278
|
}
|
|
194
279
|
/**
|
|
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.
|
|
280
|
+
* Run `fn` under the shared lease for `stateFile`, or not at all.
|
|
202
281
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
282
|
+
* Every write to the spend ledger happens before the money moves: a
|
|
283
|
+
* reservation authorizes a spend not yet signed, and a release gives back one
|
|
284
|
+
* that never left. Neither is worth writing without the lease. A reservation
|
|
285
|
+
* that cannot take it is refused, which costs a retry; a release that cannot
|
|
286
|
+
* take it is skipped, which leaves the ledger over-counting. Both fail in the
|
|
287
|
+
* direction that keeps the cap. The skills engine refuses on the same lease
|
|
288
|
+
* for the same reason.
|
|
207
289
|
*/
|
|
208
290
|
export async function withSharedStateLock(stateFile, fn) {
|
|
209
291
|
const dir = lockDirFor(stateFile);
|
|
210
292
|
const token = randomBytes(12).toString("hex");
|
|
211
293
|
const unavailable = await acquire(dir, token);
|
|
212
294
|
if (unavailable) {
|
|
213
|
-
return { held: false, unavailable: unavailable.reason
|
|
295
|
+
return { held: false, unavailable: unavailable.reason };
|
|
214
296
|
}
|
|
215
297
|
installExitHooks();
|
|
216
298
|
heldLocks.set(dir, token);
|