@blockula/mcp 0.5.1 → 1.0.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,61 @@
1
+ export interface LockOptions {
2
+ /** A lock file not touched for this long is a stuck holder's, and is taken over. */
3
+ staleMs: number;
4
+ /** How long to wait for the lock before giving up. */
5
+ waitMs: number;
6
+ pollMs: number;
7
+ /** How often a live holder touches its lock file, so a slow call isn't taken over as stuck (default a quarter of `staleMs`). */
8
+ refreshMs?: number;
9
+ }
10
+ export declare class AccountLocks {
11
+ readonly dir: string;
12
+ readonly opts: LockOptions;
13
+ private readonly queues;
14
+ constructor(dir: string, opts?: LockOptions);
15
+ /** The lock file for a key (`<chain_id> <genesis_hash> <address>`). */
16
+ file(key: string): string;
17
+ /** Runs `f` holding `key`'s lock: no other call in this process, or other process sharing the directory, holds it meanwhile. */
18
+ run<T>(key: string, f: () => Promise<T>): Promise<T>;
19
+ private holdingFile;
20
+ /**
21
+ * Removes `lock`, which was read as `seen` and judged dead or stuck. Several waiters can judge
22
+ * the same lock at once, and the first one's takeover lets another process create a fresh lock
23
+ * before the second one's removal: deleting by name would then delete that fresh lock, and two
24
+ * holders would work on one account. So the lock is renamed to a name of this takeover's own
25
+ * (only one rename of it succeeds, and a lock that's gone throws ENOENT: nothing to do), and
26
+ * deleted only if what was moved is `seen`. If it's another lock, it's put back, unless a third
27
+ * process has already made a new one (then nothing can undo that; the window is the few calls
28
+ * between the rename and the link).
29
+ */
30
+ protected takeOver(lock: string, seen: string): void;
31
+ }
32
+ /**
33
+ * One small file per (chain, account), beside its lock: every write to it happens while holding
34
+ * that account's AccountLocks lock, so two processes never write it at once.
35
+ *
36
+ * A node's next_nonce can lag what this MCP knows: its mempool may not show a tx another node
37
+ * took, and right after a block runs its account read can still be the one before (observed on a
38
+ * dev chain: "bad nonce: expected 5, got 4" for the tx after a finalized one). Nonces only go up,
39
+ * so a tx that ran keeps its nonce spent for good, and a pending one holds its nonce until its
40
+ * valid_until. One that was dropped or refused holds nothing: its nonce may be free again.
41
+ */
42
+ export declare class NonceTracker {
43
+ readonly dir: string;
44
+ constructor(dir: string);
45
+ file(key: string): string;
46
+ private read;
47
+ private write;
48
+ /** The lowest nonce the next tx may take: past the last one that ran, and past a pending one while it can still run (the chain at or before its valid_until). */
49
+ floor(key: string, height: number): number | undefined;
50
+ /** A tx was submitted and isn't final yet. Call holding the account's lock. */
51
+ pending(key: string, nonce: number, validUntil: number): void;
52
+ /** The tx with this nonce ran in a block (finalized, or failed: its fee was charged). Call holding the account's lock. */
53
+ ran(key: string, nonce: number): void;
54
+ /**
55
+ * Forgets what's known: after the node refused a tx for a nonce past its own, so a wrong record
56
+ * (a node that said a tx ran when it didn't) can't block every later tx. Call holding the lock.
57
+ */
58
+ forget(key: string): void;
59
+ /** The tx with this nonce was dropped or refused: it holds its nonce no longer. Call holding the account's lock. */
60
+ settled(key: string, nonce: number): void;
61
+ }
package/dist/locks.js ADDED
@@ -0,0 +1,222 @@
1
+ // One tx at a time per account (AGENT-10). A tx's nonce is the node's `next_nonce` when it's built,
2
+ // so two built before either reaches the node get the same nonce, and the node refuses one ("bad
3
+ // nonce"). That happened with parallel tool calls in one session and with two sessions at once.
4
+ //
5
+ // - AccountLocks serializes building, signing and submitting per account: in this process with a
6
+ // promise chain, and across processes sharing BLOCKULA_HOME with a lock file (spend.ts's
7
+ // pattern, made asynchronous: a request to the node can take seconds). Waiting for finality
8
+ // happens outside the lock, so parallel sends still overlap there. A live holder touches its
9
+ // lock file while it works (`LockOptions.refreshMs`), so only a holder that's gone, or hung past
10
+ // `staleMs`, has its lock taken over; and a takeover moves the lock aside under a name of its own
11
+ // before it deletes it, and puts back a lock it moved by mistake (`takeOver`).
12
+ // - NonceTracker remembers the last nonce this MCP signed per (chain, account) while its tx is
13
+ // pending (submitted, not yet final). The next tx takes max(node next_nonce, last + 1), so a node
14
+ // whose view lags the mempool that took the last one can't hand out its nonce again. It only
15
+ // informs: an unreadable file means the node's next_nonce, as before.
16
+ import { createHash } from "node:crypto";
17
+ import { mkdirSync, openSync, closeSync, linkSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ /** Whether a process with this pid is running (on this machine). */
21
+ function alive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (e) {
27
+ return e.code === "EPERM";
28
+ }
29
+ }
30
+ export class AccountLocks {
31
+ dir;
32
+ opts;
33
+ queues = new Map();
34
+ constructor(dir, opts = { staleMs: 120_000, waitMs: 120_000, pollMs: 25 }) {
35
+ this.dir = dir;
36
+ this.opts = opts;
37
+ }
38
+ /** The lock file for a key (`<chain_id> <genesis_hash> <address>`). */
39
+ file(key) {
40
+ return join(this.dir, `agent-nonce-${keyHash(key)}.lock`);
41
+ }
42
+ /** Runs `f` holding `key`'s lock: no other call in this process, or other process sharing the directory, holds it meanwhile. */
43
+ async run(key, f) {
44
+ const before = this.queues.get(key) ?? Promise.resolve();
45
+ let done;
46
+ const mine = new Promise((r) => (done = r));
47
+ const tail = before.then(() => mine);
48
+ this.queues.set(key, tail);
49
+ await before;
50
+ try {
51
+ return await this.holdingFile(key, f);
52
+ }
53
+ finally {
54
+ done();
55
+ if (this.queues.get(key) === tail)
56
+ this.queues.delete(key);
57
+ }
58
+ }
59
+ async holdingFile(key, f) {
60
+ const lock = this.file(key);
61
+ mkdirSync(dirname(lock), { recursive: true });
62
+ const token = `${process.pid} ${Date.now()} ${Math.random().toString(36).slice(2)}`;
63
+ const start = Date.now();
64
+ for (;;) {
65
+ try {
66
+ const fd = openSync(lock, "wx", 0o600);
67
+ try {
68
+ writeFileSync(fd, token);
69
+ }
70
+ finally {
71
+ closeSync(fd);
72
+ }
73
+ break;
74
+ }
75
+ catch (e) {
76
+ if (e.code !== "EEXIST")
77
+ throw e;
78
+ }
79
+ // Held: by a process that's gone, or one that hasn't touched its lock for too long, it's taken over.
80
+ try {
81
+ const seen = readFileSync(lock, "utf8");
82
+ const holder = Number(seen.split(" ")[0]);
83
+ const stale = Date.now() - statSync(lock).mtimeMs > this.opts.staleMs;
84
+ if (stale || (Number.isSafeInteger(holder) && holder > 0 && holder !== process.pid && !alive(holder)))
85
+ this.takeOver(lock, seen);
86
+ }
87
+ catch {
88
+ /* it went away, or is being written: try again */
89
+ }
90
+ if (Date.now() - start > this.opts.waitMs)
91
+ throw new Error(`another Blockula MCP has been sending from this account for over ${Math.round(this.opts.waitMs / 1000)} s (${lock}); if none is running, delete that file`);
92
+ await sleep(this.opts.pollMs);
93
+ }
94
+ // While `f` runs, a live holder keeps its lock fresh: a call that takes longer than `staleMs`
95
+ // (a node that hangs until BLOCKULA_TIMEOUT_MS) isn't a stuck holder.
96
+ const beat = setInterval(() => {
97
+ try {
98
+ if (readFileSync(lock, "utf8") !== token)
99
+ return; // taken over: it's not ours to touch
100
+ const now = new Date();
101
+ utimesSync(lock, now, now);
102
+ }
103
+ catch {
104
+ /* gone */
105
+ }
106
+ }, this.opts.refreshMs ?? Math.max(1, Math.floor(this.opts.staleMs / 4)));
107
+ beat.unref();
108
+ try {
109
+ return await f();
110
+ }
111
+ finally {
112
+ clearInterval(beat);
113
+ try {
114
+ if (readFileSync(lock, "utf8") === token)
115
+ rmSync(lock, { force: true });
116
+ }
117
+ catch {
118
+ /* taken over as stale, or removed */
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Removes `lock`, which was read as `seen` and judged dead or stuck. Several waiters can judge
124
+ * the same lock at once, and the first one's takeover lets another process create a fresh lock
125
+ * before the second one's removal: deleting by name would then delete that fresh lock, and two
126
+ * holders would work on one account. So the lock is renamed to a name of this takeover's own
127
+ * (only one rename of it succeeds, and a lock that's gone throws ENOENT: nothing to do), and
128
+ * deleted only if what was moved is `seen`. If it's another lock, it's put back, unless a third
129
+ * process has already made a new one (then nothing can undo that; the window is the few calls
130
+ * between the rename and the link).
131
+ */
132
+ takeOver(lock, seen) {
133
+ const grave = `${lock}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.stale`;
134
+ renameSync(lock, grave);
135
+ try {
136
+ if (readFileSync(grave, "utf8") !== seen)
137
+ linkSync(grave, lock);
138
+ }
139
+ catch {
140
+ /* a new lock is there already, or the moved one is unreadable: it's dropped with the grave */
141
+ }
142
+ finally {
143
+ rmSync(grave, { force: true });
144
+ }
145
+ }
146
+ }
147
+ const keyHash = (key) => createHash("sha256").update(key).digest("hex").slice(0, 24);
148
+ /**
149
+ * One small file per (chain, account), beside its lock: every write to it happens while holding
150
+ * that account's AccountLocks lock, so two processes never write it at once.
151
+ *
152
+ * A node's next_nonce can lag what this MCP knows: its mempool may not show a tx another node
153
+ * took, and right after a block runs its account read can still be the one before (observed on a
154
+ * dev chain: "bad nonce: expected 5, got 4" for the tx after a finalized one). Nonces only go up,
155
+ * so a tx that ran keeps its nonce spent for good, and a pending one holds its nonce until its
156
+ * valid_until. One that was dropped or refused holds nothing: its nonce may be free again.
157
+ */
158
+ export class NonceTracker {
159
+ dir;
160
+ constructor(dir) {
161
+ this.dir = dir;
162
+ }
163
+ file(key) {
164
+ return join(this.dir, `agent-nonce-${keyHash(key)}.json`);
165
+ }
166
+ read(key) {
167
+ try {
168
+ const n = JSON.parse(readFileSync(this.file(key), "utf8"));
169
+ if (!n || n.key !== key)
170
+ return { key };
171
+ const ran = Number.isSafeInteger(n.ran) && n.ran >= 0 ? n.ran : undefined;
172
+ const p = n.pending;
173
+ const pending = p && Number.isSafeInteger(p.nonce) && p.nonce >= 0 && Number.isSafeInteger(p.valid_until) ? { nonce: p.nonce, valid_until: p.valid_until } : undefined;
174
+ return { key, ...(ran !== undefined ? { ran } : {}), ...(pending ? { pending } : {}) };
175
+ }
176
+ catch {
177
+ return { key };
178
+ }
179
+ }
180
+ write(n) {
181
+ try {
182
+ mkdirSync(this.dir, { recursive: true });
183
+ const tmp = `${this.file(n.key)}.${process.pid}.tmp`;
184
+ writeFileSync(tmp, JSON.stringify(n), { mode: 0o600 });
185
+ renameSync(tmp, this.file(n.key));
186
+ }
187
+ catch {
188
+ /* informational only */
189
+ }
190
+ }
191
+ /** The lowest nonce the next tx may take: past the last one that ran, and past a pending one while it can still run (the chain at or before its valid_until). */
192
+ floor(key, height) {
193
+ const n = this.read(key);
194
+ const after = [n.ran !== undefined ? n.ran + 1 : undefined, n.pending && height <= n.pending.valid_until ? n.pending.nonce + 1 : undefined].filter((x) => x !== undefined);
195
+ return after.length ? Math.max(...after) : undefined;
196
+ }
197
+ /** A tx was submitted and isn't final yet. Call holding the account's lock. */
198
+ pending(key, nonce, validUntil) {
199
+ const n = this.read(key);
200
+ this.write({ ...n, pending: { nonce, valid_until: validUntil } });
201
+ }
202
+ /** The tx with this nonce ran in a block (finalized, or failed: its fee was charged). Call holding the account's lock. */
203
+ ran(key, nonce) {
204
+ const n = this.read(key);
205
+ const pending = n.pending && n.pending.nonce > nonce ? n.pending : undefined;
206
+ this.write({ key, ran: Math.max(n.ran ?? -1, nonce), ...(pending ? { pending } : {}) });
207
+ }
208
+ /**
209
+ * Forgets what's known: after the node refused a tx for a nonce past its own, so a wrong record
210
+ * (a node that said a tx ran when it didn't) can't block every later tx. Call holding the lock.
211
+ */
212
+ forget(key) {
213
+ rmSync(this.file(key), { force: true });
214
+ }
215
+ /** The tx with this nonce was dropped or refused: it holds its nonce no longer. Call holding the account's lock. */
216
+ settled(key, nonce) {
217
+ const n = this.read(key);
218
+ if (n.pending?.nonce !== nonce)
219
+ return;
220
+ this.write({ key, ...(n.ran !== undefined ? { ran: n.ran } : {}) });
221
+ }
222
+ }
package/dist/network.d.ts CHANGED
@@ -1,4 +1,10 @@
1
- export type NetworkKey = "mainnet" | "testnet" | "local" | "custom";
1
+ export type NetworkKey = "testnet" | "local" | "custom";
2
+ /**
3
+ * The chain id Blockula's mainnet was to have: no chain runs under it, so nothing is used under it
4
+ * by accident (`unpinnedBlockula1`), and where an address is needed an unproven name isn't signed
5
+ * on it (index.ts `onBlockula1`).
6
+ */
7
+ export declare const BLOCKULA_1 = "blockula-1";
2
8
  /** What the node must report in `/api/status`. */
3
9
  export interface Pin {
4
10
  chainId?: string;
@@ -10,24 +16,30 @@ export interface Network {
10
16
  label: string;
11
17
  rpc: string;
12
18
  pin: Pin;
13
- /** Real money: the copy and the defaults get stricter. */
14
- mainnet: boolean;
15
19
  /** Remember the first chain this node reports (TOFU) across restarts. Off for local dev chains, which `--reset` replaces. */
16
20
  remember: boolean;
17
21
  /**
18
22
  * Independent nodes of the same chain (BLOCKULA_WITNESSES, comma-separated): names that end up
19
23
  * in a signature are proven against them (audit gap-7-1). Empty: a name is the node's word.
24
+ * Unset, and the node isn't one of its network's public RPCs (your own node), they're those
25
+ * RPCs (`defaultWitnesses`; OPERATOR-6).
20
26
  */
21
27
  witnesses: string[];
28
+ /** BLOCKULA_WITNESSES was set (`none` included): no witnesses are added by default. */
29
+ witnessesSet: boolean;
22
30
  }
23
31
  export declare const LOCAL_RPC = "http://localhost:7070";
24
32
  export declare const TESTNET_RPC = "https://blockula-testnet.fly.dev";
25
- /** Public mainnet RPC endpoints, run by independent operators. Empty until launch: set BLOCKULA_RPC. */
33
+ /**
34
+ * Nothing reads this: the MCP has no mainnet network. It stays because
35
+ * `scripts/pin-network.sh --mainnet --rpc` writes it and `scripts/test-pin-network.sh` checks
36
+ * that; it goes when that mode does.
37
+ */
26
38
  export declare const MAINNET_RPCS: string[];
27
39
  /**
28
40
  * Chains whose genesis is known: any node claiming one of these chain ids must report this
29
- * genesis hash, whatever network it was configured as. `blockula-1`'s hash is added here once the
30
- * genesis ceremony has produced it (docs/GENESIS_CEREMONY.md).
41
+ * genesis hash, whatever network it was configured as. A release adds a chain's here
42
+ * (scripts/pin-network.sh).
31
43
  */
32
44
  export declare const KNOWN_GENESIS: Record<string, string>;
33
45
  /**
@@ -49,11 +61,53 @@ export interface GenesisClock {
49
61
  */
50
62
  export declare function clockFrom(genesis: unknown, chainId: string): GenesisClock | undefined;
51
63
  /** The networks the MCP knows by name. */
52
- export declare const KNOWN_NETWORKS: Record<Exclude<NetworkKey, "custom">, Omit<Network, "rpc" | "witnesses"> & {
64
+ export declare const KNOWN_NETWORKS: Record<Exclude<NetworkKey, "custom">, Omit<Network, "rpc" | "witnesses" | "witnessesSet"> & {
53
65
  rpc?: string;
54
66
  }>;
55
- /** A node URL: http(s) without credentials; plain http only on this machine or a private network. */
67
+ /**
68
+ * The witnesses a node of `chainId` gets when BLOCKULA_WITNESSES is unset: the chain's public RPCs,
69
+ * unless the node is one of them. So your own node's answers about names and heights are checked
70
+ * against the public ones (OPERATOR-6). None for chains without public RPCs (dev chains, and the
71
+ * chains people launch themselves: there, BLOCKULA_WITNESSES lists other nodes).
72
+ */
73
+ export declare function defaultWitnesses(chainId: string | undefined, rpc: string): string[];
74
+ /**
75
+ * What `status` calls the network: once the node's chain is known, a node on this machine that
76
+ * is on a public network is "your node on <it>", never a "local dev node".
77
+ */
78
+ export declare function networkLabel(net: Network, chainId?: string): string;
79
+ /**
80
+ * A node URL in one spelling: the scheme and host lowercased, a default port and dot segments
81
+ * dropped (the URL parser's own normalization), no trailing dot on the host and no trailing
82
+ * slash. So a node compares equal however it's written: `https://Blockula-Testnet.fly.dev:443/.`
83
+ * is the public RPC, and is never its own witness (audit MCK-1). What isn't an http(s) URL comes
84
+ * back as it is.
85
+ */
86
+ export declare function canonicalUrl(u: string): string;
87
+ /**
88
+ * A node URL, spelled canonically (canonicalUrl): http(s) without credentials, a query or a
89
+ * fragment (the client appends /api/… to it); plain http only on this machine or a private network.
90
+ */
56
91
  export declare function checkRpcUrl(u: string, what?: string): string;
92
+ /**
93
+ * The chain ids this MCP pins, and keys its ledger (spend.ts `chainKey`), lock and nonce records
94
+ * and grant files by: 1 to 64 letters, digits, '-', '_' and '.'. A genesis built for a public
95
+ * network has letters, digits and '-' (node/src/genesis_builder.rs, at most 64: MAX_CHAIN_ID), so
96
+ * every real chain fits. A node reporting anything else (a path separator, a space) is refused
97
+ * when its chain is pinned (`unusableChain`), so nothing it names is ever used as a file name or
98
+ * a ledger key (audit MCK-3).
99
+ */
100
+ export declare const CHAIN_ID: RegExp;
101
+ /**
102
+ * Why a node's chain can't be pinned at all, or null: a chain id outside CHAIN_ID, or a genesis
103
+ * hash that isn't 64 hex characters, can't key what this MCP keeps per chain. (A node that reports
104
+ * no genesis hash is pinned by its chain id alone, as before; it can't sign, and `use_account`
105
+ * says that's why.)
106
+ */
107
+ export declare function unusableChain(status: {
108
+ chain_id?: unknown;
109
+ genesis_hash?: unknown;
110
+ }): string | null;
57
111
  /** `--network <name>` or `--network=<name>` from the command line. */
58
112
  export declare function networkArg(argv: string[]): string | undefined;
59
113
  /**
@@ -68,6 +122,15 @@ export declare function mismatch(pin: Pin, status: {
68
122
  genesis_hash?: unknown;
69
123
  address_prefix?: unknown;
70
124
  }): string | null;
125
+ /**
126
+ * Why a node that says it's on blockula-1 is refused, or null: the pin (configured, or remembered
127
+ * from a configuration that named it) isn't that chain. A node that reports blockula-1 is never
128
+ * trusted on first use, as the CLI doesn't (`cli_net`): set BLOCKULA_CHAIN_ID=blockula-1 to mean it.
129
+ * (A pin on another chain is refused by `mismatch` first, in its own words.)
130
+ */
131
+ export declare function unpinnedBlockula1(pin: Pin, status: {
132
+ chain_id?: unknown;
133
+ }): string | null;
71
134
  /** The pin after a first status: every field the node reported that wasn't pinned yet. */
72
135
  export declare function completePin(pin: Pin, status: {
73
136
  chain_id?: unknown;
@@ -87,6 +150,7 @@ export declare class KnownChains {
87
150
  /** The genesis clock remembered for this chain (`clockFrom` of the first `/api/genesis` read for it). */
88
151
  clock(net: Network, chainId: string, genesisHash: string): GenesisClock | undefined;
89
152
  saveClock(net: Network, chainId: string, genesisHash: string, clock: GenesisClock): void;
153
+ /** Sets one entry (and drops the entries `drop` names, in the same write). */
90
154
  private write;
91
155
  }
92
156
  /**
@@ -97,5 +161,9 @@ export declare class KnownChains {
97
161
  * pinned on first use instead of refused for the old one's genesis.
98
162
  */
99
163
  export declare function effectivePin(net: Network, remembered?: Pin): Pin;
100
- /** The error an agent sees when a node is on the wrong chain: what happened and what the user can do. */
101
- export declare function mismatchError(net: Network, why: string, knownChainsFile: string): Error;
164
+ /**
165
+ * The error an agent sees when a node is on the wrong chain: what happened and what the user can
166
+ * do. `relaunched` is false for a node whose chain can't be pinned at all (`unusableChain`):
167
+ * forgetting what was remembered wouldn't help there.
168
+ */
169
+ export declare function mismatchError(net: Network, why: string, knownChainsFile: string, relaunched?: boolean): Error;