@blockula/mcp 0.2.0 → 0.5.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.
package/dist/index.js CHANGED
@@ -1,76 +1,351 @@
1
1
  #!/usr/bin/env node
2
2
  // 🧛 Blockula MCP server. Add to Claude Code with:
3
- // claude mcp add blockula -- npx -y @blockula/mcp
3
+ // claude mcp add blockula -- npx -y @blockula/mcp (the public testnet)
4
+ // claude mcp add blockula -- npx -y @blockula/mcp --network local (a `blockula dev` chain)
4
5
  //
5
6
  // Env:
6
- // BLOCKULA_RPC node URL (default: a local node on :7070 if one is running, else the public testnet)
7
+ // BLOCKULA_NETWORK mainnet, testnet (default), local or custom (same as --network)
8
+ // BLOCKULA_RPC node URL (default: the network's public RPC; required for custom, and for
9
+ // mainnet until public RPCs are built in)
10
+ // BLOCKULA_CHAIN_ID / BLOCKULA_GENESIS_HASH pin the chain the node must be on
11
+ // BLOCKULA_WITNESSES other nodes of the same chain (comma-separated URLs): .boo names that get
12
+ // signed (send, stake, unstake, use_account) are proven against them, and the
13
+ // height each tx is signed against must not be ahead of their heads (nor, in
14
+ // any case, past what the chain's genesis clock allows). Without
15
+ // them a send signs the name itself (the chain resolves it when it runs), and
16
+ // where an address is needed (stake, unstake, use_account) a name is the
17
+ // node's word: on mainnet (any node on blockula-1, however it was configured)
18
+ // the address must be confirmed first.
7
19
  // BLOCKULA_HOME where the agent wallet lives (default ~/.blockula)
8
- // BLOCKULA_MAX_SEND max FANG per send() call (default 100)
9
- // BLOCKULA_MAX_DAILY max FANG this MCP sends per UTC day, across calls (default 100)
10
- // BLOCKULA_BIN path to the `blockula` binary (default: found on PATH)
11
- // When acting for a user's account via a session key, the chain also enforces that key's limit.
20
+ // BLOCKULA_MAX_SEND max FANG one call moves (sent, staked, escrowed, paid for a name, bonded; default 100)
21
+ // BLOCKULA_MAX_DAILY max FANG leaving the account per UTC day through this MCP, fees included
22
+ // (default BLOCKULA_MAX_SEND + 1 FANG for fees, so one full-size call fits)
23
+ // BLOCKULA_TIMEOUT_MS how long one request to the node may take (default 15000, at most 2147483647)
24
+ // BLOCKULA_TX_WAIT_MS how long a tx tool waits for finality before answering "pending" (default 15000)
25
+ // BLOCKULA_BIN path to the `blockula` binary (default: of ~/.blockula/bin/blockula from `blockula release
26
+ // install` and ~/.blockula/mcp-bin/blockula from install_node, the one of this MCP's version,
27
+ // the first if both or neither are; else PATH)
28
+ // BLOCKULA_REQUIRE_VERIFY=1 install_node needs provenance too (and refuses a checksum-only release)
29
+ // BLOCKULA_RELEASE_KEY_ID the release signing key id to trust (until it's built in; it must
30
+ // agree with the built-in one and with ~/.blockula/release-key-id, if pinned)
31
+ // The network never changes while the server runs (WAL-4): see network.ts. Spending caps: see
32
+ // spend.ts (reserved before a tx is sent). Chain text is untrusted: see untrusted.ts. When acting
33
+ // for a user's account via a session key, the chain also enforces that key's limit.
12
34
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
35
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
- import { Client, Wallet, formatFang, parseFang } from "@blockula/sdk";
15
- import { spawn, execFileSync } from "node:child_process";
16
- import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
17
- import { homedir } from "node:os";
18
- import { join } from "node:path";
36
+ import { ApiError, Client, HeightAheadError, MAINNET_CHAIN_ID, NAMESPACES, RequestTimeoutError, TxFailedError, TxPendingError, Wallet, bareName, checkTimeoutMs, decodeAccount, describeKind, encodeKeyGrant, isAddress, proposalDigest, formatFang, paramsOf, parseFang, proposalSummary, stakeSummary, txDropped, txId } from "@blockula/sdk";
37
+ import { spawn, spawnSync, execFileSync } from "node:child_process";
38
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
39
+ import { homedir, tmpdir } from "node:os";
40
+ import { dirname, join } from "node:path";
19
41
  import { z } from "zod";
20
- const LOCAL_RPC = "http://localhost:7070";
21
- const TESTNET_RPC = "https://blockula-testnet.fly.dev";
42
+ import { KNOWN_CLOCKS, KNOWN_GENESIS, KnownChains, clockFrom, completePin, effectivePin, mismatch, mismatchError, resolveNetwork } from "./network.js";
43
+ import { PIN_FILE, REPO, RELEASE_KEY_ID, binPaths, checkBinaryVersion, fetchVerifiedRelease, latestTagFrom, pickNodeBinary, readReleasePin, releaseKeyIdFrom } from "./release.js";
44
+ import { SignedTxs, onChain } from "./signed.js";
45
+ import { SpendLedger } from "./spend.js";
46
+ import { SERVER_INSTRUCTIONS, UNTRUSTED_NOTE, chainData, clean } from "./untrusted.js";
22
47
  const HOME = process.env.BLOCKULA_HOME ?? join(homedir(), ".blockula");
23
48
  const WALLET_FILE = join(HOME, "agent-wallet.json");
24
49
  const MAX_SEND = parseFang(process.env.BLOCKULA_MAX_SEND ?? "100");
25
- const MAX_DAILY = parseFang(process.env.BLOCKULA_MAX_DAILY ?? "100");
50
+ /**
51
+ * Fees count toward BLOCKULA_MAX_DAILY (they leave the account too), so by default the daily cap
52
+ * is the per-call cap plus this much for fees: one call of BLOCKULA_MAX_SEND fits in a day, with
53
+ * room for the fees of many more small txs (audit review-fix-clients-3).
54
+ */
55
+ const DAILY_FEE_ALLOWANCE = parseFang("1");
56
+ const MAX_DAILY = process.env.BLOCKULA_MAX_DAILY ? parseFang(process.env.BLOCKULA_MAX_DAILY) : MAX_SEND + DAILY_FEE_ALLOWANCE;
57
+ const MAX_DAILY_TEXT = process.env.BLOCKULA_MAX_DAILY ? `BLOCKULA_MAX_DAILY=${process.env.BLOCKULA_MAX_DAILY} FANG` : `BLOCKULA_MAX_DAILY, by default BLOCKULA_MAX_SEND + 1 FANG for fees = ${formatFang(MAX_DAILY)}`;
26
58
  const SPEND_FILE = join(HOME, "agent-spend.json");
59
+ const msEnv = (name, fallback) => {
60
+ const v = process.env[name];
61
+ if (v === undefined || v === "")
62
+ return fallback;
63
+ if (!/^\d+$/.test(v))
64
+ throw new Error(`${name} must be a positive number of milliseconds`);
65
+ return checkTimeoutMs(Number(v), name);
66
+ };
67
+ const TIMEOUT_MS = msEnv("BLOCKULA_TIMEOUT_MS", 15_000);
68
+ const TX_WAIT_MS = msEnv("BLOCKULA_TX_WAIT_MS", 15_000);
69
+ const KNOWN_CHAINS = new KnownChains(join(HOME, "known-chains.json"));
27
70
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
28
- const BIN = process.env.BLOCKULA_BIN ?? "blockula";
71
+ // install_node's binary has a path of its own: it never replaces the one `blockula release install`
72
+ // checked against the operator's pinned key (audit review-r3-integrate-compose-1). Which one runs is
73
+ // decided at each use, by version (nodeBinary).
74
+ const { mcp: INSTALLED_BIN, cli: CLI_BIN } = binPaths(HOME);
75
+ /** What a binary's `--version` prints, or undefined if it doesn't run. */
76
+ function versionOf(path) {
77
+ try {
78
+ return execFileSync(path, ["--version"], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ /** The node binary to run now: BLOCKULA_BIN, else the installed one of this MCP's version (pickNodeBinary). */
85
+ function nodeBinary() {
86
+ if (process.env.BLOCKULA_BIN)
87
+ return { path: process.env.BLOCKULA_BIN };
88
+ return pickNodeBinary([CLI_BIN, INSTALLED_BIN].filter((p) => existsSync(p)).map((path) => ({ path, version: versionOf(path) })), VERSION);
89
+ }
90
+ /** Says which binary ran, and when it isn't this MCP's version. */
91
+ const ranWith = (b) => `Ran ${b.path}${b.version ? ` (${b.version})` : ""}${b.matches === false && b.path !== "blockula" ? `, not this MCP's version (${VERSION}): install_node v${VERSION} (or \`blockula release install v${VERSION}\`) gets one that is.` : "."}`;
92
+ /** Testnet economics, said wherever Hunts and the contribution pool come up. */
93
+ const NO_VALUE = "Testnet FANG, Hunts and Soul have no monetary value and come with no promise of any mainnet allocation.";
94
+ function releaseTarget() {
95
+ const key = `${process.platform}-${process.arch}`;
96
+ return { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }[key] ?? null;
97
+ }
98
+ // ---- the network: chosen once, pinned, never switched ----
99
+ let NET = null;
100
+ let NET_ERROR = null;
101
+ try {
102
+ NET = resolveNetwork(process.env, process.argv.slice(2));
103
+ }
104
+ catch (e) {
105
+ NET_ERROR = e;
106
+ }
107
+ const RPC = NET?.rpc ?? "";
108
+ let pinned = null;
109
+ /** The chain `pinned` is held to (tx_status reads a signed tx's record only on the chain it was signed for). */
110
+ let pinnedChain = {};
111
+ function network() {
112
+ if (!NET)
113
+ throw new Error(`The Blockula MCP isn't configured correctly: ${NET_ERROR?.message}`);
114
+ return NET;
115
+ }
116
+ /**
117
+ * The client for the configured network, pinned to its chain. The first call checks the node's
118
+ * chain id, genesis hash and prefix against the network's pins (and what was remembered for it);
119
+ * from then on the SDK re-checks every status, including the one every signature is made against.
120
+ */
121
+ async function chain() {
122
+ if (pinned)
123
+ return pinned;
124
+ const net = network();
125
+ const pin = effectivePin(net, KNOWN_CHAINS.get(net));
126
+ const status = await new Client(net.rpc, { timeoutMs: TIMEOUT_MS }).status();
127
+ const why = mismatch(pin, status);
128
+ if (why)
129
+ throw mismatchError(net, why, KNOWN_CHAINS.file);
130
+ const full = completePin(pin, status);
131
+ KNOWN_CHAINS.save(net, full);
132
+ const pins = { chainId: full.chainId, genesisHash: full.genesisHash, prefix: full.prefix, timeoutMs: TIMEOUT_MS };
133
+ // Witnesses are held to the same chain; with them, names are proven (Client.resolution) and a
134
+ // height ahead of their heads is refused. The chain's genesis clock bounds the height too.
135
+ const genesisClock = await clockOf(net, full);
136
+ pinned = new Client(net.rpc, { ...pins, genesisClock, witnesses: net.witnesses.map((w) => new Client(w, pins)) });
137
+ pinnedChain = { chainId: full.chainId, genesisHash: full.genesisHash, prefix: full.prefix };
138
+ return pinned;
139
+ }
140
+ /**
141
+ * The pinned chain's genesis clock, which bounds the height each tx is signed against
142
+ * (`Client.checkSigningHeight`: a node reporting a height the chain can't have reached by this
143
+ * machine's clock gets nothing signed, since it could hold the tx and release it later; audit
144
+ * review-r3-clients-2). Built in with a known genesis; else the one remembered for this chain id
145
+ * and genesis hash; else the node's `/api/genesis`, taken on first use like the genesis hash and
146
+ * remembered with it (but for local dev chains, for this session only). A node that serves none
147
+ * leaves the height its word, as without the clock.
148
+ */
149
+ async function clockOf(net, full) {
150
+ const { chainId, genesisHash } = full;
151
+ if (chainId === undefined || genesisHash === undefined)
152
+ return undefined;
153
+ if (KNOWN_GENESIS[chainId] === genesisHash && KNOWN_CLOCKS[chainId])
154
+ return KNOWN_CLOCKS[chainId];
155
+ const remembered = KNOWN_CHAINS.clock(net, chainId, genesisHash);
156
+ if (remembered)
157
+ return remembered;
158
+ const clock = clockFrom(await new Client(net.rpc, { timeoutMs: TIMEOUT_MS }).genesis().catch(() => undefined), chainId);
159
+ if (clock)
160
+ KNOWN_CHAINS.saveClock(net, chainId, genesisHash, clock);
161
+ return clock;
162
+ }
29
163
  async function reachable(url) {
30
164
  try {
31
- await new Client(url).status();
165
+ await new Client(url, { timeoutMs: Math.min(TIMEOUT_MS, 5_000) }).status();
32
166
  return true;
33
167
  }
34
168
  catch {
35
169
  return false;
36
170
  }
37
171
  }
38
- // No configuration needed: use a local node if one is running, otherwise the public testnet.
39
- let RPC = process.env.BLOCKULA_RPC ?? ((await reachable(LOCAL_RPC)) ? LOCAL_RPC : TESTNET_RPC);
40
- let client = new Client(RPC);
41
- function useRpc(url) {
42
- RPC = url;
43
- client = new Client(url);
172
+ const explorer = (path) => `${RPC}/#/${path}`;
173
+ /** A protocol v1 read, with a plain answer on nodes whose chain doesn't have the module yet. */
174
+ async function moduleRead(what, read) {
175
+ try {
176
+ return await read;
177
+ }
178
+ catch (e) {
179
+ if (e instanceof ApiError && e.status === 404 && /404|not found/i.test(e.message))
180
+ throw new Error(`${RPC} doesn't serve ${what}: it isn't active on this chain yet`);
181
+ throw e;
182
+ }
183
+ }
184
+ // ---- spending caps (BLOCKULA_MAX_SEND per call, BLOCKULA_MAX_DAILY per UTC day) ----
185
+ const LEDGER = new SpendLedger(SPEND_FILE, MAX_DAILY, MAX_DAILY_TEXT);
186
+ /** The txs this MCP signed (their valid_until, for tx_status): see signed.ts. */
187
+ const SIGNED = new SignedTxs(join(HOME, "agent-txs.json"));
188
+ /**
189
+ * Whether this is real money: the configured network is mainnet, or the chain the client is
190
+ * pinned to is (audit gap-7-1, R1 bypass): BLOCKULA_RPC alone pointing at a node on blockula-1 is
191
+ * a "custom" network, and gets mainnet's rules all the same. The client is pinned to the chain id
192
+ * of its first status, and refuses any later status that reports another, so this is the chain
193
+ * every signature it makes is for.
194
+ */
195
+ async function realMoney(client) {
196
+ return network().mainnet || (await client.status()).chain_id === MAINNET_CHAIN_ID;
44
197
  }
45
- /** Running total of FANG sent today (UTC), persisted so the cap holds across calls and restarts. */
46
- function spentToday() {
47
- const day = new Date().toISOString().slice(0, 10);
198
+ const isInFlight = (r) => typeof r === "object" && r !== null && "tx_id" in r && ["pending", "unknown", "dropped"].includes(r.status);
199
+ /**
200
+ * Signs `action`, reserves what it can cost (the spend plus the tx's max fee) against today's
201
+ * budget under its (account, nonce) slot, and only then submits it. Final: the unused part of the
202
+ * max fee is given back and the tx is returned. Everything else stays counted: failed in a block
203
+ * (its fee was charged), dropped, pending, timed out, unknown, and refused too, since the node's
204
+ * refusal is only its word and it may relay the signed tx anyway (audit F68, F72, F74). A refused
205
+ * tx stops counting twice once this MCP signs another tx with its nonce (the next send, after an
206
+ * honest refusal): see spend.ts.
207
+ */
208
+ async function submitCapped(w, action, spend, memo = "") {
209
+ const perCall = spend.perCall ?? spend.amount;
210
+ if (perCall > MAX_SEND)
211
+ throw new Error(`${spend.what ?? "amount"} (${formatFang(perCall)}) exceeds this agent's per-call limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
212
+ const tx = await w.build(action, memo, { resolutions: spend.resolutions ?? [] });
213
+ const id = txId(tx);
214
+ const maxFee = BigInt(tx.fee);
215
+ const slot = { account: tx.from, nonce: tx.nonce };
216
+ const reservation = LEDGER.reserve(spend.amount + (spend.extra ?? 0n) + maxFee, slot);
217
+ const counted = formatFang(LEDGER.held(slot));
218
+ // Before it can reach any node: tx_status reads its valid_until from here (review-fix-clients-2).
48
219
  try {
49
- const f = JSON.parse(readFileSync(SPEND_FILE, "utf8"));
50
- if (f.day === day)
51
- return { day, spent: BigInt(f.spent) };
220
+ SIGNED.add(id, { valid_until: tx.valid_until, account: tx.from, nonce: tx.nonce, chain_id: tx.chain_id, genesis_hash: tx.genesis_hash });
52
221
  }
53
222
  catch {
54
- /* no spend yet */
223
+ /* informational only: tx_status then says valid_until is unknown */
224
+ }
225
+ const inFlight = (status, why) => ({
226
+ status,
227
+ tx_id: id,
228
+ valid_until: tx.valid_until,
229
+ counted_toward_daily_limit: counted,
230
+ next: `${why} It can still execute until block ${tx.valid_until}, so it already counts toward the daily limit. Do NOT send it again: check it with tx_status (tx_id ${id}) and tell the user it isn't confirmed yet.`,
231
+ explorer: explorer(`tx/${id}`),
232
+ });
233
+ try {
234
+ await w.client.submit(tx, false);
235
+ }
236
+ catch (e) {
237
+ // The node says it refused it. An honest node then never relayed it, but a lying one can say
238
+ // so and relay it anyway, so the reservation stays: the next tx this MCP signs with the same
239
+ // nonce (after a real refusal, the node's next nonce didn't move) takes the slot over.
240
+ if (e instanceof ApiError && e.status >= 400 && e.status < 500) {
241
+ throw new Error(`${e.message} (the node refused tx ${id}; it still counts toward today's limit until another tx reuses its nonce ${tx.nonce}, since a node could refuse a tx and relay it anyway)`);
242
+ }
243
+ // Admitted, but the first read of its status failed: wait for it below like any other.
244
+ if (!(e instanceof TxPendingError))
245
+ return inFlight("unknown", `The node didn't answer clearly (${clean(String(e?.message ?? e), 200)}), so the tx (${id}) may or may not have arrived.`);
246
+ }
247
+ try {
248
+ const done = await w.client.waitForTx(id, TX_WAIT_MS, tx.valid_until);
249
+ // Only a whole number of drops below the max fee gives anything back (audit gap-7-4).
250
+ const charged = /^\d+$/.test(String(done.fee ?? "")) ? BigInt(done.fee) : maxFee;
251
+ if (charged < maxFee)
252
+ LEDGER.release(reservation, maxFee - charged);
253
+ return done;
254
+ }
255
+ catch (e) {
256
+ // Dropped from this node's mempool unrun: nothing was charged, but it isn't dead (audit review-fix-clients-2).
257
+ if (e instanceof TxFailedError && e.dropped)
258
+ return inFlight("dropped", `This node dropped tx ${id} from its mempool without running it (${clean(e.reason, 200)}), so nothing was charged yet, but another node may still include it.`);
259
+ if (e instanceof TxFailedError)
260
+ throw new Error(`tx ${id} failed${typeof e.tx?.height === "number" ? ` in block ${e.tx.height}` : ""}: ${clean(e.reason, 300)}. It ran, so its fee was charged, and it stays counted toward the daily limit (${counted}).`);
261
+ return inFlight("pending", `Submitted (tx ${id}), but not final after ${Math.round(TX_WAIT_MS / 1000)} s.`);
55
262
  }
56
- return { day, spent: 0n };
57
263
  }
58
- const server = new McpServer({ name: "blockula", version: VERSION });
59
- const text = (v) => ({ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) }] });
264
+ /**
265
+ * What opening `to`'s account would burn on top of a transfer (0 if it exists). `/api/account`
266
+ * answers for any address, with a zero balance and the address's own "original key" when it has
267
+ * no account, so an account counts as existing only by what a missing one can't have: a balance
268
+ * or a nonce. An existing account with neither is counted as new: the cap errs high, never low.
269
+ */
270
+ async function openingFee(client, to) {
271
+ let a = null;
272
+ try {
273
+ a = await client.account(to);
274
+ }
275
+ catch (e) {
276
+ if (!(e instanceof ApiError && e.status === 404))
277
+ throw e;
278
+ }
279
+ const exists = (/^\d+$/.test(String(a?.balance ?? "")) && BigInt(a.balance) > 0n) || (Number.isSafeInteger(a?.nonce) && a.nonce > 0);
280
+ return exists ? 0n : reported((await client.fees()).account_creation_fee, "the account creation fee");
281
+ }
282
+ /** A drops amount the node reports, which must be there for a spend to be priced. */
283
+ function reported(v, what) {
284
+ if ((typeof v === "string" && /^\d+$/.test(v)) || (typeof v === "number" && Number.isSafeInteger(v) && v >= 0))
285
+ return BigInt(v);
286
+ throw new Error(`the node doesn't report ${what}, so the spend can't be checked against the spending limits; refusing`);
287
+ }
288
+ /**
289
+ * A name's registration price and yearly renewal price, computed here from the chain's
290
+ * parameters (node/src/params.rs `name_price`, `name_renewal_price`) and never below what
291
+ * `/api/name` quotes: the caps count the higher. Missing prices refuse the spend.
292
+ */
293
+ async function namePrices(client, name) {
294
+ const n = bareName(name);
295
+ const [params, info] = await Promise.all([client.params().then(paramsOf), client.name(n)]);
296
+ const key = n.length <= 3 ? "name_price_3" : n.length === 4 ? "name_price_4" : "name_price_5";
297
+ const own = reported(params[key], `the name price parameter ${key}`);
298
+ const ownRenewal = (own * reported(params.name_renewal_bps, "the name_renewal_bps parameter")) / 10000n;
299
+ const quoted = reported(info?.price, "the name's price");
300
+ const quotedRenewal = reported(info?.renewal_price, "the name's renewal price");
301
+ return { price: quoted > own ? quoted : own, renewal: quotedRenewal > ownRenewal ? quotedRenewal : ownRenewal, info };
302
+ }
303
+ /**
304
+ * A name or address resolved for a field that needs an address (staking's validator, the account
305
+ * use_account acts for; audit gap-7-1). With BLOCKULA_WITNESSES a name is proven against them
306
+ * (`Client.resolution`). Without, it's the node's word: on mainnet that's not enough to sign real
307
+ * money to, so the tool answers with the address for the user to confirm instead (the agent then
308
+ * passes the address); elsewhere it signs and says it's unproven. Payments don't come here: they
309
+ * sign the name (`Client.recipient`), which the chain resolves itself.
310
+ */
311
+ async function resolved(client, input, role) {
312
+ const res = await client.resolution(input);
313
+ if (!res.proven && (await realMoney(client))) {
314
+ return {
315
+ confirm: {
316
+ status: "not_sent",
317
+ needs_confirmation: true,
318
+ [role]: clean(input, 100),
319
+ resolves_to: res.address,
320
+ resolved_by: clean(res.how, 300),
321
+ next: `Nothing was signed. This is real money, and ${clean(input, 100)} was resolved by one node without proof: a node can point a name anywhere. Show the user the address ${res.address}; only if they confirm it's the right ${role}, call this tool again with that address instead of the name. (With BLOCKULA_WITNESSES configured, names are proven and this step isn't needed.)`,
322
+ },
323
+ };
324
+ }
325
+ return { res };
326
+ }
327
+ /** What a tool reports about a resolved name: the exact address signed and how it was found. */
328
+ const resolvedView = (res, role) => (res.name === null ? {} : { [`${role}_address`]: res.address, name_resolution: res.proven ? clean(res.how, 300) : `UNPROVEN: ${clean(res.how, 300)}` });
329
+ /** What a tool reports about a recipient the chain resolves: what was signed (a proven address, or the name itself) and how. */
330
+ const recipientView = (r, role) => r.name === null ? {} : r.byChain ? { [`${role}_signed_as`]: r.signed, [`${role}_owner_now_unproven`]: r.address, name_resolution: clean(r.how, 400) } : { [`${role}_address`]: r.address, name_resolution: clean(r.how, 300) };
331
+ /** A tx tool's answer: the tx's summary and links, or the in-flight notice. */
332
+ const txResult = (r, extra = {}) => isInFlight(r) ? r : { summary: clean(String(r.summary ?? "")), fee: r.fee_display, tx_id: r.id, block: r.height, ...extra, explorer: explorer(`tx/${r.id}`) };
333
+ const server = new McpServer({ name: "blockula", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
334
+ const text = (v) => ({ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toString() : x), 2) }] });
60
335
  function readWalletFile() {
61
336
  return existsSync(WALLET_FILE) ? JSON.parse(readFileSync(WALLET_FILE, "utf8")) : null;
62
337
  }
63
- function loadWallet() {
338
+ async function loadWallet() {
64
339
  const f = readWalletFile();
65
340
  if (!f)
66
341
  return null;
67
- const w = Wallet.fromSeed(f.seed, client);
342
+ const w = Wallet.fromSeed(f.seed, await chain());
68
343
  if (f.account)
69
344
  w.account = f.account;
70
345
  return w;
71
346
  }
72
- function requireWallet() {
73
- const w = loadWallet();
347
+ async function requireWallet() {
348
+ const w = await loadWallet();
74
349
  if (!w)
75
350
  throw new Error("No agent wallet yet — call create_wallet first.");
76
351
  return w;
@@ -81,34 +356,288 @@ function tool(name, description, shape, run) {
81
356
  return text(await run(args));
82
357
  }
83
358
  catch (e) {
84
- const msg = e.message;
85
- const hint = /fetch failed|ECONNREFUSED/.test(msg) ? ` (no node at ${RPC} — call start_dev_node)` : "";
359
+ // Node errors are relayed, so they're cleaned like any chain text.
360
+ const msg = clean(String(e?.message ?? e), 1000);
361
+ const hint = /fetch failed|ECONNREFUSED/.test(msg)
362
+ ? ` (no node at ${RPC}${NET?.key === "local" ? " — call start_dev_node" : ""})`
363
+ : e instanceof RequestTimeoutError
364
+ ? ` (the node at ${RPC} isn't answering)`
365
+ : e instanceof HeightAheadError
366
+ ? ` (the chain's genesis clock is built in, or remembered in ${KNOWN_CHAINS.file} from the first /api/genesis read for this chain)`
367
+ : "";
86
368
  return { ...text(`Error: ${msg}${hint}`), isError: true };
87
369
  }
88
370
  }));
89
371
  }
90
- tool("status", "Blockula network status: which node you're connected to, chain id, height, supply, fees burned, and the crypto in use.", {}, async () => ({
91
- rpc: RPC,
92
- network: RPC === TESTNET_RPC ? "public testnet" : RPC === LOCAL_RPC ? "local node" : "custom node",
93
- explorer: RPC,
94
- mcp_version: VERSION,
95
- ...(await client.status()),
96
- }));
97
- tool("tx_status", "Check a transaction by id: pending, finalized (with block), and its plain-English summary.", { tx_id: z.string().regex(/^[0-9a-f]{64}$/) }, async ({ tx_id }) => {
98
- const tx = await client.tx(tx_id);
99
- return { status: tx.status, summary: tx.summary, block: tx.height, fee: tx.fee_display, explorer: `${RPC}/#/tx/${tx_id}` };
100
- });
101
- tool("start_dev_node", "Start a local Blockula dev chain (faucet + built-in explorer) and switch to it. Needs the `blockula` binary; without it, keep using the public testnet, which supports everything except running your own chain.", { reset: z.boolean().optional().describe("wipe the dev chain first") }, async ({ reset }) => {
102
- const target = process.env.BLOCKULA_RPC ?? LOCAL_RPC;
372
+ tool("status", "Blockula network status: which network and node you're connected to (and the chain it's pinned to), chain id, height, supply, fees burned, and the crypto in use. The network, its pins and how to change them are this MCP's own facts; `node_reports` is what the node says.", {}, async () => {
373
+ const net = network();
374
+ const client = await chain();
375
+ const s = await client.status();
376
+ // The node's report is nested and picked field by field: it can't override (or add to) what
377
+ // this MCP states about the network (audit F73).
378
+ const pick = ["chain_id", "height", "finalized_height", "syncing", "epoch", "active_validators", "supply_display", "burned_display", "version", "protocol_version", "head_age_ms", "crypto"];
379
+ return {
380
+ network: net.label,
381
+ real_money: net.mainnet || s.chain_id === MAINNET_CHAIN_ID,
382
+ rpc: net.rpc,
383
+ pinned: chainData({ chain_id: s.chain_id, genesis_hash: s.genesis_hash, address_prefix: s.address_prefix }),
384
+ how_to_change: "The network is fixed while this MCP runs. The user changes it in the MCP's configuration (--network mainnet|testnet|local|custom, or BLOCKULA_RPC).",
385
+ explorer: net.rpc,
386
+ mcp_version: VERSION,
387
+ node_reports: chainData(Object.fromEntries(pick.filter((k) => k in s).map((k) => [k, s[k]]))),
388
+ };
389
+ });
390
+ const safeInt = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : undefined);
391
+ /**
392
+ * Reads the head and the account's nonce for a tx this MCP signed (audit review-r3-wallet-1): at
393
+ * most one tx per (account, nonce) executes, so an executed nonce past the tx's means it (or a tx
394
+ * that took its nonce) ran, and it can never run again; a nonce still unused at a block past
395
+ * valid_until means it can never run at all. The status is read first, so a nonce read after a
396
+ * head past valid_until can't miss this tx running. With BLOCKULA_WITNESSES the nonce is proven
397
+ * (as of the proof's block), not this node's word.
398
+ */
399
+ async function slotFate(client, id, mine) {
400
+ const fate = {};
401
+ try {
402
+ const st = await client.status();
403
+ [fate.head, fate.historyFrom, fate.maxValidity] = [safeInt(st.height), safeInt(st.history_from), safeInt(st.max_tx_validity_blocks)];
404
+ }
405
+ catch {
406
+ /* unknown: answered conservatively below */
407
+ }
408
+ if (client.witnesses.length) {
409
+ try {
410
+ if (!isAddress(mine.account))
411
+ throw new Error(`${mine.account} isn't an address`);
412
+ const r = await client.provenRecord(NAMESPACES.ACCOUNTS, mine.account, { witnesses: client.witnesses });
413
+ // No record: provably no account, so nothing from it ever ran.
414
+ fate.accountNonce = r.value === null ? 0 : decodeAccount(r.value).nonce;
415
+ fate.proven = { height: r.height, witnesses: client.witnesses.length };
416
+ fate.head = Math.max(fate.head ?? 0, r.head);
417
+ }
418
+ catch (e) {
419
+ fate.proofError = clean(String(e?.message ?? e), 200);
420
+ }
421
+ }
422
+ else {
423
+ try {
424
+ fate.accountNonce = safeInt((await client.account(mine.account)).nonce);
425
+ }
426
+ catch {
427
+ /* unknown */
428
+ }
429
+ }
430
+ if (fate.accountNonce !== undefined && fate.accountNonce > mine.nonce) {
431
+ // The first read (that didn't find it in a block) predates the nonce read: the block it ran in
432
+ // may have executed in between. Only a read after the nonce read can say it's in none of the
433
+ // blocks this node holds.
434
+ try {
435
+ const t = await client.tx(id);
436
+ if (t.status === "finalized" || (t.status === "failed" && !txDropped(t))) {
437
+ fate.recheck = { ran: t };
438
+ return fate;
439
+ }
440
+ if (t.status === "failed")
441
+ fate.recheck = "absent";
442
+ }
443
+ catch (e) {
444
+ if (e instanceof ApiError && e.status === 404)
445
+ fate.recheck = "absent";
446
+ }
447
+ for (const other of SIGNED.sameSlot(mine, id).slice(-3)) {
448
+ const t = await client.tx(other).catch(() => null);
449
+ if (t && (t.status === "finalized" || (t.status === "failed" && !txDropped(t)))) {
450
+ fate.sibling = { id: other, status: String(t.status), ...(safeInt(t.height) !== undefined ? { block: safeInt(t.height) } : {}) };
451
+ break;
452
+ }
453
+ }
454
+ }
455
+ return fate;
456
+ }
457
+ /**
458
+ * tx_status's answer for a tx this MCP signed that the node doesn't show in a block: `how` says
459
+ * whether the node doesn't know it at all (`unknown`: pruned, never held, or never reached it) or
460
+ * remembers dropping it unrun (`dropped`). Says it's in flight only while the head is at or below
461
+ * its valid_until and its nonce is unused; never "isn't confirmed" for a nonce that executed.
462
+ */
463
+ function afterTheFact(how, tx_id, mine, fate) {
464
+ const vu = mine.valid_until;
465
+ const facts = {
466
+ tx_id,
467
+ account: mine.account,
468
+ nonce: mine.nonce,
469
+ valid_until: vu,
470
+ ...(fate.head !== undefined ? { node_height: fate.head } : {}),
471
+ ...(fate.accountNonce !== undefined ? { account_nonce: fate.accountNonce } : {}),
472
+ ...(fate.proven ? { account_nonce_proven_at: fate.proven.height, proven_by_witnesses: fate.proven.witnesses } : {}),
473
+ ...(fate.proofError ? { proof_error: fate.proofError } : {}),
474
+ ...(fate.historyFrom !== undefined ? { history_from: fate.historyFrom } : {}),
475
+ };
476
+ const seen = how === "dropped" ? "This node dropped it from its mempool without running it" : `${RPC} doesn't know this tx`;
477
+ const proof = fate.proven ? ` (proven at block ${fate.proven.height}: its state root confirmed by ${fate.proven.witnesses} other node${fate.proven.witnesses === 1 ? "" : "s"})` : "";
478
+ // The block the nonce was read at: the proof's, else (unproven) the node's head, read just before.
479
+ const nonceAt = fate.proven ? fate.proven.height : fate.head;
480
+ if (fate.accountNonce !== undefined && fate.accountNonce > mine.nonce) {
481
+ // Its nonce was used: by this tx (through another node, or in a block this node no longer
482
+ // holds) or by another one. Either way it's final: it can never execute again.
483
+ let which;
484
+ // With witnesses only the nonce is proven: which tx used it (a sibling's status, a 404 and
485
+ // history_from) is still this node's word (audit review-r4-wallet-4).
486
+ const nodeWord = fate.proven ? " Only the nonce is proven: which tx used it is this node's word." : "";
487
+ if (fate.sibling) {
488
+ which = `This MCP signed another tx with the same nonce, ${fate.sibling.id}, which this node shows ${fate.sibling.status === "finalized" ? "finalized" : "failed (its fee was charged)"}${fate.sibling.block !== undefined ? ` in block ${fate.sibling.block}` : ""}: that one used the nonce, so this one never ran and nothing was charged for it. Check that tx (tx_status ${fate.sibling.id}), not this one.${nodeWord}`;
489
+ }
490
+ else {
491
+ const earliest = fate.maxValidity !== undefined ? Math.max(1, vu - fate.maxValidity) : 1;
492
+ const holdsAll = fate.historyFrom !== undefined && fate.historyFrom <= earliest;
493
+ // "In none of them" needs a read after the nonce read that still didn't find it in a block.
494
+ const lacks = !holdsAll || fate.recheck !== "absent";
495
+ which = lacks
496
+ ? `It may have been this one${how === "dropped" ? ", included by another node after this one dropped it" : holdsAll ? ": asked again after its nonce was read, this node gave no clear answer" : `: this node ${fate.historyFrom !== undefined ? `only holds blocks from #${fate.historyFrom} on` : "may not hold the block it ran in"}, so it may have pruned or never held it`}. Look it up on an archive node (or in ${mine.account}'s history) to see whether this one ran and whether it succeeded.`
497
+ : `This node says it holds every block it could have run in (from #${fate.historyFrom}) and ${how === "dropped" ? "shows it in none of them" : "has no record of it"}, so most likely another tx used that nonce and this one never ran; confirm on another node (or in ${mine.account}'s history).${nodeWord}`;
498
+ }
499
+ return {
500
+ status: "nonce_used",
501
+ ...facts,
502
+ next: `${seen}, but ${mine.account}'s nonce ${mine.nonce} has been used (the account has run ${fate.accountNonce} txs${proof}), so this tx can never execute again: it already ran, or another tx with the same nonce took its place. ${which} Do NOT send it again until you know whether it was this one: if it was, the payment already went through. Tell the user exactly that, not that it isn't confirmed.`,
503
+ };
504
+ }
505
+ if (nonceAt !== undefined && nonceAt > vu && fate.accountNonce !== undefined) {
506
+ return {
507
+ status: "expired",
508
+ ...facts,
509
+ next: fate.proven
510
+ ? `${seen}, and at block ${nonceAt}, past its valid_until (${vu}), ${mine.account}'s nonce ${mine.nonce} was still unused${proof}: it never ran, can never execute, and nothing was charged for it. It stays counted toward today's limit.`
511
+ : `${seen}, and says the chain is at block ${nonceAt}, past its valid_until (${vu}), with ${mine.account}'s nonce ${mine.nonce} still unused: if that's right, it can never execute and nothing was charged. It stays counted toward today's limit; check the height and the account's nonce with another node (or configure BLOCKULA_WITNESSES) before signing it again.`,
512
+ };
513
+ }
514
+ if (fate.head !== undefined && fate.head <= vu && (fate.accountNonce !== undefined || (how === "dropped" && !fate.proofError))) {
515
+ const inFlight = how === "dropped" ? "This node dropped it from its mempool without running it, so nothing was charged, but another node" : `${RPC} doesn't know this tx, but this MCP signed it, and a node that holds it`;
516
+ return {
517
+ status: how,
518
+ ...(how === "dropped" ? { dropped: true } : {}),
519
+ ...facts,
520
+ next: `${inFlight} may still include it until block ${vu} (the chain is at block ${fate.head}). It still counts toward the daily limit. Do NOT send it again before then: check tx_status again, and tell the user it isn't confirmed yet.`,
521
+ };
522
+ }
523
+ // The node (or the witnesses) didn't say enough: the height, or the account's nonce once past valid_until.
524
+ const missing = fate.head === undefined
525
+ ? "the chain's height"
526
+ : fate.proofError
527
+ ? `a proven nonce for ${mine.account} (${fate.proofError})`
528
+ : fate.accountNonce !== undefined
529
+ ? `${mine.account}'s nonce as of a block past its valid_until (the proof is from block ${nonceAt}, the chain at block ${fate.head})`
530
+ : `whether ${mine.account}'s nonce ${mine.nonce} was used`;
531
+ return {
532
+ status: how,
533
+ ...(how === "dropped" ? { dropped: true } : {}),
534
+ ...facts,
535
+ next: `${seen}, and this MCP couldn't read ${missing}, so it may still be in flight (until block ${vu}) or have run already. Do NOT send it again: check tx_status again (or ask another node), and tell the user it isn't known yet whether it went through.`,
536
+ };
537
+ }
538
+ /** A tx the node shows in a block: finalized, or failed (it ran, so its fee was charged). Final either way. */
539
+ function inBlock(tx, link) {
540
+ const status = String(tx.status);
541
+ const summary = clean(String(tx.summary ?? ""));
542
+ const error = tx.error ? { error: clean(String(tx.error)) } : {};
543
+ if (status === "finalized")
544
+ return { status, summary, ...error, block: tx.height, fee: tx.fee_display, explorer: link };
545
+ return { status, summary, ...error, block: tx.height, fee: tx.fee_display, next: `It ran${typeof tx.height === "number" ? ` in block ${tx.height}` : ""} and failed, so its fee was charged and nothing else happened. That's final.`, explorer: link };
546
+ }
547
+ /**
548
+ * A tx's status as the node reports it, read the way submitCapped reads it (audit
549
+ * review-fix-clients-2): "finalized" and "failed" (it ran in a block; its fee was charged) are
550
+ * final; "dropped" (this node dropped it from its mempool unrun: nothing charged) and "pending"
551
+ * aren't, since another node may still include it until its valid_until. For a tx this MCP
552
+ * signed, valid_until, account and nonce are what it signed (signed.ts), not the node's word, and
553
+ * one the node can't show is read against the head and the account's nonce (afterTheFact).
554
+ */
555
+ tool("tx_status", "Check a transaction by id: finalized (with its block), failed in a block (its fee was charged), pending, or dropped from this node's mempool without running (nothing charged, but another node may still include it until valid_until, so it must not be sent again before then), with its plain-English summary. For a tx this MCP signed that the node can't show: nonce_used (its nonce ran: it can never execute again, and may already have gone through), expired (past valid_until, nonce unused: it never ran), unknown/dropped (still in flight), or other_chain (signed for another network: ask that one).", { tx_id: z.string().regex(/^[0-9a-f]{64}$/) }, async ({ tx_id }) => {
556
+ const client = await chain();
557
+ // A record from another network (BLOCKULA_HOME is shared by all of them) says nothing about this
558
+ // one: its account's nonce and this chain's head are another chain's (audit review-r4-wallet-3).
559
+ const record = SIGNED.get(tx_id);
560
+ const mine = record && onChain(record, pinnedChain) && isAddress(record.account, pinnedChain.prefix) ? record : undefined;
561
+ const link = explorer(`tx/${tx_id}`);
562
+ const until = (v) => (v === undefined ? "its valid_until (unknown here: this MCP didn't sign it)" : `block ${v}`);
563
+ let tx;
564
+ try {
565
+ tx = await client.tx(tx_id);
566
+ }
567
+ catch (e) {
568
+ // This node doesn't know it: pruned or never held (it ran long ago), or never reached it.
569
+ if (e instanceof ApiError && e.status === 404 && mine) {
570
+ const fate = await slotFate(client, tx_id, mine);
571
+ // It ran in a block that executed after the first read: answered as what it is.
572
+ if (fate.recheck && fate.recheck !== "absent")
573
+ return inBlock(fate.recheck.ran, link);
574
+ return { ...afterTheFact("unknown", tx_id, mine, fate), explorer: link };
575
+ }
576
+ if (e instanceof ApiError && e.status === 404 && record) {
577
+ const signedFor = record.chain_id !== undefined ? `chain ${record.chain_id} (genesis ${record.genesis_hash})` : "a chain it didn't record (it was signed before this MCP kept that)";
578
+ return {
579
+ status: "other_chain",
580
+ tx_id,
581
+ account: record.account,
582
+ nonce: record.nonce,
583
+ valid_until: record.valid_until,
584
+ ...(record.chain_id !== undefined ? { signed_for_chain_id: record.chain_id, signed_for_genesis_hash: record.genesis_hash } : {}),
585
+ next: `This MCP signed it for ${signedFor}, not the network it's on now (${pinnedChain.chainId ?? "unknown"} at ${RPC}), which can't say whether it ran. Do NOT send it again, and don't tell the user it failed or never ran: ask a node of the network it was signed for (run the MCP on that network and call tx_status again).`,
586
+ explorer: link,
587
+ };
588
+ }
589
+ throw e;
590
+ }
591
+ const status = String(tx.status);
592
+ const summary = clean(String(tx.summary ?? ""));
593
+ const error = tx.error ? { error: clean(String(tx.error)) } : {};
594
+ const nodeUntil = Number.isSafeInteger(tx.valid_until) ? tx.valid_until : undefined;
595
+ const validUntil = mine?.valid_until ?? nodeUntil;
596
+ if (status === "finalized" || (status === "failed" && !txDropped(tx)))
597
+ return inBlock(tx, link);
598
+ if (status === "failed") {
599
+ // Dropped unrun: the height in the answer is where this node dropped it, not a block it ran in.
600
+ const at = typeof tx.height === "number" ? { dropped_at_height: tx.height } : {};
601
+ if (mine) {
602
+ const fate = await slotFate(client, tx_id, mine);
603
+ // Another node included it after this one dropped it, and this node has run that block since.
604
+ if (fate.recheck && fate.recheck !== "absent")
605
+ return inBlock(fate.recheck.ran, link);
606
+ const r = afterTheFact("dropped", tx_id, mine, fate);
607
+ return { ...r, ...(r.status === "dropped" ? { dropped: true } : {}), summary, ...error, ...at, explorer: link };
608
+ }
609
+ const head = await client.status().then((st) => safeInt(st.height)).catch(() => undefined);
610
+ const past = validUntil !== undefined && head !== undefined && head > validUntil;
611
+ const next = past
612
+ ? `This node dropped it without running it, and says the chain is at block ${head}, past its valid_until (${validUntil}): if that height is right, it can never execute and nothing was charged. It stays counted toward today's limit; check the height with another node before signing it again.`
613
+ : `This node dropped it from its mempool without running it, so nothing was charged, but another node may still include it until ${until(validUntil)}. It still counts toward the daily limit. Do NOT send it again before then: check tx_status again, and tell the user it isn't confirmed yet.`;
614
+ return { status: "dropped", dropped: true, summary, ...error, ...at, valid_until: validUntil ?? "unknown", ...(head !== undefined ? { node_height: head } : {}), next, explorer: link };
615
+ }
616
+ return {
617
+ status: clean(status),
618
+ summary,
619
+ ...error,
620
+ valid_until: validUntil ?? "unknown",
621
+ ...(status === "pending" ? { next: `Not final yet: it can still execute until ${until(validUntil)}. Do NOT send it again: check tx_status again.` } : {}),
622
+ explorer: link,
623
+ };
624
+ });
625
+ tool("start_dev_node", "Start a local Blockula dev chain (faucet + built-in explorer) on this machine. This MCP uses it only if it was configured for the local network (--network local); otherwise it keeps its network and tells you how to switch. Needs the `blockula` binary (install_node).", { reset: z.boolean().optional().describe("wipe the dev chain first") }, async ({ reset }) => {
626
+ const net = network();
627
+ const target = net.key === "local" ? net.rpc : "http://localhost:7070";
628
+ const using = net.key === "local" ? "This MCP is on the local network, so it uses it now." : `This MCP stays on the ${net.label} (${net.rpc}): the network never changes while it runs. To use the dev chain, the user restarts the MCP with --network local (or BLOCKULA_NETWORK=local).`;
103
629
  if (!reset && (await reachable(target))) {
104
- useRpc(target);
105
- return `Already running at ${target} (explorer: ${target})`;
630
+ // Asked for the dev chain explicitly: pin whichever one runs now (a reset makes a new genesis).
631
+ if (net.key === "local")
632
+ pinned = null;
633
+ return `A node is already running at ${target} (explorer: ${target}). ${using}`;
106
634
  }
107
635
  const port = new URL(target).port || "7070";
108
636
  mkdirSync(HOME, { recursive: true });
109
637
  const log = openSync(join(HOME, "dev-node.log"), "a");
110
638
  let missing = false;
111
- const child = spawn(BIN, ["dev", "--port", port, ...(reset ? ["--reset"] : [])], { detached: true, stdio: ["ignore", log, log] });
639
+ const bin = nodeBinary();
640
+ const child = spawn(bin.path, ["dev", "--port", port, ...(reset ? ["--reset"] : [])], { detached: true, stdio: ["ignore", log, log] });
112
641
  child.on("error", (e) => {
113
642
  missing = e.code === "ENOENT";
114
643
  });
@@ -116,93 +645,406 @@ tool("start_dev_node", "Start a local Blockula dev chain (faucet + built-in expl
116
645
  for (let i = 0; i < 40 && !missing; i++) {
117
646
  await new Promise((r) => setTimeout(r, 250));
118
647
  if (await reachable(target)) {
119
- useRpc(target);
120
- return `🧛 Dev chain is up. Explorer: ${target} · logs: ${join(HOME, "dev-node.log")}`;
648
+ // A reset dev chain has a new genesis: re-pin on the next call.
649
+ if (net.key === "local")
650
+ pinned = null;
651
+ return `🧛 Dev chain is up. Explorer: ${target} · logs: ${join(HOME, "dev-node.log")}. ${ranWith(bin)} ${using}`;
121
652
  }
122
653
  }
123
654
  if (missing) {
124
- throw new Error(`The \`${BIN}\` node binary isn't installed, so a local chain can't start. You're connected to ${RPC}${RPC === TESTNET_RPC ? " (the public testnet)" : ""}, which works for wallets, the faucet, names and sends. To run your own chain, build the node from the Blockula repo (cargo build --release) and set BLOCKULA_BIN.`);
655
+ throw new Error(`The \`${bin.path}\` node binary isn't installed, so a local chain can't start. Call install_node to download the prebuilt node (checksum-verified, plus provenance or signature checks where available), then start_dev_node again.`);
125
656
  }
126
657
  throw new Error(`node didn't start. See ${join(HOME, "dev-node.log")}`);
127
658
  });
659
+ tool("install_node", "Download the prebuilt `blockula` node + CLI for this machine from the official GitHub release and install it to ~/.blockula/mcp-bin (never over the binary `blockula release install` keeps in ~/.blockula/bin), after the same checks as scripts/install.sh: the release must be the version asked for (`latest` is resolved to its tag first), SHA-256 against the release checksums, build provenance from that tag with a logged-in GitHub CLI, and the offline ML-DSA-65 release signature. Once the release key id is known (pinned in ~/.blockula/release-key-id by `blockula release install`, built in, or BLOCKULA_RELEASE_KEY_ID, which must all agree), a release without a valid signature is refused. It says which checks passed; with BLOCKULA_REQUIRE_VERIFY=1 it also requires provenance and refuses a release it can only checksum. Needed for start_dev_node and summon_chain; nothing else needs it.", { version: z.string().regex(/^(latest|v\d+\.\d+\.\d+)$/).default("latest") }, async ({ version }) => {
660
+ const target = releaseTarget();
661
+ if (!target)
662
+ throw new Error(`No prebuilt node for ${process.platform}/${process.arch}. Build from source: https://github.com/${REPO}`);
663
+ // The operator's pin (written by `blockula release install`) binds this MCP too.
664
+ const pinFile = join(HOME, PIN_FILE);
665
+ const releaseKeyId = releaseKeyIdFrom(process.env.BLOCKULA_RELEASE_KEY_ID, RELEASE_KEY_ID, readReleasePin(pinFile), pinFile);
666
+ // Every check is bound to one tag: GitHub redirects /releases/latest to /releases/tag/<tag>.
667
+ const tag = version === "latest" ? latestTagFrom((await fetch(`https://github.com/${REPO}/releases/latest`, { method: "HEAD", redirect: "follow", signal: AbortSignal.timeout(60_000) })).url) : version;
668
+ const base = `https://github.com/${REPO}/releases/download/${tag}`;
669
+ const asset = `blockula-${target}.tar.gz`;
670
+ const tmp = mkdtempSync(join(tmpdir(), "blockula-"));
671
+ try {
672
+ const rel = await fetchVerifiedRelease(base, asset, { releaseKeyId, requireVerify: process.env.BLOCKULA_REQUIRE_VERIFY === "1", tag }, {
673
+ async download(url) {
674
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(300_000) });
675
+ if (res.status === 404)
676
+ return null;
677
+ if (!res.ok)
678
+ throw new Error(`download failed (${res.status}): ${url}`);
679
+ return Buffer.from(await res.arrayBuffer());
680
+ },
681
+ async attest(archive, name, tag) {
682
+ if (spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status !== 0)
683
+ return { result: "unavailable" };
684
+ const file = join(tmp, `attest-${name}`);
685
+ writeFileSync(file, archive);
686
+ const r = spawnSync("gh", ["attestation", "verify", file, "--repo", REPO, "--signer-workflow", `${REPO}/.github/workflows/release.yml`, "--source-ref", `refs/tags/${tag}`], { encoding: "utf8" });
687
+ return r.status === 0 ? { result: "ok" } : { result: "failed", detail: r.stderr || r.stdout };
688
+ },
689
+ });
690
+ writeFileSync(join(tmp, asset), rel.archive);
691
+ execFileSync("tar", ["-xzf", join(tmp, asset), "-C", tmp]);
692
+ // The binary must be the version asked for before it replaces anything.
693
+ const extracted = join(tmp, `blockula-${target}`, "blockula");
694
+ let printed = "";
695
+ try {
696
+ printed = execFileSync(extracted, ["--version"], { encoding: "utf8" });
697
+ }
698
+ catch {
699
+ // checkBinaryVersion reports it as unrunnable.
700
+ }
701
+ checkBinaryVersion(printed, tag);
702
+ // Atomically: a temporary file beside it, then a rename.
703
+ mkdirSync(dirname(INSTALLED_BIN), { recursive: true });
704
+ const staged = `${INSTALLED_BIN}.new-${process.pid}`;
705
+ try {
706
+ copyFileSync(extracted, staged);
707
+ chmodSync(staged, 0o755);
708
+ renameSync(staged, INSTALLED_BIN);
709
+ }
710
+ finally {
711
+ rmSync(staged, { force: true });
712
+ }
713
+ const installed = execFileSync(INSTALLED_BIN, ["--version"], { encoding: "utf8" }).trim();
714
+ const runs = nodeBinary();
715
+ const which = runs.path === INSTALLED_BIN ? "they run this one" : `they run ${runs.path}${runs.version ? ` (${runs.version})` : ""}, ${process.env.BLOCKULA_BIN ? "BLOCKULA_BIN" : "the binary of this MCP's version or `blockula release install`'s"}, not this one`;
716
+ return { installed, path: INSTALLED_BIN, sha256: rel.sha256, verified: rel.verified.length ? rel.verified : ["checksum only"], ...(rel.warning ? { warning: rel.warning } : {}), next: `start_dev_node runs a local chain; summon_chain creates your own network (${which})` };
717
+ }
718
+ finally {
719
+ rmSync(tmp, { recursive: true, force: true });
720
+ }
721
+ });
128
722
  tool("summon_chain", "Create a brand-new Blockula chain (fresh genesis + validator key) that you can run as your own network.", { chain_id: z.string().regex(/^[a-z0-9-]{3,40}$/), faucet: z.boolean().default(true) }, async ({ chain_id, faucet }) => {
129
- const out = execFileSync(BIN, ["summon", chain_id, ...(faucet ? [] : ["--no-faucet"])], { encoding: "utf8" });
130
- return out.trim();
723
+ const bin = nodeBinary();
724
+ const out = execFileSync(bin.path, ["summon", chain_id, ...(faucet ? [] : ["--no-faucet"])], { encoding: "utf8" });
725
+ return `${out.trim()}\n${ranWith(bin)}`;
131
726
  });
132
727
  tool("create_wallet", "Create this agent's post-quantum (ML-DSA-65) wallet, stored locally. Returns the existing one if it already exists.", {}, async () => {
133
- let w = loadWallet();
728
+ let w = await loadWallet();
134
729
  const existed = !!w;
135
730
  if (!w) {
136
- w = Wallet.generate(client);
731
+ w = Wallet.generate(await chain());
732
+ await w.client.addressPrefix();
137
733
  mkdirSync(HOME, { recursive: true });
138
734
  writeFileSync(WALLET_FILE, JSON.stringify({ address: w.address, key_id: w.keyId, seed: w.seedHex }, null, 2), { mode: 0o600 });
139
735
  }
736
+ await w.client.addressPrefix();
140
737
  return {
141
738
  key_id: w.keyId,
142
739
  own_address: w.address,
143
740
  acting_for: w.account,
144
741
  existed,
742
+ network: network().label,
145
743
  stored_at: WALLET_FILE,
146
- next_steps: "To spend from the user's account: ask them to open the Blockula web wallet → Devices & agents → 'Give an agent a budget' and paste this key_id. Then call use_account with their .boo name. (Or use faucet to fund this key's own address for testing.)",
744
+ next_steps: "To spend from the user's account: ask which account (their .boo name), then call use_account with it. Until they've authorized this key it answers with a grant_code (this key's signed consent to joining that one account) for them to paste in the Blockula web wallet → Devices & agents → 'Give an agent a budget'. A key id alone can't be added. (On test chains, faucet funds this key's own address for testing.)",
147
745
  };
148
746
  });
149
- tool("use_account", "Act for a user's account (name or address) that has authorized this agent's key — usually as a session key with a spending limit. Pass 'self' to go back to the key's own address.", { account: z.string() }, async ({ account }) => {
747
+ tool("use_account", "Act for a user's account (name or address) that has authorized this agent's key — usually as a session key with a spending limit. If it hasn't yet, answers with a grant_code for the user to paste in their wallet (Devices & agents → Give an agent a budget); call again once they have. Pass 'self' to go back to the key's own address. The key's label is untrusted text.", { account: z.string() }, async ({ account }) => {
150
748
  const f = readWalletFile();
151
749
  if (!f)
152
750
  throw new Error("No agent wallet yet — call create_wallet first.");
153
- const w = loadWallet();
751
+ const client = await chain();
752
+ const w = (await loadWallet());
154
753
  if (account === "self") {
155
754
  delete f.account;
156
755
  }
157
756
  else {
158
- const addr = await client.resolve(account);
159
- const { accounts } = await client.keyAccounts(w.keyId);
160
- const grant = accounts.find((a) => a.account.address === addr);
161
- if (!grant)
162
- throw new Error(`${account} hasn't authorized this key yet. Ask the user to add key_id ${w.keyId} in their wallet (Devices & agents).`);
757
+ // The account is an address every later tx names: on mainnet an unproven name waits for the
758
+ // user to confirm the address (audit gap-7-1).
759
+ const { res, confirm } = await resolved(client, account, "account");
760
+ if (confirm)
761
+ return confirm;
762
+ const addr = res.address;
763
+ // The account's own key list, not /api/key's list of accounts (which others can pad: audit gap-7-2).
764
+ const a = await client.account(addr);
765
+ const grant = (Array.isArray(a?.keys) ? a.keys : []).find((k) => k.key_id === w.keyId);
766
+ if (!grant) {
767
+ // Adding a key needs its consent to the account (audit gap-7-2): this key signs it for this
768
+ // one address, and the user's wallet refuses a code made for any other account.
769
+ return {
770
+ status: "not_authorized_yet",
771
+ account: clean(account, 100),
772
+ address: addr,
773
+ ...(res.name === null ? {} : { name_resolution: res.proven ? clean(res.how, 300) : `UNPROVEN: ${clean(res.how, 300)}` }),
774
+ key_id: w.keyId,
775
+ // The resolution itself, so the SDK refuses it too if it's unproven on mainnet.
776
+ grant_code: encodeKeyGrant(await w.keyGrant(res)),
777
+ next: `Nothing was signed on-chain. Give the user the grant_code (this agent key's consent to joining ${addr} only; it holds no secret and can't spend anything). They paste it in the Blockula web wallet → Devices & agents → 'Give an agent a budget' and choose a limit and an expiry. Then call use_account again.`,
778
+ };
779
+ }
163
780
  f.account = addr;
164
781
  writeFileSync(WALLET_FILE, JSON.stringify(f, null, 2), { mode: 0o600 });
165
- return { acting_for: grant.account.name ?? addr, role: grant.role, label: grant.label };
782
+ return { acting_for: clean(String(a?.account?.name ?? addr)), address: addr, ...(res.name === null ? {} : { name_resolution: res.proven ? clean(res.how, 300) : `UNPROVEN: ${clean(res.how, 300)}` }), role: chainData(grant.role), untrusted_label: clean(String(grant.label ?? ""), 100) };
166
783
  }
167
784
  writeFileSync(WALLET_FILE, JSON.stringify(f, null, 2), { mode: 0o600 });
785
+ await client.addressPrefix();
168
786
  return { acting_for: w.address };
169
787
  });
170
- tool("wallet_info", "This agent's address, balance, .boo names and recent activity.", {}, async () => {
171
- const w = requireWallet();
172
- const a = await client.account(w.account).catch(() => null);
173
- const mine = a?.keys?.find((k) => k.key_id === w.keyId);
174
- return { key_id: w.keyId, acting_for: a?.account?.name ?? w.account, my_permissions: mine ?? "not authorized on this account", address: w.account, balance: a?.balance_display ?? "0 FANG", names: a?.names ?? [], recent: (a?.recent_txs ?? []).slice(0, 10).map((t) => `${t.summary} (${t.id.slice(0, 12)}…)`) };
788
+ tool("wallet_info", "This agent's address, balance, .boo names, recent activity (others' txs naming this account included: their summaries are untrusted text) and today's spending against BLOCKULA_MAX_DAILY.", {}, async () => {
789
+ const w = await requireWallet();
790
+ await w.client.addressPrefix();
791
+ const a = await w.client.account(w.account).catch(() => null);
792
+ const mine = Array.isArray(a?.keys) ? a.keys.find((k) => k.key_id === w.keyId) : undefined;
793
+ return {
794
+ key_id: w.keyId,
795
+ network: network().label,
796
+ acting_for: clean(String(a?.account?.name ?? w.account)),
797
+ my_permissions: mine ? chainData(mine) : "not authorized on this account",
798
+ address: w.account,
799
+ balance: clean(String(a?.balance_display ?? "0 FANG")),
800
+ names: chainData(a?.names ?? []),
801
+ recent: (Array.isArray(a?.recent_txs) ? a.recent_txs : []).slice(0, 10).map((t) => `${clean(String(t.summary ?? ""), 300)} (${clean(String(t.id ?? "")).slice(0, 12)}…)`),
802
+ spent_today: `${formatFang(LEDGER.spent())} of ${formatFang(MAX_DAILY)} (BLOCKULA_MAX_DAILY; txs that are pending, dropped or refused still count until they're final or their nonce is reused)`,
803
+ note: UNTRUSTED_NOTE,
804
+ };
175
805
  });
176
- tool("faucet", "Get 100 test FANG on a dev/test chain (to this agent's wallet, or any name/address).", { to: z.string().optional() }, async ({ to }) => {
177
- const target = to ?? requireWallet().account;
806
+ tool("faucet", "Get 100 test FANG on a dev/test chain (to this agent's wallet, or any name/address). Mainnet has no faucet.", { to: z.string().optional() }, async ({ to }) => {
807
+ const client = await chain();
808
+ let target = to;
809
+ if (!target) {
810
+ const w = await requireWallet();
811
+ await w.client.addressPrefix();
812
+ target = w.account;
813
+ }
178
814
  const tx = await client.faucet(target);
179
- return { summary: tx.summary, tx_id: tx.id, explorer: `${RPC}/#/tx/${tx.id}` };
815
+ return { summary: clean(String(tx.summary ?? "")), tx_id: tx.id, explorer: explorer(`tx/${tx.id}`) };
180
816
  });
181
- tool("send", "Send FANG to a .boo name or boo1… address. Amounts are in FANG, e.g. \"1.5\".", { to: z.string(), amount: z.string(), memo: z.string().max(256).optional() }, async ({ to, amount, memo }) => {
817
+ tool("send", "Send FANG to a .boo name or address. Amounts are in FANG, e.g. \"1.5\". A name is signed as the name (the chain pays whoever owns it when the tx runs) unless BLOCKULA_WITNESSES proved its owner. Counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY (with the fee, and what opening a new account burns). If it comes back \"pending\", it was submitted: check tx_status, never send it again.", { to: z.string(), amount: z.string(), memo: z.string().max(256).optional() }, async ({ to, amount, memo }) => {
182
818
  const drops = parseFang(amount);
183
- if (drops > MAX_SEND)
184
- throw new Error(`amount exceeds this agent's per-send limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
185
- const today = spentToday();
186
- if (today.spent + drops > MAX_DAILY) {
187
- throw new Error(`this would exceed the agent's daily limit (BLOCKULA_MAX_DAILY=${process.env.BLOCKULA_MAX_DAILY ?? "100"} FANG; ${formatFang(today.spent)} already sent today)`);
188
- }
189
- const tx = await requireWallet().send(to, amount, { memo });
190
- mkdirSync(HOME, { recursive: true });
191
- writeFileSync(SPEND_FILE, JSON.stringify({ day: today.day, spent: (today.spent + drops + BigInt(tx.fee)).toString() }), { mode: 0o600 });
192
- return { summary: tx.summary, fee: tx.fee_display, tx_id: tx.id, block: tx.height, explorer: `${RPC}/#/tx/${tx.id}` };
819
+ if (drops <= 0n)
820
+ throw new Error("send more than 0");
821
+ const w = await requireWallet();
822
+ await w.client.addressPrefix();
823
+ // With witnesses a name's proven owner is signed; without, the name itself, which the chain
824
+ // resolves when the tx runs: a lying node can't point it at another account now, though one
825
+ // holding the signed tx could release it later in its validity window (audit gap-7-1,
826
+ // review-r3-clients-2).
827
+ const rc = await w.client.recipient(to);
828
+ const opening = await openingFee(w.client, rc.address);
829
+ const r = await submitCapped(w, { type: "transfer", to: rc.signed, amount: drops.toString() }, { amount: drops, extra: opening }, memo ?? "");
830
+ return isInFlight(r) ? { ...r, ...recipientView(rc, "recipient") } : txResult(r, recipientView(rc, "recipient"));
831
+ });
832
+ tool("check_name", "Check whether a .boo name is available and what it costs, or who owns it and when it expires.", { name: z.string() }, async ({ name }) => chainData(await (await chain()).name(name)));
833
+ tool("register_name", "Claim a .boo name for the account this agent acts for (3–32 chars, a-z 0-9 -). Shorter names cost more (the price is burned); names are registered for a year at a time. The price counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY. Session keys can't register names.", { name: z.string() }, async ({ name }) => {
834
+ const w = await requireWallet();
835
+ const n = bareName(name);
836
+ const { price } = await namePrices(w.client, n);
837
+ return txResult(await submitCapped(w, { type: "register_name", name: n }, { amount: price, what: "the name's price" }), { price: formatFang(price) });
193
838
  });
194
- tool("check_name", "Check whether a .boo name is available and what it costs.", { name: z.string() }, ({ name }) => client.name(name));
195
- tool("register_name", "Claim a .boo name for this agent's wallet (3–32 chars, a-z 0-9 -). Shorter names cost more.", { name: z.string() }, async ({ name }) => {
196
- const tx = await requireWallet().registerName(name);
197
- return { summary: tx.summary, tx_id: tx.id, explorer: `${RPC}/#/tx/${tx.id}` };
839
+ tool("renew_name", "Extend a .boo name the account this agent acts for owns, by 1–10 years (paid at the chain's renewal price per year, which is burned; counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY). Names that aren't renewed expire after a grace period and anyone can register them.", { name: z.string(), years: z.number().int().min(1).max(10).default(1) }, async ({ name, years }) => {
840
+ const w = await requireWallet();
841
+ const n = bareName(name);
842
+ const { renewal, info } = await namePrices(w.client, n);
843
+ const cost = renewal * BigInt(years);
844
+ return txResult(await submitCapped(w, { type: "renew_name", name: n, years }, { amount: cost, what: "the renewal" }), { cost: formatFang(cost), ...(typeof info?.expires_at === "number" ? { expired_at_before: info.expires_at } : {}) });
198
845
  });
199
- tool("lookup", "Look up anything — a .boo name, boo1… address, block number/hash or tx id — and get a plain-English explanation.", { query: z.string() }, async ({ query }) => {
846
+ const LOOKUP_TYPES = new Set(["block", "tx", "account", "name"]);
847
+ tool("lookup", `Look up anything — a .boo name, address, block number/hash or tx id — and get a plain-English explanation. ${UNTRUSTED_NOTE}`, { query: z.string() }, async ({ query }) => {
848
+ const client = await chain();
200
849
  const hit = await client.search(query);
201
- const data = hit.type === "block" ? await client.block(hit.id) : hit.type === "tx" ? await client.tx(hit.id) : hit.type === "account" ? await client.account(hit.id) : await client.name(hit.id);
202
- return { type: hit.type, explorer: `${RPC}/#/${hit.type}/${hit.id}`, ...data };
850
+ const type = String(hit?.type ?? "");
851
+ const id = String(hit?.id ?? "");
852
+ if (!LOOKUP_TYPES.has(type))
853
+ throw new Error(`nothing found for ${clean(query, 100)}`);
854
+ const data = type === "block" ? await client.block(id) : type === "tx" ? await client.tx(id) : type === "account" ? await client.account(id) : await client.name(id);
855
+ // The node's JSON is nested (it can't override `type` or `explorer`), cleaned and labeled.
856
+ return { type, explorer: explorer(`${type}/${encodeURIComponent(id)}`), data: chainData(data), note: UNTRUSTED_NOTE };
203
857
  });
204
858
  tool("latest_blocks", "The most recent blocks, summarized.", { limit: z.number().int().min(1).max(50).default(10) }, async ({ limit }) => {
205
- const blocks = await client.blocks(limit);
206
- return blocks.map((b) => `${b.summary} · ${b.hash.slice(0, 12)}…`);
859
+ const blocks = await (await chain()).blocks(limit);
860
+ return (Array.isArray(blocks) ? blocks : []).map((b) => `${clean(String(b.summary ?? ""), 200)} · ${clean(String(b.hash ?? "")).slice(0, 12)}…`);
861
+ });
862
+ // ---- staking ----
863
+ tool("stake", "Stake (delegate) FANG from the account this agent acts for in a validator's pool. Staked FANG earns a share of issuance, is slashed if the validator misbehaves, and takes the unbonding period (21 days on mainnet) to come back after unstake. Session keys can't stake. Counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY.", { validator: z.string().describe("validator address (or a .boo name its account holds)"), amount: z.string().describe("FANG, e.g. \"10\"") }, async ({ validator, amount }) => {
864
+ const drops = parseFang(amount);
865
+ if (drops <= 0n)
866
+ throw new Error("delegate more than 0");
867
+ const w = await requireWallet();
868
+ const { res, confirm } = await resolved(w.client, validator, "validator");
869
+ if (confirm)
870
+ return confirm;
871
+ const r = await submitCapped(w, { type: "delegate", validator: res.address, amount: drops.toString() }, { amount: drops, resolutions: [res] });
872
+ return isInFlight(r) ? { ...r, ...resolvedView(res, "validator") } : txResult(r, { ...resolvedView(res, "validator"), next: "my_stake shows the stake and its value" });
873
+ });
874
+ tool("unstake", "Start unbonding stake from a validator's pool: an amount in FANG (signed as that amount: the chain converts it to pool shares at its own price when the tx runs) or \"all\". It stops earning now and can be withdrawn with withdraw_stake once the unbonding period is over.", { validator: z.string(), amount: z.string().default("all").describe("FANG, or \"all\"") }, async ({ validator, amount }) => {
875
+ const drops = amount === "all" ? null : parseFang(amount);
876
+ if (drops !== null && drops <= 0n)
877
+ throw new Error("unstake more than 0 FANG, or \"all\"");
878
+ const w = await requireWallet();
879
+ const { res, confirm } = await resolved(w.client, validator, "validator");
880
+ if (confirm)
881
+ return confirm;
882
+ const v = res.address;
883
+ await w.client.addressPrefix();
884
+ const d = stakeSummary(await moduleRead("staking", w.client.staking(w.account))).delegations.find((x) => x.validator === v);
885
+ if (!d || BigInt(d.shares) === 0n)
886
+ throw new Error(`${w.account} has no stake with ${clean(validator, 100)}`);
887
+ // An amount is signed as an amount, and the chain converts it at the pool's price when it runs:
888
+ // converting it here took the price from this node's report of the stake, and one that said a
889
+ // 1,000 FANG stake was worth 10 FANG and a drop (or was 100× as many shares) turned "unstake
890
+ // 10" into unbonding all of it (audit gap-7-5). The node's value only words a refusal now.
891
+ if (drops !== null && drops > BigInt(d.value))
892
+ throw new Error(`the node reports this stake is worth ${formatFang(BigInt(d.value))}, less than the ${formatFang(drops)} asked for: nothing was signed (the chain refuses more than the stake is worth). To unbond all of it, pass amount "all".`);
893
+ // Only the fee leaves the account (it counts toward the daily limit).
894
+ const action = drops === null ? { type: "undelegate", validator: v, shares: d.shares } : { type: "undelegate", validator: v, shares: "0", amount: drops.toString() };
895
+ const view = drops === null
896
+ ? { ...resolvedView(res, "validator"), unbonding: `all ${d.shares} shares (the node reports they're worth ${formatFang(BigInt(d.value))})` }
897
+ : { ...resolvedView(res, "validator"), unbonding: `${formatFang(drops)} (at least: the chain sells the shares that much buys at the pool's price when the tx runs)` };
898
+ const r = await submitCapped(w, action, { amount: 0n, resolutions: [res] });
899
+ return isInFlight(r) ? { ...r, ...view } : txResult(r, view);
900
+ });
901
+ tool("withdraw_stake", "Pay out every unbonding entry of the account this agent acts for that has finished unbonding.", {}, async () => {
902
+ return txResult(await submitCapped(await requireWallet(), { type: "withdraw_unbonded" }, { amount: 0n }));
903
+ });
904
+ tool("my_stake", "The stake of the account this agent acts for: each pool (validator, shares, value), unbonding entries and when they complete, and what can be withdrawn now.", {}, async () => {
905
+ const w = await requireWallet();
906
+ await w.client.addressPrefix();
907
+ const [raw, status] = await Promise.all([moduleRead("staking", w.client.staking(w.account)), w.client.status()]);
908
+ const s = stakeSummary(raw);
909
+ return {
910
+ account: w.account,
911
+ height: status.height,
912
+ summary: s.summary,
913
+ staked: formatFang(BigInt(s.bonded)),
914
+ unbonding: formatFang(BigInt(s.unbonding_total)),
915
+ withdrawable_now: formatFang(BigInt(s.withdrawable)),
916
+ pools: s.delegations.map((d) => ({ validator: d.validator_name ?? d.validator, address: d.validator, shares: d.shares, value: formatFang(BigInt(d.value)), earned: formatFang(BigInt(d.value) > BigInt(d.principal) ? BigInt(d.value) - BigInt(d.principal) : 0n), ...(d.self_bond ? { self_bond: true } : {}) })),
917
+ unbonding_entries: s.unbonding.map((u) => ({ validator: u.validator, amount: formatFang(BigInt(u.amount)), completes_at_block: u.completes_at, ready: u.ready })),
918
+ ...(s.redelegations.length ? { redelegations: s.redelegations.map((r) => ({ from: r.from, to: r.to, amount: formatFang(BigInt(r.amount)), slashable_by_from_until_block: r.completes_at })) } : {}),
919
+ };
920
+ });
921
+ // ---- governance ----
922
+ /**
923
+ * A proposal as the agent sees it. `does` is rendered here from the proposal's kind, the content a
924
+ * vote binds (with its title and link): the node's own summary isn't covered by any digest, so a
925
+ * node could make it say anything (audit gap-7-6), and it isn't relayed. `proposal_digest` is what
926
+ * `vote` needs back: it refuses a proposal that reads differently by then.
927
+ */
928
+ const proposalView = (p) => ({
929
+ id: p.id,
930
+ untrusted_title: clean(p.title, 120),
931
+ does: clean(describeKind(p.kind, { freeText: false }), 600),
932
+ kind: chainData(p.kind),
933
+ ...(p.digest ? { proposal_digest: p.digest } : { proposal_digest: null, cannot_vote: clean(`${p.digest_error ?? "unknown content"}: vote with the blockula CLI, which reads it exactly`, 600) }),
934
+ status: clean(p.status, 40),
935
+ proposer: clean(p.proposer_name ?? p.proposer ?? "", 100),
936
+ aye_weight: formatFang(BigInt(p.aye)),
937
+ nay_weight: formatFang(BigInt(p.nay)),
938
+ turnout: formatFang(BigInt(p.turnout)),
939
+ ...(p.quorum !== undefined ? { quorum: formatFang(BigInt(p.quorum)), quorum_reached: p.quorum_reached } : {}),
940
+ ...(p.council.ayes || p.council.nays ? { council: `${p.council.ayes} validator${p.council.ayes === 1 ? "" : "s"} approve${p.council.nays ? `, ${p.council.nays} reject` : ""}${p.council.of ? ` (of ${p.council.of})` : ""}` } : {}),
941
+ ...(p.supermajority ? { needs: p.threshold_bps ? `${p.threshold_bps / 100}% of the weight` : "a supermajority" } : {}),
942
+ voting_ends_at_block: p.voting_ends_at,
943
+ ...(p.executes_at !== undefined ? { executes_at_block: p.executes_at } : {}),
944
+ ...(p.outcome ? { outcome: clean(p.outcome, 300) } : {}),
945
+ ...(p.url ? { untrusted_url: clean(p.url, 200) } : {}),
946
+ explorer: explorer(`proposal/${p.id}`),
947
+ });
948
+ tool("list_proposals", `On-chain governance proposals (parameter changes, treasury spends, upgrades, distributions …): what each does (\`does\`, rendered by this MCP from the proposal's \`kind\`, the content a vote binds: show it to the user with the title), its \`proposal_digest\` (pass it to vote), its status, token-weighted tallies, the bootstrap validator council's votes, and when voting ends and the timelock lets it execute. Titles, links and reasons are written by proposers. ${UNTRUSTED_NOTE}`, { status: z.string().optional().describe("e.g. \"voting\"; default: all, newest first"), limit: z.number().int().min(1).max(50).default(20) }, async ({ status, limit }) => {
949
+ const res = await moduleRead("governance proposals", (await chain()).proposals({ status, limit }));
950
+ const list = (Array.isArray(res.proposals) ? res.proposals : []);
951
+ return { proposals: list.slice(0, limit).map((p) => proposalView(proposalSummary(p, res.params))), note: UNTRUSTED_NOTE };
952
+ });
953
+ tool("vote", "Vote on a governance proposal for the account this agent acts for. Pass the proposal_digest list_proposals returned with what you showed the user: the vote binds exactly that content, and is refused (nothing signed) if the proposal reads differently now. `amount` FANG of its unlocked holdings is locked: conviction 0 locks nothing and counts 0.1×; 1–6 lock for 1, 2, 4, 8, 16 or 32 lock periods (a week each on mainnet) after voting ends and count 1×–6×. Voting again replaces the vote. Session keys can't vote. Ask the user before locking their FANG.", { proposal_id: z.number().int().min(0), proposal_digest: z.string().regex(/^[0-9a-f]{64}$/).describe("list_proposals' proposal_digest for this proposal: what the user was shown"), vote: z.enum(["aye", "nay"]), amount: z.string().describe("FANG to lock"), conviction: z.number().int().min(0).max(6).default(1) }, async ({ proposal_id, proposal_digest: shown, vote, amount, conviction }) => {
954
+ const drops = parseFang(amount);
955
+ if (drops <= 0n)
956
+ throw new Error("vote with more than 0");
957
+ // The amount is locked, not spent: it's held to BLOCKULA_MAX_SEND, and only the fee counts toward the daily limit.
958
+ const w = await requireWallet();
959
+ // The vote binds the content list_proposals showed (its digest, passed back here), not a fresh
960
+ // read: a node that showed one thing and answers another now is refused before anything is
961
+ // signed, and on chain the digest must match the stored proposal (audit gap-7-6).
962
+ const p = await w.client.proposal(proposal_id);
963
+ if (!p?.kind || typeof p.kind !== "object")
964
+ throw new Error(`the node doesn't report what proposal #${proposal_id} does, so the vote can't be bound to it`);
965
+ let proposal_digest;
966
+ try {
967
+ proposal_digest = proposalDigest(p.kind, String(p.title ?? ""), typeof p.url === "string" ? p.url : "");
968
+ }
969
+ catch (e) {
970
+ throw new Error(`proposal #${proposal_id} holds content this MCP can't read exactly (${clean(String(e.message), 200)}), so it can't bind a vote to it: vote with the blockula CLI`);
971
+ }
972
+ if (proposal_digest !== shown) {
973
+ throw new Error(`proposal #${proposal_id} doesn't read the way it did when it was shown (its digest is now ${proposal_digest}, not ${shown}): nothing was signed. The node may be showing different content to different reads. Call list_proposals again and show the user what it does now before voting.`);
974
+ }
975
+ const voted_on = { untrusted_title: clean(String(p.title ?? ""), 200), does: clean(describeKind(p.kind, { freeText: false }), 600), kind: chainData(p.kind), proposal_digest };
976
+ const r = await submitCapped(w, { type: "vote", proposal_id, aye: vote === "aye", amount: drops.toString(), conviction, proposal_digest }, { amount: 0n, perCall: drops, what: "the vote's amount" });
977
+ return isInFlight(r) ? { ...r, voted_on } : { summary: clean(String(r.summary ?? "")), fee: r.fee_display, tx_id: r.id, voted_on, explorer: explorer(`proposal/${proposal_id}`) };
978
+ });
979
+ // ---- Hunts and the contribution pool ----
980
+ tool("list_hunts", `Open bounties (Hunts) on GitHub issues, with FANG escrowed on-chain. Each links to an issue with acceptance criteria: implement it, open a PR, and the bounty is awarded on-chain when it merges. Titles are written by whoever created the hunt. ${UNTRUSTED_NOTE} ${NO_VALUE}`, { status: z.enum(["open", "awarded", "cancelled", "expired"]).default("open") }, async ({ status }) => {
981
+ const res = await (await chain()).hunts(status);
982
+ const hunts = (Array.isArray(res) ? res : Array.isArray(res?.hunts) ? res.hunts : []);
983
+ const funders = (f) => (Array.isArray(f) ? f.length : f && typeof f === "object" ? Object.keys(f).length : 0);
984
+ return {
985
+ hunts: hunts.slice(0, 100).map((h) => ({ hunt_id: Number(h.id), bounty: clean(String(h.amount_display ?? ""), 40), untrusted_title: clean(String(h.title ?? ""), 120), issue: clean(String(h.issue_url ?? ""), 200), funders: funders(h.funders), explorer: explorer(`hunt/${Number(h.id)}`) })),
986
+ note: `${UNTRUSTED_NOTE} ${NO_VALUE}`,
987
+ };
988
+ });
989
+ tool("link_github", "Link a GitHub handle to the account this agent acts for, so Hunt awards show up on its profile. First create a PUBLIC gist (as that GitHub user) containing the account's address, then pass the gist id here.", { handle: z.string().regex(/^[A-Za-z0-9-]{1,39}$/), gist_id: z.string().regex(/^[0-9a-f]{20,40}$/) }, async ({ handle, gist_id }) => {
990
+ const w = await requireWallet();
991
+ const r = await submitCapped(w, { type: "link_github", handle, gist_id }, { amount: 0n });
992
+ if (isInFlight(r))
993
+ return r;
994
+ const check = await w.client.github(handle).catch(() => null);
995
+ return { summary: clean(String(r.summary ?? "")), verified: check?.verified === true, explorer: explorer(`tx/${r.id}`) };
996
+ });
997
+ tool("fund_hunt", `Add FANG to an open Hunt's bounty (crowdfunding). Refunded to you if the hunt is cancelled. ${NO_VALUE}`, { hunt_id: z.number().int().min(0), amount: z.string() }, async ({ hunt_id, amount }) => {
998
+ const drops = parseFang(amount);
999
+ if (drops <= 0n)
1000
+ throw new Error("fund more than 0");
1001
+ const r = await submitCapped(await requireWallet(), { type: "fund_hunt", hunt_id, amount: drops.toString() }, { amount: drops });
1002
+ return isInFlight(r) ? r : { summary: clean(String(r.summary ?? "")), fee: r.fee_display, tx_id: r.id, explorer: explorer(`hunt/${hunt_id}`) };
1003
+ });
1004
+ tool("contribution_pool", `Blockula's contribution pool (Proof of Contribution): its balance, which GitHub repos earn from it, the rules, and the Séance (review jury) that's open now. Merged PRs to pool repos can be claimed for a share; payouts are judged by human juries, rate a sponsor's claims in a Séance together (one pooled win rate; each next a quarter of the one before), and vest over ~90 days. ${NO_VALUE}`, {}, async () => {
1005
+ const c = await (await chain()).contributions();
1006
+ return {
1007
+ pool: chainData(c.pool?.balance_display),
1008
+ next_seance_pays_up_to: chainData(c.pool?.next_budget_display),
1009
+ repos: chainData(c.repos),
1010
+ claims: chainData(c.claims),
1011
+ open_seance: c.open_seance ? { id: c.open_seance.id, phase: chainData(c.open_seance.phase), claims: Array.isArray(c.open_seance.claims) ? c.open_seance.claims.length : 0, jurors: Array.isArray(c.open_seance.jurors) ? c.open_seance.jurors.length : 0, reveals_until_block: c.open_seance.reveal_until } : null,
1012
+ rules: chainData(c.rules),
1013
+ how_to_earn: "Get a PR merged into a pool repo's default branch, authored by the GitHub handle linked to the account you act for (link_github), then submit_contribution. Validators check the merge on GitHub; a jury compares it with other claims.",
1014
+ note: NO_VALUE,
1015
+ explorer: explorer("contribute"),
1016
+ };
1017
+ });
1018
+ tool("submit_contribution", `Claim a merged PR for the contribution pool, for the account this agent acts for. The PR's author must be that account's linked GitHub handle. Costs a small refundable bond (lost if the jury marks it spam). If you act for a user through a session key, they must first register that key as a Familiar (wallet.registerFamiliar), or the chain refuses the claim; the claim then credits you as their Familiar. ${NO_VALUE}`, { repo: z.string().regex(/^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/), pr: z.number().int().min(1) }, async ({ repo, pr }) => {
1019
+ const w = await requireWallet();
1020
+ await w.client.addressPrefix();
1021
+ const me = await w.client.account(w.account).catch(() => null);
1022
+ // node/src/poc.rs `claim_bond`: CLAIM_BOND (1 FANG) per claim, more with open claims; never counted below it.
1023
+ const quoted = me?.contribution?.next_claim_bond;
1024
+ const bond = /^\d+$/.test(String(quoted ?? "")) && BigInt(quoted) > 1000000000n ? BigInt(quoted) : 1000000000n;
1025
+ const r = await submitCapped(w, { type: "submit_claim", repo, pr }, { amount: bond, what: `the claim bond (${formatFang(bond)})` });
1026
+ if (isInFlight(r))
1027
+ return r;
1028
+ const claims = await w.client.claims({ account: w.account }).catch(() => null);
1029
+ const claim = (Array.isArray(claims?.claims) ? claims.claims : []).find((c) => c.repo === repo.toLowerCase() && c.pr === pr);
1030
+ return { summary: clean(String(r.summary ?? "")), claim_id: claim?.id, status: claim ? clean(String(claim.status)) : undefined, next: "Validators check GitHub within a minute or two; then it waits for the revert window and the next Séance. Track it with my_contributions.", note: NO_VALUE, explorer: explorer(`claim/${claim?.id ?? ""}`) };
1031
+ });
1032
+ tool("my_contributions", `The Soul (reputation), contribution claims, vesting rewards, vouch and jury status of the account this agent acts for. A claim's untrusted_note is written by whoever judged or rejected it. ${NO_VALUE}`, {}, async () => {
1033
+ const w = await requireWallet();
1034
+ await w.client.addressPrefix();
1035
+ const a = await w.client.account(w.account);
1036
+ const p = a.contribution ?? {};
1037
+ return {
1038
+ account: clean(String(a.account?.name ?? a.account?.address ?? w.account)),
1039
+ soul: chainData(p.soul?.display),
1040
+ vouched_by: chainData(p.vouched_by?.voucher?.name ?? p.vouched_by?.voucher?.short ?? null),
1041
+ juror: chainData(p.juror),
1042
+ rewards: chainData({ withdrawable: p.rewards?.withdrawable_display, still_vesting: p.rewards?.unvested_display }),
1043
+ claims: (Array.isArray(p.claims) ? p.claims : []).map((c) => ({ id: c.id, pr: clean(`${c.repo}#${c.pr}`, 160), status: clean(String(c.status)), score: chainData(c.score_display), earned: chainData(c.payout_display), ...(c.reason ? { untrusted_note: clean(c.reason, 300) } : {}) })),
1044
+ next_claim_bond: chainData(p.next_claim_bond_display),
1045
+ note: `${UNTRUSTED_NOTE} ${NO_VALUE}`,
1046
+ };
207
1047
  });
1048
+ if (NET_ERROR)
1049
+ console.error(`blockula-mcp: ${NET_ERROR.message}`);
208
1050
  await server.connect(new StdioServerTransport());