@blockula/mcp 0.3.0 → 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.
package/dist/index.js CHANGED
@@ -1,127 +1,808 @@
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 testnet (default), local or custom (same as --network)
8
+ // BLOCKULA_RPC node URL (default: the network's public RPC; required for custom)
9
+ // BLOCKULA_CHAIN_ID / BLOCKULA_GENESIS_HASH pin the chain the node must be on (a node that says
10
+ // it's on blockula-1 is refused unless BLOCKULA_CHAIN_ID=blockula-1)
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 blockula-1 (BLOCKULA_CHAIN_ID=blockula-1) the address must be
18
+ // confirmed first. Unset, your own node of the public
19
+ // testnet (any node but its public RPC) gets the public RPC as a witness;
20
+ // `none` opts out.
7
21
  // 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: ~/.blockula/bin/blockula from install_node, else PATH)
11
- // When acting for a user's account via a session key, the chain also enforces that key's limit.
22
+ // BLOCKULA_MAX_SEND max FANG one call moves (sent, staked, escrowed, paid for a name, bonded; default 100)
23
+ // BLOCKULA_MAX_DAILY max FANG leaving the account per UTC day through this MCP on each chain, fees
24
+ // included (default BLOCKULA_MAX_SEND + 1 FANG for fees, so one full-size call fits)
25
+ // BLOCKULA_TIMEOUT_MS how long one request to the node may take (default 15000, at most 2147483647)
26
+ // BLOCKULA_TX_WAIT_MS how long a tx tool waits for finality before answering "pending" (default 15000)
27
+ // BLOCKULA_BIN path to the `blockula` binary (default: of ~/.blockula/bin/blockula from `blockula release
28
+ // install` and ~/.blockula/mcp-bin/blockula from install_node, the one of this MCP's version,
29
+ // the first if both or neither are; else PATH)
30
+ // BLOCKULA_REQUIRE_VERIFY=1 install_node needs provenance too (and refuses a checksum-only release)
31
+ // BLOCKULA_RELEASE_KEY_ID the release signing key id to trust (until it's built in; it must
32
+ // agree with the built-in one and with ~/.blockula/release-key-id, if pinned)
33
+ // The network never changes while the server runs (WAL-4): see network.ts. Spending caps: see
34
+ // spend.ts (reserved before a tx is sent) and budget.ts (the caps, and what's refused before
35
+ // signing). Chain text is untrusted: see untrusted.ts. When acting for a user's account via a
36
+ // session key, the chain also enforces that key's limit. The account acted for is kept per chain
37
+ // (accounts.ts), and one tx at a time is built per account (locks.ts).
12
38
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
39
  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 { createHash } from "node:crypto";
17
- import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
40
+ import { ApiError, Client, HeightAheadError, NAMESPACES, RequestTimeoutError, TxFailedError, TxPendingError, Wallet, addressPrefix, bareName, baseFee, decodeAccount, describeKind, encodeKeyGrant, isAddress, proposalDigest, formatFang, paramsOf, proposalSummary, stakeSummary, txDropped, txId } from "@blockula/sdk";
41
+ import { spawn, spawnSync, execFileSync } from "node:child_process";
42
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
43
  import { homedir, tmpdir } from "node:os";
19
- import { join } from "node:path";
44
+ import { dirname, join } from "node:path";
20
45
  import { z } from "zod";
21
- const LOCAL_RPC = "http://localhost:7070";
22
- const TESTNET_RPC = "https://blockula-testnet.fly.dev";
46
+ import { BLOCKULA_1, KNOWN_CLOCKS, KNOWN_GENESIS, KnownChains, clockFrom, completePin, defaultWitnesses, effectivePin, mismatch, mismatchError, networkLabel, resolveNetwork, unpinnedBlockula1, unusableChain } from "./network.js";
47
+ import { WALLET_URLS, actingFor, checkCode, createWalletFile, ignoredNote, newWalletFile, readWalletFile, secretOf, setActingFor, writeWalletFile } from "./accounts.js";
48
+ import { aboutBlocks, allowanceOf, amountArg, capsFrom, configError, dropsOf, keyOn, msFrom, preflight, spendableNow } from "./budget.js";
49
+ import { AccountLocks, NonceTracker } from "./locks.js";
50
+ import { PIN_FILE, REPO, RELEASE_KEY_ID, binPaths, checkBinaryVersion, fetchVerifiedRelease, latestTagFrom, pickNodeBinary, readReleasePin, releaseKeyIdFrom } from "./release.js";
51
+ import { SignedTxs, onChain } from "./signed.js";
52
+ import { SpendLedger, chainKey } from "./spend.js";
53
+ import { PROGRAM_NOTE, SERVER_INSTRUCTIONS, UNTRUSTED_NOTE, chainData, clean, codeView, programView, provenDifferences, viewView } from "./untrusted.js";
23
54
  const HOME = process.env.BLOCKULA_HOME ?? join(homedir(), ".blockula");
24
55
  const WALLET_FILE = join(HOME, "agent-wallet.json");
25
- const MAX_SEND = parseFang(process.env.BLOCKULA_MAX_SEND ?? "100");
26
- const MAX_DAILY = parseFang(process.env.BLOCKULA_MAX_DAILY ?? "100");
27
56
  const SPEND_FILE = join(HOME, "agent-spend.json");
57
+ /**
58
+ * The caps' and durations' variables, parsed once inside a try (AGENT-15): a bad one is reported by
59
+ * every tool, as a bad network is, instead of exiting before the client can be told why (the
60
+ * defaults stand in meanwhile, and nothing uses them).
61
+ */
62
+ const CONFIG_ERROR = configError(process.env);
63
+ const CAPS = capsFrom(CONFIG_ERROR ? {} : process.env);
64
+ const TIMEOUT_MS = CONFIG_ERROR ? 15_000 : msFrom(process.env, "BLOCKULA_TIMEOUT_MS", 15_000);
65
+ const TX_WAIT_MS = CONFIG_ERROR ? 15_000 : msFrom(process.env, "BLOCKULA_TX_WAIT_MS", 15_000);
66
+ const KNOWN_CHAINS = new KnownChains(join(HOME, "known-chains.json"));
28
67
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
29
- const REPO = "jackdishman/blockula";
30
- const INSTALLED_BIN = join(HOME, "bin", "blockula");
31
- let BIN = process.env.BLOCKULA_BIN ?? (existsSync(INSTALLED_BIN) ? INSTALLED_BIN : "blockula");
68
+ // install_node's binary has a path of its own: it never replaces the one `blockula release install`
69
+ // checked against the operator's pinned key (audit review-r3-integrate-compose-1). Which one runs is
70
+ // decided at each use, by version (nodeBinary).
71
+ const { mcp: INSTALLED_BIN, cli: CLI_BIN } = binPaths(HOME);
72
+ /** What a binary's `--version` prints, or undefined if it doesn't run. */
73
+ function versionOf(path) {
74
+ try {
75
+ return execFileSync(path, ["--version"], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /** The node binary to run now: BLOCKULA_BIN, else the installed one of this MCP's version (pickNodeBinary). */
82
+ function nodeBinary() {
83
+ if (process.env.BLOCKULA_BIN)
84
+ return { path: process.env.BLOCKULA_BIN };
85
+ return pickNodeBinary([CLI_BIN, INSTALLED_BIN].filter((p) => existsSync(p)).map((path) => ({ path, version: versionOf(path) })), VERSION);
86
+ }
87
+ /** Says which binary ran, and when it isn't this MCP's version. */
88
+ 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.` : "."}`;
89
+ /** Said wherever Hunts and the contribution pool come up: on every chain, FANG, Hunts and Soul have no value. */
90
+ const NO_VALUE = "FANG, Hunts and Soul have no value.";
32
91
  function releaseTarget() {
33
92
  const key = `${process.platform}-${process.arch}`;
34
93
  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;
35
94
  }
36
- async function download(url) {
37
- const res = await fetch(url, { redirect: "follow" });
38
- if (!res.ok)
39
- throw new Error(`download failed (${res.status}): ${url}`);
40
- return Buffer.from(await res.arrayBuffer());
95
+ // ---- the network: chosen once, pinned, never switched ----
96
+ let NET = null;
97
+ let NET_ERROR = null;
98
+ try {
99
+ NET = resolveNetwork(process.env, process.argv.slice(2));
100
+ }
101
+ catch (e) {
102
+ NET_ERROR = e;
103
+ }
104
+ const RPC = NET?.rpc ?? "";
105
+ /** A Hunt or contribution tool's description: `text`, then NO_VALUE. */
106
+ const described = (text) => `${text} ${NO_VALUE}`;
107
+ let pinned = null;
108
+ /** The chain `pinned` is held to (tx_status reads a signed tx's record only on the chain it was signed for). */
109
+ let pinnedChain = {};
110
+ /** The witnesses `pinned` checks names and heights against: configured, or the public RPCs for your own node. */
111
+ let activeWitnesses = [];
112
+ /** Why this MCP can't run as configured (the network, a cap or a duration), or null. */
113
+ const BAD_CONFIG = NET_ERROR ?? CONFIG_ERROR;
114
+ function network() {
115
+ if (!NET || BAD_CONFIG)
116
+ throw new Error(`The Blockula MCP isn't configured correctly: ${BAD_CONFIG?.message}`);
117
+ return NET;
118
+ }
119
+ /**
120
+ * The pinned chain as the ledger, the key file and the nonce tracker key it: `<chain_id>
121
+ * <genesis_hash>`. `chain()` refused a chain id or genesis hash that doesn't fit, so this is
122
+ * undefined only for a node that reports no genesis hash (and can't sign).
123
+ */
124
+ function chainNow() {
125
+ const { chainId, genesisHash } = pinnedChain;
126
+ try {
127
+ return chainId && genesisHash ? chainKey(chainId, genesisHash) : undefined;
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ }
133
+ /**
134
+ * The client for the configured network, pinned to its chain. The first call checks the node's
135
+ * chain id, genesis hash and prefix against the network's pins (and what was remembered for it);
136
+ * from then on the SDK re-checks every status, including the one every signature is made against.
137
+ */
138
+ async function chain() {
139
+ if (pinned)
140
+ return pinned;
141
+ const net = network();
142
+ const pin = effectivePin(net, KNOWN_CHAINS.get(net));
143
+ const status = await new Client(net.rpc, { timeoutMs: TIMEOUT_MS }).status();
144
+ // A chain id or genesis hash this MCP can't key its ledger, locks and grant files by is refused
145
+ // here, once, so nothing later has to check what a node named (audit MCK-3).
146
+ const unusable = unusableChain(status);
147
+ if (unusable)
148
+ throw mismatchError(net, unusable, KNOWN_CHAINS.file, false);
149
+ const why = mismatch(pin, status);
150
+ if (why)
151
+ throw mismatchError(net, why, KNOWN_CHAINS.file);
152
+ // blockula-1 only where the user pinned it: forgetting what was remembered wouldn't help.
153
+ const unpinned = unpinnedBlockula1(pin, status);
154
+ if (unpinned)
155
+ throw mismatchError(net, unpinned, KNOWN_CHAINS.file, false);
156
+ const full = completePin(pin, status);
157
+ KNOWN_CHAINS.save(net, full);
158
+ const pins = { chainId: full.chainId, genesisHash: full.genesisHash, prefix: full.prefix, timeoutMs: TIMEOUT_MS };
159
+ // Witnesses are held to the same chain; with them, names are proven (Client.resolution) and a
160
+ // height ahead of their heads is refused. The chain's genesis clock bounds the height too.
161
+ // Unset, your own node of a public network (its chain known only now, for a local or custom
162
+ // node) is checked against that network's public RPCs (OPERATOR-6).
163
+ const genesisClock = await clockOf(net, full);
164
+ const witnesses = net.witnessesSet || net.witnesses.length ? net.witnesses : defaultWitnesses(full.chainId, net.rpc);
165
+ pinned = new Client(net.rpc, { ...pins, genesisClock, witnesses: witnesses.map((w) => new Client(w, pins)) });
166
+ pinnedChain = { chainId: full.chainId, genesisHash: full.genesisHash, prefix: full.prefix };
167
+ activeWitnesses = witnesses;
168
+ return pinned;
169
+ }
170
+ /**
171
+ * The pinned chain's genesis clock, which bounds the height each tx is signed against
172
+ * (`Client.checkSigningHeight`: a node reporting a height the chain can't have reached by this
173
+ * machine's clock gets nothing signed, since it could hold the tx and release it later; audit
174
+ * review-r3-clients-2). Built in with a known genesis; else the one remembered for this chain id
175
+ * and genesis hash; else the node's `/api/genesis`, taken on first use like the genesis hash and
176
+ * remembered with it (but for local dev chains, for this session only). A node that serves none
177
+ * leaves the height its word, as without the clock.
178
+ */
179
+ async function clockOf(net, full) {
180
+ const { chainId, genesisHash } = full;
181
+ if (chainId === undefined || genesisHash === undefined)
182
+ return undefined;
183
+ if (KNOWN_GENESIS[chainId] === genesisHash && KNOWN_CLOCKS[chainId])
184
+ return KNOWN_CLOCKS[chainId];
185
+ const remembered = KNOWN_CHAINS.clock(net, chainId, genesisHash);
186
+ if (remembered)
187
+ return remembered;
188
+ const clock = clockFrom(await new Client(net.rpc, { timeoutMs: TIMEOUT_MS }).genesis().catch(() => undefined), chainId);
189
+ if (clock)
190
+ KNOWN_CHAINS.saveClock(net, chainId, genesisHash, clock);
191
+ return clock;
41
192
  }
42
193
  async function reachable(url) {
43
194
  try {
44
- await new Client(url).status();
195
+ await new Client(url, { timeoutMs: Math.min(TIMEOUT_MS, 5_000) }).status();
45
196
  return true;
46
197
  }
47
198
  catch {
48
199
  return false;
49
200
  }
50
201
  }
51
- // No configuration needed: use a local node if one is running, otherwise the public testnet.
52
- let RPC = process.env.BLOCKULA_RPC ?? ((await reachable(LOCAL_RPC)) ? LOCAL_RPC : TESTNET_RPC);
53
- let client = new Client(RPC);
54
- function useRpc(url) {
55
- RPC = url;
56
- client = new Client(url);
202
+ const explorer = (path) => `${RPC}/#/${path}`;
203
+ /** A protocol v1 read, with a plain answer on nodes whose chain doesn't have the module yet. */
204
+ async function moduleRead(what, read) {
205
+ try {
206
+ return await read;
207
+ }
208
+ catch (e) {
209
+ if (e instanceof ApiError && e.status === 404 && /404|not found/i.test(e.message))
210
+ throw new Error(`${RPC} doesn't serve ${what}: it isn't active on this chain yet`);
211
+ throw e;
212
+ }
213
+ }
214
+ // ---- spending caps (BLOCKULA_MAX_SEND per call, BLOCKULA_MAX_DAILY per UTC day and chain) ----
215
+ const LEDGER = new SpendLedger(SPEND_FILE, CAPS.maxDaily, CAPS.dailyText);
216
+ /** The txs this MCP signed (their valid_until, for tx_status): see signed.ts. */
217
+ const SIGNED = new SignedTxs(join(HOME, "agent-txs.json"));
218
+ /** One tx at a time per account, and the last nonce signed while it's pending: see locks.ts. */
219
+ const LOCKS = new AccountLocks(join(HOME, "nonces"));
220
+ const NONCES = new NonceTracker(join(HOME, "nonces"));
221
+ /**
222
+ * Whether this is blockula-1, where an address that was resolved from a name by one node isn't
223
+ * signed until the user confirms it (audit gap-7-1, R1 bypass): decided by the chain the client is
224
+ * pinned to, not by what the configuration calls the network, so BLOCKULA_RPC alone at a node on
225
+ * blockula-1 gets the same rule. The client is pinned to the chain id of its first status, and
226
+ * refuses any later status that reports another, so this is the chain every signature it makes is
227
+ * for.
228
+ */
229
+ async function onBlockula1(client) {
230
+ return (await client.status()).chain_id === BLOCKULA_1;
57
231
  }
58
- /** Running total of FANG sent today (UTC), persisted so the cap holds across calls and restarts. */
59
- function spentToday() {
60
- const day = new Date().toISOString().slice(0, 10);
232
+ const isInFlight = (r) => typeof r === "object" && r !== null && "tx_id" in r && ["pending", "unknown", "dropped"].includes(r.status);
233
+ /** What a result's tx cost (the fee charged, once final) or can cost (its max fee, in flight). Kept out of the result's JSON. */
234
+ const COST = new WeakMap();
235
+ /** The fee `w.build` will set for this action (base fee for its size + tip), or undefined when it can't be priced here. */
236
+ function feeFor(w, status, action, memo, fees) {
61
237
  try {
62
- const f = JSON.parse(readFileSync(SPEND_FILE, "utf8"));
63
- if (f.day === day)
64
- return { day, spent: BigInt(f.spent) };
238
+ if (typeof status?.chain_id !== "string" || typeof status?.genesis_hash !== "string")
239
+ return undefined;
240
+ // Every number in the signing bytes is fixed width, so a draft prices the same as the tx.
241
+ const draft = { chain_id: status.chain_id, genesis_hash: status.genesis_hash, from: w.account, nonce: 0, fee: "0", tip: w.tip.toString(), valid_until: 0, action, memo };
242
+ return baseFee(draft, fees?.suggested) + w.tip;
65
243
  }
66
244
  catch {
67
- /* no spend yet */
245
+ return undefined;
68
246
  }
69
- return { day, spent: 0n };
70
247
  }
71
- const server = new McpServer({ name: "blockula", version: VERSION });
72
- const text = (v) => ({ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) }] });
73
- function readWalletFile() {
74
- return existsSync(WALLET_FILE) ? JSON.parse(readFileSync(WALLET_FILE, "utf8")) : null;
248
+ /** The same tx with another nonce, signed again: the nonce is fixed width, so its size and fee don't change. */
249
+ function withNonce(w, tx, nonce) {
250
+ const { public_key: _key, signature: _sig, ...unsigned } = tx;
251
+ return w.sign({ ...unsigned, nonce });
75
252
  }
76
- function loadWallet() {
77
- const f = readWalletFile();
253
+ /** A node's "bad nonce" refusal: the nonce it expects and the one it got. */
254
+ const BAD_NONCE = /bad nonce: expected (\d+), got (\d+)/i;
255
+ /**
256
+ * Submits a signed tx. A node that says it expects a later nonce than this tx's may be mid-block:
257
+ * while a block persists, admission can count the txs it just ran twice and refuse the next
258
+ * correct nonce (PERF-1; seen here against a dev chain). #62 fixes it in the node, but nodes
259
+ * from before it still do it. The same signed tx is offered again a few times, which is safe:
260
+ * it's one tx, so at most one copy runs. A nonce another tx really took gets the same refusal
261
+ * each time, and that one is answered.
262
+ */
263
+ async function submitPatiently(client, tx) {
264
+ for (let attempt = 1;; attempt++) {
265
+ try {
266
+ return await client.submit(tx, false);
267
+ }
268
+ catch (e) {
269
+ const m = e instanceof ApiError && e.status >= 400 && e.status < 500 ? BAD_NONCE.exec(e.message) : null;
270
+ if (!m || Number(m[2]) !== tx.nonce || Number(m[1]) <= tx.nonce || attempt >= 4)
271
+ throw e;
272
+ await new Promise((r) => setTimeout(r, 300 * attempt));
273
+ }
274
+ }
275
+ }
276
+ /**
277
+ * Signs `action`, reserves what it can cost (the spend plus the tx's max fee) against today's
278
+ * budget under its (chain, account, nonce) slot, and only then submits it. Final: the unused part
279
+ * of the max fee is given back and the tx is returned. Everything else stays counted: failed in a
280
+ * block (its fee was charged), dropped, pending, timed out, unknown, and refused too, since the
281
+ * node's refusal is only its word and it may relay the signed tx anyway (audit F68, F72, F74). A
282
+ * refused tx stops counting twice once this MCP signs another tx with its nonce (the next send,
283
+ * after an honest refusal): see spend.ts.
284
+ *
285
+ * Before anything is signed, what the chain would refuse by the node's own reads is refused here
286
+ * (budget.ts `preflight`: a session key's limits, what the account can move, a key that isn't on
287
+ * the account; AGENT-6), and so is what wouldn't fit today's budget. Building, signing and
288
+ * submitting hold the account's lock (locks.ts; AGENT-10), so parallel calls and other sessions
289
+ * take nonces one after another; waiting for finality doesn't.
290
+ */
291
+ async function submitCapped(w, action, spend, memo = "") {
292
+ const perCall = spend.perCall ?? spend.amount;
293
+ if (perCall > CAPS.maxSend)
294
+ throw new Error(`${spend.what ?? "amount"} (${formatFang(perCall)}) exceeds this agent's per-call limit (${CAPS.sendText})`);
295
+ await w.client.addressPrefix();
296
+ const chainId = chainNow();
297
+ const lockKey = `${chainId ?? "unknown-chain"} ${w.account}`;
298
+ const sent = await LOCKS.run(lockKey, async () => {
299
+ const [status, account, fees] = await Promise.all([w.client.status(), w.client.account(w.account).catch(() => null), w.client.fees().catch(() => null)]);
300
+ const height = safeInt(status.height) ?? 0;
301
+ const fee = feeFor(w, status, action, memo, fees);
302
+ preflight({ account, keyId: w.keyId, actingFor: w.account, action: action.type, outflow: spend.outflow ?? 0n, opening: spend.extra ?? 0n, fee: fee ?? 0n, height: height + 1, blockTimeMs: w.client.genesisClock?.blockTimeMs });
303
+ // Today's budget, before signing: what the tx's slot would add (a nonce already used today counts once).
304
+ if (fee !== undefined && safeInt(account?.next_nonce) !== undefined) {
305
+ const need = spend.amount + (spend.extra ?? 0n) + fee;
306
+ const held = LEDGER.held({ account: w.account, nonce: account.next_nonce, chain: chainId });
307
+ const adds = need > held ? need - held : 0n;
308
+ const today = LEDGER.spent(chainId);
309
+ if (today + adds > CAPS.maxDaily)
310
+ throw new Error(`this would exceed the agent's daily limit (${CAPS.dailyText}; ${formatFang(today)} already spent or in flight today on this chain, and this needs ${formatFang(adds)} more including fees). Nothing was signed.`);
311
+ }
312
+ let tx = await w.build(action, memo, { resolutions: spend.resolutions ?? [] });
313
+ // A node whose view lags (its mempool, or a block that just ran) mustn't hand out a nonce this MCP already used.
314
+ const floor = NONCES.floor(lockKey, height);
315
+ const bumped = floor !== undefined && tx.nonce < floor;
316
+ if (bumped)
317
+ tx = withNonce(w, tx, floor);
318
+ const id = txId(tx);
319
+ const slot = { account: tx.from, nonce: tx.nonce, chain: chainId };
320
+ const reservation = LEDGER.reserve(spend.amount + (spend.extra ?? 0n) + BigInt(tx.fee), slot);
321
+ // Before it can reach any node: tx_status reads its valid_until from here (review-fix-clients-2).
322
+ try {
323
+ SIGNED.add(id, { valid_until: tx.valid_until, account: tx.from, nonce: tx.nonce, chain_id: tx.chain_id, genesis_hash: tx.genesis_hash });
324
+ }
325
+ catch {
326
+ /* informational only: tx_status then says valid_until is unknown */
327
+ }
328
+ try {
329
+ await submitPatiently(w.client, tx);
330
+ }
331
+ catch (e) {
332
+ // The node says it refused it. An honest node then never relayed it, but a lying one can say
333
+ // so and relay it anyway, so the reservation stays: the next tx this MCP signs with the same
334
+ // nonce (after a real refusal, the node's next nonce didn't move) takes the slot over.
335
+ if (e instanceof ApiError && e.status >= 400 && e.status < 500) {
336
+ NONCES.settled(lockKey, tx.nonce);
337
+ // Signed past the node's nonce, and refused for it: the node is behind, or a tx this MCP
338
+ // counted on was dropped. Its view is used next time.
339
+ const lag = bumped && /bad nonce/i.test(e.message);
340
+ if (lag)
341
+ NONCES.forget(lockKey);
342
+ const why = lag ? ` This MCP signed it with nonce ${tx.nonce}, past the last nonce it used; the node expects an earlier one, so the node is behind or that tx was dropped: check the last tx with tx_status, then try again.` : "";
343
+ 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)${why}`);
344
+ }
345
+ // Admitted, but the first read of its status failed: wait for it below like any other.
346
+ if (!(e instanceof TxPendingError))
347
+ return { tx, id, reservation, unclear: clean(String(e?.message ?? e), 200) };
348
+ }
349
+ NONCES.pending(lockKey, tx.nonce, tx.valid_until);
350
+ return { tx, id, reservation };
351
+ });
352
+ const { tx, id, reservation } = sent;
353
+ const maxFee = BigInt(tx.fee);
354
+ const counted = formatFang(LEDGER.held({ account: tx.from, nonce: tx.nonce, chain: chainId }));
355
+ // What became of its nonce: spent for good once it ran in a block; free again if it was dropped.
356
+ const ran = () => LOCKS.run(lockKey, async () => NONCES.ran(lockKey, tx.nonce)).catch(() => undefined);
357
+ const settle = () => LOCKS.run(lockKey, async () => NONCES.settled(lockKey, tx.nonce)).catch(() => undefined);
358
+ const inFlight = (status, why) => {
359
+ const r = {
360
+ status,
361
+ tx_id: id,
362
+ valid_until: tx.valid_until,
363
+ counted_toward_daily_limit: counted,
364
+ 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.`,
365
+ explorer: explorer(`tx/${id}`),
366
+ };
367
+ COST.set(r, { fee: maxFee, final: false });
368
+ return r;
369
+ };
370
+ if ("unclear" in sent)
371
+ return inFlight("unknown", `The node didn't answer clearly (${sent.unclear}), so the tx (${id}) may or may not have arrived.`);
372
+ try {
373
+ const done = await w.client.waitForTx(id, TX_WAIT_MS, tx.valid_until);
374
+ await ran();
375
+ // Only a whole number of drops below the max fee gives anything back (audit gap-7-4).
376
+ const charged = /^\d+$/.test(String(done.fee ?? "")) ? BigInt(done.fee) : maxFee;
377
+ if (charged < maxFee)
378
+ LEDGER.release(reservation, maxFee - charged);
379
+ if (done && typeof done === "object")
380
+ COST.set(done, { fee: charged < maxFee ? charged : maxFee, final: true });
381
+ return done;
382
+ }
383
+ catch (e) {
384
+ // Dropped from this node's mempool unrun: nothing was charged, but it isn't dead (audit review-fix-clients-2).
385
+ if (e instanceof TxFailedError && e.dropped) {
386
+ await settle();
387
+ 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.`);
388
+ }
389
+ if (e instanceof TxFailedError) {
390
+ await ran();
391
+ 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}).`);
392
+ }
393
+ return inFlight("pending", `Submitted (tx ${id}), but not final after ${Math.round(TX_WAIT_MS / 1000)} s.`);
394
+ }
395
+ }
396
+ /**
397
+ * What opening `to`'s account would burn on top of a transfer (0 if it exists): the node's
398
+ * `exists` and `creation_fee`. A node too old to say answers for any address, with a zero
399
+ * balance and the address's own "original key" when it has no account, so there an account
400
+ * counts as existing only by what a missing one can't have: a balance or a nonce. An existing
401
+ * account with neither is counted as new: the cap errs high, never low.
402
+ */
403
+ async function openingFee(client, to) {
404
+ let a = null;
405
+ try {
406
+ a = await client.account(to);
407
+ }
408
+ catch (e) {
409
+ if (!(e instanceof ApiError && e.status === 404))
410
+ throw e;
411
+ }
412
+ // Nodes since protocol v1 say it outright: whether the account exists, and what opening it burns.
413
+ if (typeof a?.exists === "boolean" && /^\d+$/.test(String(a?.creation_fee ?? "")))
414
+ return a.exists ? 0n : BigInt(a.creation_fee);
415
+ const exists = (/^\d+$/.test(String(a?.balance ?? "")) && BigInt(a.balance) > 0n) || (Number.isSafeInteger(a?.nonce) && a.nonce > 0);
416
+ return exists ? 0n : reported((await client.fees()).account_creation_fee, "the account creation fee");
417
+ }
418
+ /** A drops amount the node reports, which must be there for a spend to be priced. */
419
+ function reported(v, what) {
420
+ if ((typeof v === "string" && /^\d+$/.test(v)) || (typeof v === "number" && Number.isSafeInteger(v) && v >= 0))
421
+ return BigInt(v);
422
+ throw new Error(`the node doesn't report ${what}, so the spend can't be checked against the spending limits; refusing`);
423
+ }
424
+ /**
425
+ * A name's registration price and yearly renewal price, computed here from the chain's
426
+ * parameters (node/src/params.rs `name_price`, `name_renewal_price`) and never below what
427
+ * `/api/name` quotes: the caps count the higher. Missing prices refuse the spend.
428
+ */
429
+ async function namePrices(client, name) {
430
+ const n = bareName(name);
431
+ const [params, info] = await Promise.all([client.params().then(paramsOf), client.name(n)]);
432
+ const key = n.length <= 3 ? "name_price_3" : n.length === 4 ? "name_price_4" : "name_price_5";
433
+ const own = reported(params[key], `the name price parameter ${key}`);
434
+ const ownRenewal = (own * reported(params.name_renewal_bps, "the name_renewal_bps parameter")) / 10000n;
435
+ const quoted = reported(info?.price, "the name's price");
436
+ const quotedRenewal = reported(info?.renewal_price, "the name's renewal price");
437
+ return { price: quoted > own ? quoted : own, renewal: quotedRenewal > ownRenewal ? quotedRenewal : ownRenewal, info };
438
+ }
439
+ /**
440
+ * A name or address resolved for a field that needs an address (staking's validator, the account
441
+ * use_account acts for; audit gap-7-1). With BLOCKULA_WITNESSES a name is proven against them
442
+ * (`Client.resolution`). Without, it's the node's word: on blockula-1 that's not enough to sign, so
443
+ * the tool answers with the address for the user to confirm instead (the agent then passes the
444
+ * address); elsewhere it signs and says it's unproven. Payments don't come here: they sign the
445
+ * name (`Client.recipient`), which the chain resolves itself.
446
+ */
447
+ async function resolved(client, input, role) {
448
+ const res = await client.resolution(input);
449
+ if (!res.proven && (await onBlockula1(client))) {
450
+ return {
451
+ confirm: {
452
+ status: "not_sent",
453
+ needs_confirmation: true,
454
+ [role]: clean(input, 100),
455
+ resolves_to: res.address,
456
+ resolved_by: clean(res.how, 300),
457
+ next: `Nothing was signed. ${clean(input, 100)} was resolved by one node without proof, and on ${BLOCKULA_1} that isn't signed: 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.)`,
458
+ },
459
+ };
460
+ }
461
+ return { res };
462
+ }
463
+ /** What a tool reports about a resolved name: the exact address signed and how it was found. */
464
+ const resolvedView = (res, role) => (res.name === null ? {} : { [`${role}_address`]: res.address, name_resolution: res.proven ? clean(res.how, 300) : `UNPROVEN: ${clean(res.how, 300)}` });
465
+ /** What a tool reports about a recipient the chain resolves: what was signed (a proven address, or the name itself) and how. */
466
+ 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) };
467
+ /** A tx tool's answer: the tx's summary and links, or the in-flight notice. */
468
+ 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}`) };
469
+ const server = new McpServer({ name: "blockula", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
470
+ const text = (v) => ({ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toString() : x), 2) }] });
471
+ /** The agent's key and who it acts for, on the pinned chain (accounts.ts). Null before create_wallet. */
472
+ async function walletState() {
473
+ const client = await chain();
474
+ const f = readWalletFile(WALLET_FILE);
78
475
  if (!f)
79
476
  return null;
80
- const w = Wallet.fromSeed(f.seed, client);
81
- if (f.account)
82
- w.account = f.account;
83
- return w;
477
+ const w = Wallet.fromSeed(secretOf(f), client);
478
+ const prefix = await client.addressPrefix();
479
+ const key = chainNow();
480
+ const acting = key ? actingFor(f, key, prefix) : { changed: false };
481
+ // A 0.5.1 account moved to this chain: written back whole, key included.
482
+ if (acting.changed)
483
+ writeWalletFile(WALLET_FILE, f);
484
+ if (acting.account)
485
+ w.account = acting.account;
486
+ return { w, f, acting, chain: key };
487
+ }
488
+ async function loadWallet() {
489
+ return (await walletState())?.w ?? null;
84
490
  }
85
- function requireWallet() {
86
- const w = loadWallet();
491
+ async function requireWallet() {
492
+ const w = await loadWallet();
87
493
  if (!w)
88
- throw new Error("No agent wallet yet — call create_wallet first.");
494
+ throw new Error("No agent wallet yet: call create_wallet first.");
89
495
  return w;
90
496
  }
497
+ /** How clients show and gate each tool (AGENT-9): reads are read-only; what signs moves FANG (fees at least) on a public chain. */
498
+ const READS = new Set(["status", "tx_status", "wallet_info", "check_name", "lookup", "latest_blocks", "my_stake", "list_proposals", "list_hunts", "contribution_pool", "my_contributions", "program_show", "program_view", "program_code"]);
499
+ const MOVES = new Set(["send", "stake", "unstake", "withdraw_stake", "vote", "register_name", "renew_name", "fund_hunt", "submit_contribution", "link_github"]);
500
+ const TITLES = {
501
+ status: "Network status", tx_status: "Transaction status", start_dev_node: "Start a dev chain", install_node: "Install the node", summon_chain: "Create a chain",
502
+ create_wallet: "Create the agent wallet", use_account: "Act for an account", wallet_info: "Wallet and budget", faucet: "Get test FANG", send: "Send FANG",
503
+ check_name: "Check a name", register_name: "Register a name", renew_name: "Renew a name", lookup: "Look up", latest_blocks: "Latest blocks",
504
+ stake: "Stake FANG", unstake: "Unstake FANG", withdraw_stake: "Withdraw unbonded stake", my_stake: "My stake", list_proposals: "Governance proposals", vote: "Vote",
505
+ list_hunts: "Hunts", link_github: "Link GitHub", fund_hunt: "Fund a Hunt", contribution_pool: "Contribution pool", submit_contribution: "Claim a contribution", my_contributions: "My contributions",
506
+ program_show: "Show a program", program_view: "Run a program's view", program_code: "Show a program's code",
507
+ };
508
+ const annotationsOf = (name) => READS.has(name)
509
+ ? { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
510
+ : MOVES.has(name)
511
+ ? { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
512
+ : { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
91
513
  function tool(name, description, shape, run) {
92
- server.registerTool(name, { description, inputSchema: shape }, (async (args) => {
514
+ server.registerTool(name, { title: TITLES[name], description, inputSchema: shape, annotations: { title: TITLES[name], ...annotationsOf(name) } }, (async (args) => {
93
515
  try {
516
+ // A bad configuration (the network, a cap, a duration) is every tool's answer (AGENT-15).
517
+ if (BAD_CONFIG)
518
+ throw new Error(`The Blockula MCP isn't configured correctly: ${BAD_CONFIG.message}`);
94
519
  return text(await run(args));
95
520
  }
96
521
  catch (e) {
97
- const msg = e.message;
98
- const hint = /fetch failed|ECONNREFUSED/.test(msg) ? ` (no node at ${RPC} — call start_dev_node)` : "";
522
+ // Node errors are relayed, so they're cleaned like any chain text.
523
+ const msg = clean(String(e?.message ?? e), 1000);
524
+ const hint = /fetch failed|ECONNREFUSED/.test(msg)
525
+ ? ` (no node at ${RPC}${NET?.key === "local" ? " — call start_dev_node" : ""}${activeWitnesses.length ? `, or a witness isn't answering: ${activeWitnesses.join(", ")}; BLOCKULA_WITNESSES=none opts out` : ""})`
526
+ : e instanceof RequestTimeoutError
527
+ ? ` (the node at ${RPC} isn't answering)`
528
+ : e instanceof HeightAheadError
529
+ ? ` (the chain's genesis clock is built in, or remembered in ${KNOWN_CHAINS.file} from the first /api/genesis read for this chain)`
530
+ : "";
99
531
  return { ...text(`Error: ${msg}${hint}`), isError: true };
100
532
  }
101
533
  }));
102
534
  }
103
- tool("status", "Blockula network status: which node you're connected to, chain id, height, supply, fees burned, and the crypto in use.", {}, async () => ({
104
- rpc: RPC,
105
- network: RPC === TESTNET_RPC ? "public testnet" : RPC === LOCAL_RPC ? "local node" : "custom node",
106
- explorer: RPC,
107
- mcp_version: VERSION,
108
- ...(await client.status()),
109
- }));
110
- 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 }) => {
111
- const tx = await client.tx(tx_id);
112
- return { status: tx.status, summary: tx.summary, block: tx.height, fee: tx.fee_display, explorer: `${RPC}/#/tx/${tx_id}` };
535
+ tool("status", "Blockula network status: which network and node you're connected to (and the chain it's pinned to), the witnesses names are proven against, 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 () => {
536
+ const net = network();
537
+ const client = await chain();
538
+ const s = await client.status();
539
+ // The node's report is nested and picked field by field: it can't override (or add to) what
540
+ // this MCP states about the network (audit F73).
541
+ const pick = ["chain_id", "height", "finalized_height", "syncing", "epoch", "active_validators", "supply_display", "burned_display", "version", "protocol_version", "head_age_ms", "crypto"];
542
+ return {
543
+ network: networkLabel(net, pinnedChain.chainId),
544
+ rpc: net.rpc,
545
+ // OPERATOR-6: who checks this node's answers about names and heights.
546
+ witnesses: activeWitnesses,
547
+ names: activeWitnesses.length ? `proven against ${activeWitnesses.length} node${activeWitnesses.length === 1 ? "" : "s"} (${activeWitnesses.join(", ")})` : `UNPROVEN: one node's word (${net.rpc}). BLOCKULA_WITNESSES lists other nodes to prove them against.`,
548
+ pinned: chainData({ chain_id: s.chain_id, genesis_hash: s.genesis_hash, address_prefix: s.address_prefix }),
549
+ how_to_change: "The network is fixed while this MCP runs. The user changes it in the MCP's configuration (--network testnet|local|custom, or BLOCKULA_RPC).",
550
+ explorer: net.rpc,
551
+ mcp_version: VERSION,
552
+ node_reports: chainData(Object.fromEntries(pick.filter((k) => k in s).map((k) => [k, s[k]]))),
553
+ };
554
+ });
555
+ const safeInt = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : undefined);
556
+ /**
557
+ * Reads the head and the account's nonce for a tx this MCP signed (audit review-r3-wallet-1): at
558
+ * most one tx per (account, nonce) executes, so an executed nonce past the tx's means it (or a tx
559
+ * that took its nonce) ran, and it can never run again; a nonce still unused at a block past
560
+ * valid_until means it can never run at all. The status is read first, so a nonce read after a
561
+ * head past valid_until can't miss this tx running. With BLOCKULA_WITNESSES the nonce is proven
562
+ * (as of the proof's block), not this node's word.
563
+ */
564
+ async function slotFate(client, id, mine) {
565
+ const fate = {};
566
+ try {
567
+ const st = await client.status();
568
+ [fate.head, fate.historyFrom, fate.maxValidity] = [safeInt(st.height), safeInt(st.history_from), safeInt(st.max_tx_validity_blocks)];
569
+ }
570
+ catch {
571
+ /* unknown: answered conservatively below */
572
+ }
573
+ if (client.witnesses.length) {
574
+ try {
575
+ if (!isAddress(mine.account))
576
+ throw new Error(`${mine.account} isn't an address`);
577
+ const r = await client.provenRecord(NAMESPACES.ACCOUNTS, mine.account, { witnesses: client.witnesses });
578
+ // No record: provably no account, so nothing from it ever ran.
579
+ fate.accountNonce = r.value === null ? 0 : decodeAccount(r.value).nonce;
580
+ fate.proven = { height: r.height, witnesses: client.witnesses.length };
581
+ fate.head = Math.max(fate.head ?? 0, r.head);
582
+ }
583
+ catch (e) {
584
+ fate.proofError = clean(String(e?.message ?? e), 200);
585
+ }
586
+ }
587
+ else {
588
+ try {
589
+ fate.accountNonce = safeInt((await client.account(mine.account)).nonce);
590
+ }
591
+ catch {
592
+ /* unknown */
593
+ }
594
+ }
595
+ if (fate.accountNonce !== undefined && fate.accountNonce > mine.nonce) {
596
+ // The first read (that didn't find it in a block) predates the nonce read: the block it ran in
597
+ // may have executed in between. Only a read after the nonce read can say it's in none of the
598
+ // blocks this node holds.
599
+ try {
600
+ const t = await client.tx(id);
601
+ if (t.status === "finalized" || (t.status === "failed" && !txDropped(t))) {
602
+ fate.recheck = { ran: t };
603
+ return fate;
604
+ }
605
+ if (t.status === "failed")
606
+ fate.recheck = "absent";
607
+ }
608
+ catch (e) {
609
+ if (e instanceof ApiError && e.status === 404)
610
+ fate.recheck = "absent";
611
+ }
612
+ for (const other of SIGNED.sameSlot(mine, id).slice(-3)) {
613
+ const t = await client.tx(other).catch(() => null);
614
+ if (t && (t.status === "finalized" || (t.status === "failed" && !txDropped(t)))) {
615
+ fate.sibling = { id: other, status: String(t.status), ...(safeInt(t.height) !== undefined ? { block: safeInt(t.height) } : {}) };
616
+ break;
617
+ }
618
+ }
619
+ }
620
+ return fate;
621
+ }
622
+ /**
623
+ * tx_status's answer for a tx this MCP signed that the node doesn't show in a block: `how` says
624
+ * whether the node doesn't know it at all (`unknown`: pruned, never held, or never reached it) or
625
+ * remembers dropping it unrun (`dropped`). Says it's in flight only while the head is at or below
626
+ * its valid_until and its nonce is unused; never "isn't confirmed" for a nonce that executed.
627
+ */
628
+ function afterTheFact(how, tx_id, mine, fate) {
629
+ const vu = mine.valid_until;
630
+ const facts = {
631
+ tx_id,
632
+ account: mine.account,
633
+ nonce: mine.nonce,
634
+ valid_until: vu,
635
+ ...(fate.head !== undefined ? { node_height: fate.head } : {}),
636
+ ...(fate.accountNonce !== undefined ? { account_nonce: fate.accountNonce } : {}),
637
+ ...(fate.proven ? { account_nonce_proven_at: fate.proven.height, proven_by_witnesses: fate.proven.witnesses } : {}),
638
+ ...(fate.proofError ? { proof_error: fate.proofError } : {}),
639
+ ...(fate.historyFrom !== undefined ? { history_from: fate.historyFrom } : {}),
640
+ };
641
+ const seen = how === "dropped" ? "This node dropped it from its mempool without running it" : `${RPC} doesn't know this tx`;
642
+ 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"})` : "";
643
+ // The block the nonce was read at: the proof's, else (unproven) the node's head, read just before.
644
+ const nonceAt = fate.proven ? fate.proven.height : fate.head;
645
+ if (fate.accountNonce !== undefined && fate.accountNonce > mine.nonce) {
646
+ // Its nonce was used: by this tx (through another node, or in a block this node no longer
647
+ // holds) or by another one. Either way it's final: it can never execute again.
648
+ let which;
649
+ // With witnesses only the nonce is proven: which tx used it (a sibling's status, a 404 and
650
+ // history_from) is still this node's word (audit review-r4-wallet-4).
651
+ const nodeWord = fate.proven ? " Only the nonce is proven: which tx used it is this node's word." : "";
652
+ if (fate.sibling) {
653
+ 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}`;
654
+ }
655
+ else {
656
+ const earliest = fate.maxValidity !== undefined ? Math.max(1, vu - fate.maxValidity) : 1;
657
+ const holdsAll = fate.historyFrom !== undefined && fate.historyFrom <= earliest;
658
+ // "In none of them" needs a read after the nonce read that still didn't find it in a block.
659
+ const lacks = !holdsAll || fate.recheck !== "absent";
660
+ which = lacks
661
+ ? `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.`
662
+ : `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}`;
663
+ }
664
+ return {
665
+ status: "nonce_used",
666
+ ...facts,
667
+ 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.`,
668
+ };
669
+ }
670
+ if (nonceAt !== undefined && nonceAt > vu && fate.accountNonce !== undefined) {
671
+ return {
672
+ status: "expired",
673
+ ...facts,
674
+ next: fate.proven
675
+ ? `${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.`
676
+ : `${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.`,
677
+ };
678
+ }
679
+ if (fate.head !== undefined && fate.head <= vu && (fate.accountNonce !== undefined || (how === "dropped" && !fate.proofError))) {
680
+ 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`;
681
+ return {
682
+ status: how,
683
+ ...(how === "dropped" ? { dropped: true } : {}),
684
+ ...facts,
685
+ 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.`,
686
+ };
687
+ }
688
+ // The node (or the witnesses) didn't say enough: the height, or the account's nonce once past valid_until.
689
+ const missing = fate.head === undefined
690
+ ? "the chain's height"
691
+ : fate.proofError
692
+ ? `a proven nonce for ${mine.account} (${fate.proofError})`
693
+ : fate.accountNonce !== undefined
694
+ ? `${mine.account}'s nonce as of a block past its valid_until (the proof is from block ${nonceAt}, the chain at block ${fate.head})`
695
+ : `whether ${mine.account}'s nonce ${mine.nonce} was used`;
696
+ return {
697
+ status: how,
698
+ ...(how === "dropped" ? { dropped: true } : {}),
699
+ ...facts,
700
+ 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.`,
701
+ };
702
+ }
703
+ /** A tx the node shows in a block: finalized, or failed (it ran, so its fee was charged). Final either way. */
704
+ function inBlock(tx, link) {
705
+ const status = String(tx.status);
706
+ const summary = clean(String(tx.summary ?? ""));
707
+ const error = tx.error ? { error: clean(String(tx.error)) } : {};
708
+ if (status === "finalized")
709
+ return { status, summary, ...error, block: tx.height, fee: tx.fee_display, explorer: link };
710
+ 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 };
711
+ }
712
+ /**
713
+ * A tx's status as the node reports it, read the way submitCapped reads it (audit
714
+ * review-fix-clients-2): "finalized" and "failed" (it ran in a block; its fee was charged) are
715
+ * final; "dropped" (this node dropped it from its mempool unrun: nothing charged) and "pending"
716
+ * aren't, since another node may still include it until its valid_until. For a tx this MCP
717
+ * signed, valid_until, account and nonce are what it signed (signed.ts), not the node's word, and
718
+ * one the node can't show is read against the head and the account's nonce (afterTheFact).
719
+ */
720
+ 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 }) => {
721
+ const client = await chain();
722
+ // A record from another network (BLOCKULA_HOME is shared by all of them) says nothing about this
723
+ // one: its account's nonce and this chain's head are another chain's (audit review-r4-wallet-3).
724
+ const record = SIGNED.get(tx_id);
725
+ const mine = record && onChain(record, pinnedChain) && isAddress(record.account, pinnedChain.prefix) ? record : undefined;
726
+ const link = explorer(`tx/${tx_id}`);
727
+ const until = (v) => (v === undefined ? "its valid_until (unknown here: this MCP didn't sign it)" : `block ${v}`);
728
+ let tx;
729
+ try {
730
+ tx = await client.tx(tx_id);
731
+ }
732
+ catch (e) {
733
+ // This node doesn't know it: pruned or never held (it ran long ago), or never reached it.
734
+ if (e instanceof ApiError && e.status === 404 && mine) {
735
+ const fate = await slotFate(client, tx_id, mine);
736
+ // It ran in a block that executed after the first read: answered as what it is.
737
+ if (fate.recheck && fate.recheck !== "absent")
738
+ return inBlock(fate.recheck.ran, link);
739
+ return { ...afterTheFact("unknown", tx_id, mine, fate), explorer: link };
740
+ }
741
+ if (e instanceof ApiError && e.status === 404 && record) {
742
+ 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)";
743
+ return {
744
+ status: "other_chain",
745
+ tx_id,
746
+ account: record.account,
747
+ nonce: record.nonce,
748
+ valid_until: record.valid_until,
749
+ ...(record.chain_id !== undefined ? { signed_for_chain_id: record.chain_id, signed_for_genesis_hash: record.genesis_hash } : {}),
750
+ 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).`,
751
+ explorer: link,
752
+ };
753
+ }
754
+ throw e;
755
+ }
756
+ const status = String(tx.status);
757
+ const summary = clean(String(tx.summary ?? ""));
758
+ const error = tx.error ? { error: clean(String(tx.error)) } : {};
759
+ const nodeUntil = Number.isSafeInteger(tx.valid_until) ? tx.valid_until : undefined;
760
+ const validUntil = mine?.valid_until ?? nodeUntil;
761
+ if (status === "finalized" || (status === "failed" && !txDropped(tx)))
762
+ return inBlock(tx, link);
763
+ if (status === "failed") {
764
+ // Dropped unrun: the height in the answer is where this node dropped it, not a block it ran in.
765
+ const at = typeof tx.height === "number" ? { dropped_at_height: tx.height } : {};
766
+ if (mine) {
767
+ const fate = await slotFate(client, tx_id, mine);
768
+ // Another node included it after this one dropped it, and this node has run that block since.
769
+ if (fate.recheck && fate.recheck !== "absent")
770
+ return inBlock(fate.recheck.ran, link);
771
+ const r = afterTheFact("dropped", tx_id, mine, fate);
772
+ return { ...r, ...(r.status === "dropped" ? { dropped: true } : {}), summary, ...error, ...at, explorer: link };
773
+ }
774
+ const head = await client.status().then((st) => safeInt(st.height)).catch(() => undefined);
775
+ const past = validUntil !== undefined && head !== undefined && head > validUntil;
776
+ const next = past
777
+ ? `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.`
778
+ : `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.`;
779
+ return { status: "dropped", dropped: true, summary, ...error, ...at, valid_until: validUntil ?? "unknown", ...(head !== undefined ? { node_height: head } : {}), next, explorer: link };
780
+ }
781
+ return {
782
+ status: clean(status),
783
+ summary,
784
+ ...error,
785
+ valid_until: validUntil ?? "unknown",
786
+ ...(status === "pending" ? { next: `Not final yet: it can still execute until ${until(validUntil)}. Do NOT send it again: check tx_status again.` } : {}),
787
+ explorer: link,
788
+ };
113
789
  });
114
- 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 }) => {
115
- const target = process.env.BLOCKULA_RPC ?? LOCAL_RPC;
790
+ 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 }) => {
791
+ const net = network();
792
+ const target = net.key === "local" ? net.rpc : "http://localhost:7070";
793
+ 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).`;
116
794
  if (!reset && (await reachable(target))) {
117
- useRpc(target);
118
- return `Already running at ${target} (explorer: ${target})`;
795
+ // Asked for the dev chain explicitly: pin whichever one runs now (a reset makes a new genesis).
796
+ if (net.key === "local")
797
+ pinned = null;
798
+ return `A node is already running at ${target} (explorer: ${target}). ${using}`;
119
799
  }
120
800
  const port = new URL(target).port || "7070";
121
801
  mkdirSync(HOME, { recursive: true });
122
802
  const log = openSync(join(HOME, "dev-node.log"), "a");
123
803
  let missing = false;
124
- const child = spawn(BIN, ["dev", "--port", port, ...(reset ? ["--reset"] : [])], { detached: true, stdio: ["ignore", log, log] });
804
+ const bin = nodeBinary();
805
+ const child = spawn(bin.path, ["dev", "--port", port, ...(reset ? ["--reset"] : [])], { detached: true, stdio: ["ignore", log, log] });
125
806
  child.on("error", (e) => {
126
807
  missing = e.code === "ENOENT";
127
808
  });
@@ -129,140 +810,575 @@ tool("start_dev_node", "Start a local Blockula dev chain (faucet + built-in expl
129
810
  for (let i = 0; i < 40 && !missing; i++) {
130
811
  await new Promise((r) => setTimeout(r, 250));
131
812
  if (await reachable(target)) {
132
- useRpc(target);
133
- return `🧛 Dev chain is up. Explorer: ${target} · logs: ${join(HOME, "dev-node.log")}`;
813
+ // A reset dev chain has a new genesis: re-pin on the next call.
814
+ if (net.key === "local")
815
+ pinned = null;
816
+ return `🧛 Dev chain is up. Explorer: ${target} · logs: ${join(HOME, "dev-node.log")}. ${ranWith(bin)} ${using}`;
134
817
  }
135
818
  }
136
819
  if (missing) {
137
- 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. Call install_node to download the prebuilt node (checksum-verified), then start_dev_node again.`);
820
+ 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.`);
138
821
  }
139
822
  throw new Error(`node didn't start. See ${join(HOME, "dev-node.log")}`);
140
823
  });
141
- tool("install_node", "Download the prebuilt `blockula` node + CLI for this machine from the official GitHub release, verify its SHA-256 checksum, and install it to ~/.blockula/bin. 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 }) => {
824
+ tool("install_node", "Download the prebuilt `blockula` node + CLI for this machine from the official GitHub release into ~/.blockula/mcp-bin, after the same checks as scripts/install.sh (the version asked for, its checksum, provenance and release signature; the result says which passed). 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 }) => {
142
825
  const target = releaseTarget();
143
826
  if (!target)
144
827
  throw new Error(`No prebuilt node for ${process.platform}/${process.arch}. Build from source: https://github.com/${REPO}`);
145
- const base = version === "latest" ? `https://github.com/${REPO}/releases/latest/download` : `https://github.com/${REPO}/releases/download/${version}`;
828
+ // The operator's pin (written by `blockula release install`) binds this MCP too.
829
+ const pinFile = join(HOME, PIN_FILE);
830
+ const releaseKeyId = releaseKeyIdFrom(process.env.BLOCKULA_RELEASE_KEY_ID, RELEASE_KEY_ID, readReleasePin(pinFile), pinFile);
831
+ // Every check is bound to one tag: GitHub redirects /releases/latest to /releases/tag/<tag>.
832
+ const tag = version === "latest" ? latestTagFrom((await fetch(`https://github.com/${REPO}/releases/latest`, { method: "HEAD", redirect: "follow", signal: AbortSignal.timeout(60_000) })).url) : version;
833
+ const base = `https://github.com/${REPO}/releases/download/${tag}`;
146
834
  const asset = `blockula-${target}.tar.gz`;
147
- const [tarball, sums] = await Promise.all([download(`${base}/${asset}`), download(`${base}/checksums.txt`)]);
148
- const expected = sums.toString("utf8").split("\n").map((l) => l.trim().split(/\s+/)).find(([, f]) => f === asset)?.[0];
149
- const actual = createHash("sha256").update(tarball).digest("hex");
150
- if (!expected || expected !== actual)
151
- throw new Error(`checksum mismatch for ${asset}; not installing`);
152
835
  const tmp = mkdtempSync(join(tmpdir(), "blockula-"));
153
836
  try {
154
- writeFileSync(join(tmp, asset), tarball);
837
+ const rel = await fetchVerifiedRelease(base, asset, { releaseKeyId, requireVerify: process.env.BLOCKULA_REQUIRE_VERIFY === "1", tag }, {
838
+ async download(url) {
839
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(300_000) });
840
+ if (res.status === 404)
841
+ return null;
842
+ if (!res.ok)
843
+ throw new Error(`download failed (${res.status}): ${url}`);
844
+ return Buffer.from(await res.arrayBuffer());
845
+ },
846
+ async attest(archive, name, tag) {
847
+ if (spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status !== 0)
848
+ return { result: "unavailable" };
849
+ const file = join(tmp, `attest-${name}`);
850
+ writeFileSync(file, archive);
851
+ const r = spawnSync("gh", ["attestation", "verify", file, "--repo", REPO, "--signer-workflow", `${REPO}/.github/workflows/release.yml`, "--source-ref", `refs/tags/${tag}`], { encoding: "utf8" });
852
+ return r.status === 0 ? { result: "ok" } : { result: "failed", detail: r.stderr || r.stdout };
853
+ },
854
+ });
855
+ writeFileSync(join(tmp, asset), rel.archive);
155
856
  execFileSync("tar", ["-xzf", join(tmp, asset), "-C", tmp]);
156
- mkdirSync(join(HOME, "bin"), { recursive: true });
157
- copyFileSync(join(tmp, `blockula-${target}`, "blockula"), INSTALLED_BIN);
158
- chmodSync(INSTALLED_BIN, 0o755);
857
+ // The binary must be the version asked for before it replaces anything.
858
+ const extracted = join(tmp, `blockula-${target}`, "blockula");
859
+ let printed = "";
860
+ try {
861
+ printed = execFileSync(extracted, ["--version"], { encoding: "utf8" });
862
+ }
863
+ catch {
864
+ // checkBinaryVersion reports it as unrunnable.
865
+ }
866
+ checkBinaryVersion(printed, tag);
867
+ // Atomically: a temporary file beside it, then a rename.
868
+ mkdirSync(dirname(INSTALLED_BIN), { recursive: true });
869
+ const staged = `${INSTALLED_BIN}.new-${process.pid}`;
870
+ try {
871
+ copyFileSync(extracted, staged);
872
+ chmodSync(staged, 0o755);
873
+ renameSync(staged, INSTALLED_BIN);
874
+ }
875
+ finally {
876
+ rmSync(staged, { force: true });
877
+ }
878
+ const installed = execFileSync(INSTALLED_BIN, ["--version"], { encoding: "utf8" }).trim();
879
+ const runs = nodeBinary();
880
+ 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`;
881
+ 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})` };
159
882
  }
160
883
  finally {
161
884
  rmSync(tmp, { recursive: true, force: true });
162
885
  }
163
- BIN = INSTALLED_BIN;
164
- const installed = execFileSync(INSTALLED_BIN, ["--version"], { encoding: "utf8" }).trim();
165
- return { installed, path: INSTALLED_BIN, sha256: actual, next: "start_dev_node runs a local chain; summon_chain creates your own network" };
166
886
  });
167
887
  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 }) => {
168
- const out = execFileSync(BIN, ["summon", chain_id, ...(faucet ? [] : ["--no-faucet"])], { encoding: "utf8" });
169
- return out.trim();
888
+ const bin = nodeBinary();
889
+ const out = execFileSync(bin.path, ["summon", chain_id, ...(faucet ? [] : ["--no-faucet"])], { encoding: "utf8" });
890
+ return `${out.trim()}\n${ranWith(bin)}`;
170
891
  });
171
- tool("create_wallet", "Create this agent's post-quantum (ML-DSA-65) wallet, stored locally. Returns the existing one if it already exists.", {}, async () => {
172
- let w = loadWallet();
173
- const existed = !!w;
174
- if (!w) {
175
- w = Wallet.generate(client);
176
- mkdirSync(HOME, { recursive: true });
177
- writeFileSync(WALLET_FILE, JSON.stringify({ address: w.address, key_id: w.keyId, seed: w.seedHex }, null, 2), { mode: 0o600 });
892
+ /**
893
+ * What the agent can spend for an account now (AGENT-7), from its `/api/account` answer: what it
894
+ * can move, what's locked and until when, the session key's allowance this window, this MCP's
895
+ * caps, and the smallest of them. wallet_info and use_account return it.
896
+ */
897
+ async function budgetOf(w, a) {
898
+ const client = w.client;
899
+ const head = safeInt((await client.status()).height) ?? 0;
900
+ // As of the next block, the first a tx can run in.
901
+ const height = head + 1;
902
+ const blockTimeMs = client.genesisClock?.blockTimeMs ?? 1000;
903
+ const key = keyOn(a, w.keyId);
904
+ const allowance = allowanceOf(key ?? undefined, height);
905
+ const transferable = dropsOf(a?.transferable);
906
+ const locked = dropsOf(a?.locked);
907
+ const today = LEDGER.spent(chainNow());
908
+ const dailyLeft = CAPS.maxDaily > today ? CAPS.maxDaily - today : 0n;
909
+ const spendable = spendableNow({ transferable, allowance, height, blockTimeMs, dailyLeft, caps: CAPS });
910
+ const locks = (Array.isArray(a?.locks) ? a.locks : []).filter((l) => safeInt(l?.until) !== undefined && dropsOf(l?.amount) !== undefined);
911
+ const lastEnd = locks.reduce((m, l) => Math.max(m, l.until), 0);
912
+ return {
913
+ ...(transferable !== undefined ? { transferable: formatFang(transferable) } : {}),
914
+ ...(locked !== undefined && locked > 0n
915
+ ? {
916
+ locked: formatFang(locked),
917
+ ...(lastEnd > 0 ? { locked_until_block: lastEnd, locked_until: lastEnd > head ? `${aboutBlocks(lastEnd - head, blockTimeMs)} from now` : "now" } : {}),
918
+ locks: locks.slice(0, 10).map((l) => ({ id: clean(String(l.id ?? ""), 60), amount: formatFang(dropsOf(l.amount)), until_block: l.until, ends_in: l.until > head ? aboutBlocks(l.until - head, blockTimeMs) : "now", vesting: l.vesting === true })),
919
+ }
920
+ : {}),
921
+ spendable_now: formatFang(spendable.amount),
922
+ spendable_now_limited_by: spendable.limitedBy,
923
+ spending_limits: spendable.limits,
924
+ ...(allowance
925
+ ? {
926
+ session_key: {
927
+ limit: `${formatFang(allowance.limit)} per ${aboutBlocks(allowance.periodBlocks, blockTimeMs).replace(/^about /, "")}`,
928
+ spent_this_period: formatFang(allowance.spent),
929
+ left: formatFang(allowance.expired ? 0n : allowance.left),
930
+ ...(allowance.resetsAt !== undefined ? { resets_at_block: allowance.resetsAt, resets_in: aboutBlocks(allowance.resetsAt - head, blockTimeMs) } : {}),
931
+ expires_at_block: allowance.expiresAt,
932
+ expires_in: allowance.expired ? "expired" : aboutBlocks(allowance.expiresAt - head, blockTimeMs),
933
+ },
934
+ }
935
+ : {}),
936
+ caps: { per_call: formatFang(CAPS.maxSend), per_day: formatFang(CAPS.maxDaily) },
937
+ spent_today: `${formatFang(today)} of ${formatFang(CAPS.maxDaily)} (${CAPS.dailyText}; on this chain; txs that are pending, dropped or refused still count until they're final or their nonce is reused)`,
938
+ };
939
+ }
940
+ /** An address of another network's prefix gets a clear answer (AGENT-14), not "isn't a valid address". */
941
+ function otherNetwork(input, prefix) {
942
+ const p = addressPrefix(input.trim());
943
+ if (p === undefined || p === prefix)
944
+ return;
945
+ const which = p === "tboo" ? "a testnet address" : "another network's address";
946
+ throw new Error(`${clean(input, 100)} is ${which} (${p}1…), and this MCP is on ${networkLabel(network(), pinnedChain.chainId)}, whose addresses start with ${prefix}1. Nothing was signed.`);
947
+ }
948
+ /** What a payment cost (AGENT-1): the amount, the fee charged and what opening the recipient's account burned. */
949
+ function costView(r, amount, opening) {
950
+ const c = COST.get(r);
951
+ return {
952
+ ...(opening > 0n ? { account_opening_fee_burned: formatFang(opening) } : {}),
953
+ ...(c ? { total_cost: formatFang(amount + c.fee + opening), ...(c.final ? {} : { total_cost_is: "at most, if it runs: the fee charged is the base fee of the block that includes it" }) } : {}),
954
+ };
955
+ }
956
+ tool("create_wallet", "Create this agent's post-quantum (ML-DSA-65) wallet, stored locally, unencrypted (mode 600). Returns the existing one if it already exists.", {}, async () => {
957
+ const client = await chain();
958
+ let st = await walletState();
959
+ const existed = !!st;
960
+ if (!st) {
961
+ const fresh = Wallet.generate(client);
962
+ await client.addressPrefix();
963
+ // Never over a key another session made meanwhile: that one is kept and used.
964
+ createWalletFile(WALLET_FILE, newWalletFile(fresh.address, fresh.keyId, fresh.seedHex));
965
+ st = (await walletState());
178
966
  }
967
+ const { w } = st;
179
968
  return {
180
969
  key_id: w.keyId,
970
+ check_code: checkCode(w.keyId),
181
971
  own_address: w.address,
182
972
  acting_for: w.account,
183
973
  existed,
974
+ network: networkLabel(network(), pinnedChain.chainId),
184
975
  stored_at: WALLET_FILE,
185
- 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.)",
976
+ next_steps: "To spend from the user's account: ask which account (their .boo name or address), 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.)",
186
977
  };
187
978
  });
188
- 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 }) => {
189
- const f = readWalletFile();
190
- if (!f)
191
- throw new Error("No agent wallet yet — call create_wallet first.");
192
- const w = loadWallet();
979
+ tool("use_account", "Act for a user's account that has authorized this agent's key, usually as a session key with a spending limit. Kept per network. If it hasn't authorized the key yet, answers with a grant_code for the user to paste in their wallet (Devices & agents → Give an agent a budget) and a check_code for them to compare with what the wallet shows; 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().describe("the user's account: its address or .boo name, or \"self\"") }, async ({ account }) => {
980
+ const st = await walletState();
981
+ if (!st)
982
+ throw new Error("No agent wallet yet: call create_wallet first.");
983
+ const { w, f, chain: key } = st;
984
+ const client = w.client;
985
+ if (!key)
986
+ throw new Error(`${RPC} doesn't report its chain's genesis hash, so the account can't be kept for this chain`);
193
987
  if (account === "self") {
194
- delete f.account;
988
+ setActingFor(f, key, undefined);
989
+ writeWalletFile(WALLET_FILE, f);
990
+ return { acting_for: w.address };
195
991
  }
196
- else {
197
- const addr = await client.resolve(account);
198
- const { accounts } = await client.keyAccounts(w.keyId);
199
- const grant = accounts.find((a) => a.account.address === addr);
200
- if (!grant)
201
- throw new Error(`${account} hasn't authorized this key yet. Ask the user to add key_id ${w.keyId} in their wallet (Devices & agents).`);
202
- f.account = addr;
203
- writeFileSync(WALLET_FILE, JSON.stringify(f, null, 2), { mode: 0o600 });
204
- return { acting_for: grant.account.name ?? addr, role: grant.role, label: grant.label };
205
- }
206
- writeFileSync(WALLET_FILE, JSON.stringify(f, null, 2), { mode: 0o600 });
207
- return { acting_for: w.address };
992
+ otherNetwork(account, await client.addressPrefix());
993
+ // The account is an address every later tx names: on blockula-1 an unproven name waits for the
994
+ // user to confirm the address (audit gap-7-1).
995
+ const { res, confirm } = await resolved(client, account, "account");
996
+ if (confirm)
997
+ return confirm;
998
+ const addr = res.address;
999
+ const resolution = res.name === null ? {} : { name_resolution: res.proven ? clean(res.how, 300) : `UNPROVEN: ${clean(res.how, 300)}` };
1000
+ // The account's own key list, not /api/key's list of accounts (which others can pad: audit gap-7-2).
1001
+ const a = await client.account(addr);
1002
+ // An address with no account can't add a key (that takes a tx, and a tx takes FANG): no grant to hand out.
1003
+ if (a?.exists === false) {
1004
+ return { status: "no_account", account: clean(account, 100), address: addr, ...resolution, next: `There's no account at ${addr} on ${networkLabel(network(), pinnedChain.chainId)}: nothing was signed. Check the address with the user (their wallet shows it under Devices & agents). An account exists once it has received FANG.` };
1005
+ }
1006
+ const grant = keyOn(a, w.keyId);
1007
+ if (!grant) {
1008
+ // Adding a key needs its consent to the account (audit gap-7-2): this key signs it for this
1009
+ // one address, and the user's wallet refuses a code made for any other account.
1010
+ const wallet = pinnedChain.chainId ? WALLET_URLS[pinnedChain.chainId] : undefined;
1011
+ // The resolution itself, so the SDK refuses it too if it's unproven on blockula-1.
1012
+ const grantCode = encodeKeyGrant(await w.keyGrant(res));
1013
+ // AGENT-5: thousands of characters are easy to mangle when retyped; a file can be copied whole.
1014
+ // Its name is a chain id `chain()` checked (no path separator can be in it) and an address.
1015
+ const grantFile = join(HOME, `grant-${pinnedChain.chainId ?? "chain"}-${addr}.txt`);
1016
+ let saved = false;
1017
+ try {
1018
+ mkdirSync(HOME, { recursive: true });
1019
+ writeFileSync(grantFile, `${grantCode}\n`, { mode: 0o600 });
1020
+ saved = true;
1021
+ }
1022
+ catch {
1023
+ /* the code is in the answer anyway */
1024
+ }
1025
+ return {
1026
+ status: "not_authorized_yet",
1027
+ account: clean(account, 100),
1028
+ address: addr,
1029
+ ...resolution,
1030
+ key_id: w.keyId,
1031
+ check_code: checkCode(w.keyId),
1032
+ ...(wallet ? { wallet_url: wallet } : {}),
1033
+ grant_code: grantCode,
1034
+ ...(saved ? { grant_file: grantFile, copy_it: `${process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"} < ${grantFile}` } : {}),
1035
+ next: `Nothing was signed on-chain. Give the user the grant_code exactly as it is${saved ? " (or have them copy it from grant_file: copy_it puts it on the clipboard)" : ""}: 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${wallet ? ` (${wallet})` : ""} → Devices & agents → 'Give an agent a budget', check that the wallet shows the check code ${checkCode(w.keyId)}, and choose a limit and an expiry. Then call use_account again.`,
1036
+ };
1037
+ }
1038
+ setActingFor(f, key, addr);
1039
+ writeWalletFile(WALLET_FILE, f);
1040
+ w.account = addr;
1041
+ return { acting_for: clean(String(a?.account?.name ?? addr)), address: addr, ...resolution, role: chainData(grant.role), untrusted_label: clean(String(grant.label ?? ""), 100), ...(await budgetOf(w, a)) };
1042
+ });
1043
+ tool("wallet_info", "This agent's address and the account it acts for, its balance and what can actually be spent now (transferable, locked and until when, the session key's allowance, this MCP's caps: spendable_now and what limits it), .boo names, recent activity (others' txs naming this account included: their summaries are untrusted text) and today's spending.", {}, async () => {
1044
+ const st = await walletState();
1045
+ if (!st)
1046
+ throw new Error("No agent wallet yet: call create_wallet first.");
1047
+ const { w, acting } = st;
1048
+ const prefix = await w.client.addressPrefix();
1049
+ const a = await w.client.account(w.account).catch(() => null);
1050
+ const mine = keyOn(a, w.keyId);
1051
+ return {
1052
+ key_id: w.keyId,
1053
+ network: networkLabel(network(), pinnedChain.chainId),
1054
+ acting_for: clean(String(a?.account?.name ?? w.account)),
1055
+ ...(acting.ignored ? { previous_account_ignored: ignoredNote(acting.ignored, prefix) } : {}),
1056
+ my_permissions: mine ? chainData(mine) : "not authorized on this account",
1057
+ address: w.account,
1058
+ balance: clean(String(a?.balance_display ?? "0 FANG")),
1059
+ ...(await budgetOf(w, a)),
1060
+ names: chainData(a?.names ?? []),
1061
+ 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)}…)`),
1062
+ note: UNTRUSTED_NOTE,
1063
+ };
208
1064
  });
209
- tool("wallet_info", "This agent's address, balance, .boo names and recent activity.", {}, async () => {
210
- const w = requireWallet();
211
- const a = await client.account(w.account).catch(() => null);
212
- const mine = a?.keys?.find((k) => k.key_id === w.keyId);
213
- 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)}…)`) };
1065
+ /** What `faucet` answers when the node says its chain has none. */
1066
+ const NO_FAUCET = "This chain has no faucet, so nothing was sent. Its FANG comes from accounts that already hold some: ask whoever runs the chain. The public testnet and dev chains (start_dev_node) have faucets.";
1067
+ tool("faucet", "Get 100 test FANG from the chain's faucet (to this agent's wallet, or any name/address). On a chain without one, it answers status no_faucet.", { to: z.string().optional().describe("a name or address; default: the account this agent acts for") }, async ({ to }) => {
1068
+ const client = await chain();
1069
+ let target = to;
1070
+ if (!target) {
1071
+ const w = await requireWallet();
1072
+ await w.client.addressPrefix();
1073
+ target = w.account;
1074
+ }
1075
+ try {
1076
+ const tx = await client.faucet(target);
1077
+ return { summary: clean(String(tx.summary ?? "")), tx_id: tx.id, explorer: explorer(`tx/${tx.id}`) };
1078
+ }
1079
+ catch (e) {
1080
+ // The node's answer on a chain whose genesis has no faucet (node/src/api.rs `faucet`).
1081
+ if (e instanceof ApiError && e.status === 400 && /no faucet/i.test(e.message))
1082
+ return { status: "no_faucet", summary: NO_FAUCET };
1083
+ throw e;
1084
+ }
214
1085
  });
215
- 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 }) => {
216
- const target = to ?? requireWallet().account;
217
- const tx = await client.faucet(target);
218
- return { summary: tx.summary, tx_id: tx.id, explorer: `${RPC}/#/tx/${tx.id}` };
1086
+ 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. Paying an address that has no account yet also burns the account creation fee (0.1 FANG on today's chains) to open it: the result reports account_opening_fee_burned and total_cost (amount, fee and burn). Counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY (with the fee and that burn). What the chain would refuse (a session key's limit, FANG that's locked) is refused before signing. If it comes back \"pending\", it was submitted: check tx_status, never send it again.", {
1087
+ to: z.string().describe("a .boo name or an address of this network"),
1088
+ amount: z.string().describe("FANG, e.g. \"1.5\" (up to 9 decimals)"),
1089
+ memo: z.string().max(256).optional().describe("public and permanent: anyone can read it, forever. Never put personal data in it"),
1090
+ }, async ({ to, amount, memo }) => {
1091
+ const drops = amountArg(amount);
1092
+ if (drops <= 0n)
1093
+ throw new Error("send more than 0");
1094
+ const w = await requireWallet();
1095
+ otherNetwork(to, await w.client.addressPrefix());
1096
+ // With witnesses a name's proven owner is signed; without, the name itself, which the chain
1097
+ // resolves when the tx runs: a lying node can't point it at another account now, though one
1098
+ // holding the signed tx could release it later in its validity window (audit gap-7-1,
1099
+ // review-r3-clients-2).
1100
+ const rc = await w.client.recipient(to);
1101
+ if (rc.address === w.account)
1102
+ throw new Error(`${clean(to, 100)} is the account this agent acts for (${w.account}): a payment to itself only pays a fee. Nothing was signed.`);
1103
+ const opening = await openingFee(w.client, rc.address);
1104
+ const r = await submitCapped(w, { type: "transfer", to: rc.signed, amount: drops.toString() }, { amount: drops, extra: opening, outflow: drops }, memo ?? "");
1105
+ const view = { ...recipientView(rc, "recipient"), ...costView(r, drops, opening) };
1106
+ return isInFlight(r) ? { ...r, ...view } : txResult(r, view);
219
1107
  });
220
- 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 }) => {
221
- const drops = parseFang(amount);
222
- if (drops > MAX_SEND)
223
- throw new Error(`amount exceeds this agent's per-send limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
224
- const today = spentToday();
225
- if (today.spent + drops > MAX_DAILY) {
226
- 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)`);
227
- }
228
- const tx = await requireWallet().send(to, amount, { memo });
229
- mkdirSync(HOME, { recursive: true });
230
- writeFileSync(SPEND_FILE, JSON.stringify({ day: today.day, spent: (today.spent + drops + BigInt(tx.fee)).toString() }), { mode: 0o600 });
231
- return { summary: tx.summary, fee: tx.fee_display, tx_id: tx.id, block: tx.height, explorer: `${RPC}/#/tx/${tx.id}` };
1108
+ 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)));
1109
+ 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 paid to the treasury. Names are registered for a year at a time, are public, and go to whoever asks first. The price counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY. Session keys can't register names (refused before signing).", { name: z.string().describe("e.g. \"vlad\" or \"vlad.boo\"") }, async ({ name }) => {
1110
+ const w = await requireWallet();
1111
+ const n = bareName(name);
1112
+ const { price } = await namePrices(w.client, n);
1113
+ return txResult(await submitCapped(w, { type: "register_name", name: n }, { amount: price, outflow: price, what: "the name's price" }), { price: formatFang(price), paid_to: "the treasury" });
232
1114
  });
233
- tool("check_name", "Check whether a .boo name is available and what it costs.", { name: z.string() }, ({ name }) => client.name(name));
234
- 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 }) => {
235
- const tx = await requireWallet().registerName(name);
236
- return { summary: tx.summary, tx_id: tx.id, explorer: `${RPC}/#/tx/${tx.id}` };
1115
+ tool("renew_name", "Extend a .boo name the account this agent acts for owns, by 1–10 years (at the chain's renewal price per year, paid to the treasury; counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY). Names that aren't renewed expire after a grace period and anyone can register them. Session keys can't renew names.", { name: z.string().describe("e.g. \"vlad.boo\""), years: z.number().int().min(1).max(10).default(1) }, async ({ name, years }) => {
1116
+ const w = await requireWallet();
1117
+ const n = bareName(name);
1118
+ const { renewal, info } = await namePrices(w.client, n);
1119
+ const cost = renewal * BigInt(years);
1120
+ return txResult(await submitCapped(w, { type: "renew_name", name: n, years }, { amount: cost, outflow: cost, what: "the renewal" }), { cost: formatFang(cost), paid_to: "the treasury", ...(typeof info?.expires_at === "number" ? { expired_at_before: info.expires_at } : {}) });
237
1121
  });
238
- 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 }) => {
1122
+ const LOOKUP_TYPES = new Set(["block", "tx", "account", "name", "program", "code"]);
1123
+ tool("lookup", `Look up anything — a .boo name, address, block number/hash, tx id, program address or stored code hash — and get a plain-English explanation. ${UNTRUSTED_NOTE}`, { query: z.string() }, async ({ query }) => {
1124
+ const client = await chain();
239
1125
  const hit = await client.search(query);
240
- 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);
241
- return { type: hit.type, explorer: `${RPC}/#/${hit.type}/${hit.id}`, ...data };
1126
+ const type = String(hit?.type ?? "");
1127
+ const id = String(hit?.id ?? "");
1128
+ if (!LOOKUP_TYPES.has(type))
1129
+ throw new Error(`nothing found for ${clean(query, 100)}`);
1130
+ // A program or a stored code is read by the SDK, which checks it, and shown through untrusted.ts (never the node's JSON as is).
1131
+ if (type === "program" || type === "code") {
1132
+ const data = type === "program" ? await programShown(client, id) : codeView(await client.code(id));
1133
+ return { type, ...(type === "program" ? { explorer: explorer(`program/${encodeURIComponent(id)}`) } : {}), data, note: PROGRAM_NOTE };
1134
+ }
1135
+ const data = type === "block" ? await client.block(id) : type === "tx" ? await client.tx(id) : type === "account" ? await client.account(id) : await client.name(id);
1136
+ // The node's JSON is nested (it can't override `type` or `explorer`), cleaned and labeled.
1137
+ return { type, explorer: explorer(`${type}/${encodeURIComponent(id)}`), data: chainData(data), note: UNTRUSTED_NOTE };
1138
+ });
1139
+ // ---- programs (docs/PROGRAMS-V0.md §8.5): third-party WebAssembly, read only. No tool here signs a program transaction: an agent's
1140
+ // calls need the confirmation flow of PRG 11.5 (the user is asked, an allowlist), a later release. What a program or its deployer
1141
+ // chose reaches the agent only as untrusted_* (untrusted.ts), and no description below holds any of it. ----
1142
+ /**
1143
+ * A program as `program_show` shows it. With BLOCKULA_WITNESSES its record is proven (its Merkle proof against the witnesses' state
1144
+ * roots), and so is its account's balance: the result is built from what was proven, and the node's own reading must agree with the
1145
+ * proof on every field no transaction changes (`provenDifferences`), else it's refused. What isn't proven (methods and types, the code's
1146
+ * size, the names beside addresses) is the node's word, and the result says so. Without witnesses it's all one node's word, checked
1147
+ * for consistency by the SDK (its version digest is the one its own code, interface and version compute), and says so.
1148
+ */
1149
+ async function programShown(client, address) {
1150
+ const info = await client.program(address);
1151
+ if (!client.witnesses.length)
1152
+ return programView(info);
1153
+ const witnesses = client.witnesses;
1154
+ const record = await client.provenProgram(address, { witnesses });
1155
+ if (!record)
1156
+ throw new Error(`${address} isn't a program: its record is provably absent against ${witnesses.length} witness(es), whatever ${RPC} says`);
1157
+ const odd = provenDifferences(info, record);
1158
+ if (odd.length)
1159
+ throw new Error(`${RPC} and its witnesses disagree about the program at ${address}: ${odd.join("; ")}. Nothing it said was used`);
1160
+ // The balance, proven at its own block (a program holds an account from its create on; none proves 0).
1161
+ const account = await client.provenRecord(NAMESPACES.ACCOUNTS, address, { witnesses });
1162
+ return programView(info, { record, balance: { drops: (account.value === null ? 0n : decodeAccount(account.value).balance).toString(), height: account.height }, witnesses: witnesses.length });
1163
+ }
1164
+ tool("program_show", "A program on this chain by its address (never a .boo name): its record (who created it and when, its code and interface hashes, its version), who could upgrade it, what it holds and stores, its methods (kinds and types as the chain enforces them; the names are the deployer's) and the version digest a call binds. Read only: this MCP signs no program transaction. Every program-chosen name is an untrusted_* field. " + PROGRAM_NOTE, { address: z.string().describe("the program's address (boo1… or tboo1…)") }, async ({ address }) => {
1165
+ const client = await chain();
1166
+ return { ...(await programShown(client, address)), explorer: explorer(`program/${encodeURIComponent(address)}`) };
242
1167
  });
1168
+ tool("program_view", "Run a program's read-only (`view`) method on the node, as of its newest block: nothing is signed, sent or paid. Returns what the method returned as the program's own claim (untrusted_return_data, hex), or how it failed (numbers, never engine text). A method that changes state is a transaction, which this MCP doesn't sign. Some nodes serve no views (403) and each client may run 60 a minute (429). " + PROGRAM_NOTE, {
1169
+ address: z.string().describe("the program's address (boo1… or tboo1…)"),
1170
+ method: z.string().regex(/^[a-z][a-z0-9_]{0,31}$/).describe("a `view` method of its interface (see program_show)"),
1171
+ input: z.string().regex(/^([0-9a-f]{2})*$/).max(65536).default("").describe("what the method reads, lowercase hex of whole bytes (default none)"),
1172
+ bindings: z.array(z.object({ program: z.string(), version_digest: z.string().regex(/^[0-9a-f]{64}$/).optional() })).max(16).default([]).describe("other programs the view may reach, by address and the version digest they must be at (none: any version, which a read may take)"),
1173
+ fuel: z.number().int().min(1).optional().describe("the most fuel the view may use (the node caps it too)"),
1174
+ }, async ({ address, method, input, bindings, fuel }) => {
1175
+ const client = await chain();
1176
+ const v = await client.view(address, method, input, { bindings: bindings.map((b) => ({ program: b.program, versionDigest: b.version_digest })), fuel });
1177
+ return viewView(address, method, v, RPC);
1178
+ });
1179
+ tool("program_code", "A stored program code by its hash (a program's `code.wasm_hash`): its size, the methods its exported functions carry (untrusted_exports: the deployer's names), who uploaded it and for what deposit, how many programs use it and from which block it's usable. Read only. The code's bytes aren't returned. " + PROGRAM_NOTE, { hash: z.string().regex(/^[0-9a-f]{64}$/).describe("64 lowercase hex characters") }, async ({ hash }) => codeView(await (await chain()).code(hash)));
243
1180
  tool("latest_blocks", "The most recent blocks, summarized.", { limit: z.number().int().min(1).max(50).default(10) }, async ({ limit }) => {
244
- const blocks = await client.blocks(limit);
245
- return blocks.map((b) => `${b.summary} · ${b.hash.slice(0, 12)}…`);
1181
+ const blocks = await (await chain()).blocks(limit);
1182
+ return (Array.isArray(blocks) ? blocks : []).map((b) => `${clean(String(b.summary ?? ""), 200)} · ${clean(String(b.hash ?? "")).slice(0, 12)}…`);
1183
+ });
1184
+ // ---- staking ----
1185
+ 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 chain's unbonding period (21 days by default) 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 }) => {
1186
+ const drops = amountArg(amount);
1187
+ if (drops <= 0n)
1188
+ throw new Error("delegate more than 0");
1189
+ const w = await requireWallet();
1190
+ const { res, confirm } = await resolved(w.client, validator, "validator");
1191
+ if (confirm)
1192
+ return confirm;
1193
+ const r = await submitCapped(w, { type: "delegate", validator: res.address, amount: drops.toString() }, { amount: drops, resolutions: [res] });
1194
+ return isInFlight(r) ? { ...r, ...resolvedView(res, "validator") } : txResult(r, { ...resolvedView(res, "validator"), next: "my_stake shows the stake and its value" });
246
1195
  });
247
- 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.", { status: z.enum(["open", "awarded", "cancelled", "expired"]).default("open") }, async ({ status }) => {
248
- const res = await client.hunts(status);
249
- const hunts = (Array.isArray(res) ? res : res.hunts ?? []);
250
- return hunts.map((h) => ({ hunt_id: h.id, bounty: h.amount_display, title: h.title, issue: h.issue_url, funders: h.funders?.length ?? 0, explorer: `${RPC}/#/hunt/${h.id}` }));
1196
+ 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 }) => {
1197
+ const drops = amount === "all" ? null : amountArg(amount);
1198
+ if (drops !== null && drops <= 0n)
1199
+ throw new Error("unstake more than 0 FANG, or \"all\"");
1200
+ const w = await requireWallet();
1201
+ const { res, confirm } = await resolved(w.client, validator, "validator");
1202
+ if (confirm)
1203
+ return confirm;
1204
+ const v = res.address;
1205
+ await w.client.addressPrefix();
1206
+ const d = stakeSummary(await moduleRead("staking", w.client.staking(w.account))).delegations.find((x) => x.validator === v);
1207
+ if (!d || BigInt(d.shares) === 0n)
1208
+ throw new Error(`${w.account} has no stake with ${clean(validator, 100)}`);
1209
+ // An amount is signed as an amount, and the chain converts it at the pool's price when it runs:
1210
+ // converting it here took the price from this node's report of the stake, and one that said a
1211
+ // 1,000 FANG stake was worth 10 FANG and a drop (or was 100× as many shares) turned "unstake
1212
+ // 10" into unbonding all of it (audit gap-7-5). The node's value only words a refusal now.
1213
+ if (drops !== null && drops > BigInt(d.value))
1214
+ 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".`);
1215
+ // Only the fee leaves the account (it counts toward the daily limit).
1216
+ const action = drops === null ? { type: "undelegate", validator: v, shares: d.shares } : { type: "undelegate", validator: v, shares: "0", amount: drops.toString() };
1217
+ const view = drops === null
1218
+ ? { ...resolvedView(res, "validator"), unbonding: `all ${d.shares} shares (the node reports they're worth ${formatFang(BigInt(d.value))})` }
1219
+ : { ...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)` };
1220
+ const r = await submitCapped(w, action, { amount: 0n, resolutions: [res] });
1221
+ return isInFlight(r) ? { ...r, ...view } : txResult(r, view);
1222
+ });
1223
+ tool("withdraw_stake", "Pay out every unbonding entry of the account this agent acts for that has finished unbonding.", {}, async () => {
1224
+ return txResult(await submitCapped(await requireWallet(), { type: "withdraw_unbonded" }, { amount: 0n }));
1225
+ });
1226
+ 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 () => {
1227
+ const w = await requireWallet();
1228
+ await w.client.addressPrefix();
1229
+ const [raw, status] = await Promise.all([moduleRead("staking", w.client.staking(w.account)), w.client.status()]);
1230
+ const s = stakeSummary(raw);
1231
+ return {
1232
+ account: w.account,
1233
+ height: status.height,
1234
+ summary: s.summary,
1235
+ staked: formatFang(BigInt(s.bonded)),
1236
+ unbonding: formatFang(BigInt(s.unbonding_total)),
1237
+ withdrawable_now: formatFang(BigInt(s.withdrawable)),
1238
+ 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 } : {}) })),
1239
+ unbonding_entries: s.unbonding.map((u) => ({ validator: u.validator, amount: formatFang(BigInt(u.amount)), completes_at_block: u.completes_at, ready: u.ready })),
1240
+ ...(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 })) } : {}),
1241
+ };
251
1242
  });
252
- 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 boo1 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 }) => {
253
- const tx = await requireWallet().linkGithub(handle, gist_id);
254
- const check = await client.github(handle).catch(() => null);
255
- return { summary: tx.summary, verified: check?.verified ?? false, explorer: `${RPC}/#/tx/${tx.id}` };
1243
+ // ---- governance ----
1244
+ /**
1245
+ * A proposal as the agent sees it. `does` is rendered here from the proposal's kind, the content a
1246
+ * vote binds (with its title and link): the node's own summary isn't covered by any digest, so a
1247
+ * node could make it say anything (audit gap-7-6), and it isn't relayed. `proposal_digest` is what
1248
+ * `vote` needs back: it refuses a proposal that reads differently by then.
1249
+ */
1250
+ const proposalView = (p) => ({
1251
+ id: p.id,
1252
+ untrusted_title: clean(p.title, 120),
1253
+ does: clean(describeKind(p.kind, { freeText: false }), 600),
1254
+ kind: chainData(p.kind),
1255
+ ...(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) }),
1256
+ status: clean(p.status, 40),
1257
+ proposer: clean(p.proposer_name ?? p.proposer ?? "", 100),
1258
+ aye_weight: formatFang(BigInt(p.aye)),
1259
+ nay_weight: formatFang(BigInt(p.nay)),
1260
+ turnout: formatFang(BigInt(p.turnout)),
1261
+ ...(p.quorum !== undefined ? { quorum: formatFang(BigInt(p.quorum)), quorum_reached: p.quorum_reached } : {}),
1262
+ ...(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})` : ""}` } : {}),
1263
+ ...(p.supermajority ? { needs: p.threshold_bps ? `${p.threshold_bps / 100}% of the weight` : "a supermajority" } : {}),
1264
+ voting_ends_at_block: p.voting_ends_at,
1265
+ ...(p.executes_at !== undefined ? { executes_at_block: p.executes_at } : {}),
1266
+ ...(p.outcome ? { outcome: clean(p.outcome, 300) } : {}),
1267
+ ...(p.url ? { untrusted_url: clean(p.url, 200) } : {}),
1268
+ explorer: explorer(`proposal/${p.id}`),
256
1269
  });
257
- tool("fund_hunt", "Add FANG to an open Hunt's bounty (crowdfunding). Refunded to you if the hunt is cancelled.", { hunt_id: z.number().int().min(0), amount: z.string() }, async ({ hunt_id, amount }) => {
258
- const drops = parseFang(amount);
259
- if (drops > MAX_SEND)
260
- throw new Error(`amount exceeds this agent's per-send limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
261
- const today = spentToday();
262
- if (today.spent + drops > MAX_DAILY)
263
- throw new Error(`this would exceed the agent's daily limit (BLOCKULA_MAX_DAILY=${process.env.BLOCKULA_MAX_DAILY ?? "100"} FANG)`);
264
- const tx = await requireWallet().fundHunt(hunt_id, amount);
265
- writeFileSync(SPEND_FILE, JSON.stringify({ day: today.day, spent: (today.spent + drops + BigInt(tx.fee)).toString() }), { mode: 0o600 });
266
- return { summary: tx.summary, explorer: `${RPC}/#/hunt/${hunt_id}` };
1270
+ 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 }) => {
1271
+ const res = await moduleRead("governance proposals", (await chain()).proposals({ status, limit }));
1272
+ const list = (Array.isArray(res.proposals) ? res.proposals : []);
1273
+ return { proposals: list.slice(0, limit).map((p) => proposalView(proposalSummary(p, res.params))), note: UNTRUSTED_NOTE };
1274
+ });
1275
+ 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 by default) 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 }) => {
1276
+ const drops = amountArg(amount);
1277
+ if (drops <= 0n)
1278
+ throw new Error("vote with more than 0");
1279
+ // The amount is locked, not spent: it's held to BLOCKULA_MAX_SEND, and only the fee counts toward the daily limit.
1280
+ const w = await requireWallet();
1281
+ // The vote binds the content list_proposals showed (its digest, passed back here), not a fresh
1282
+ // read: a node that showed one thing and answers another now is refused before anything is
1283
+ // signed, and on chain the digest must match the stored proposal (audit gap-7-6).
1284
+ const p = await w.client.proposal(proposal_id);
1285
+ if (!p?.kind || typeof p.kind !== "object")
1286
+ throw new Error(`the node doesn't report what proposal #${proposal_id} does, so the vote can't be bound to it`);
1287
+ let proposal_digest;
1288
+ try {
1289
+ proposal_digest = proposalDigest(p.kind, String(p.title ?? ""), typeof p.url === "string" ? p.url : "");
1290
+ }
1291
+ catch (e) {
1292
+ 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`);
1293
+ }
1294
+ if (proposal_digest !== shown) {
1295
+ 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.`);
1296
+ }
1297
+ const voted_on = { untrusted_title: clean(String(p.title ?? ""), 200), does: clean(describeKind(p.kind, { freeText: false }), 600), kind: chainData(p.kind), proposal_digest };
1298
+ 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" });
1299
+ 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}`) };
1300
+ });
1301
+ // ---- Hunts and the contribution pool ----
1302
+ /** Where to find work when no Hunt is open (AGENT-4). */
1303
+ const GOOD_FIRST = "https://github.com/jackdishman/blockula/issues?q=is%3Aopen+label%3Afamiliar-friendly";
1304
+ tool("list_hunts", described(`Bounties (Hunts) on GitHub issues, with FANG escrowed on-chain. Each links to an issue with acceptance criteria: implement it and open a PR. The Hunt's creator awards the bounty after the PR merges, to the account linked to the PR author's GitHub (link_github, with an owner key). Each Hunt says when it expires. Titles are written by whoever created the hunt. ${UNTRUSTED_NOTE}`), { status: z.enum(["open", "awarded", "cancelled", "expired"]).default("open") }, async ({ status }) => {
1305
+ const res = await (await chain()).hunts(status);
1306
+ const hunts = (Array.isArray(res) ? res : Array.isArray(res?.hunts) ? res.hunts : []);
1307
+ const funders = (f) => (Array.isArray(f) ? f.length : f && typeof f === "object" ? Object.keys(f).length : 0);
1308
+ return {
1309
+ 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), ...(safeInt(h.expires_at) !== undefined ? { expires_at_block: safeInt(h.expires_at) } : {}), explorer: explorer(`hunt/${Number(h.id)}`) })),
1310
+ ...(hunts.length === 0 ? { next: `No ${status} Hunts right now. Merged PRs to the contribution pool's repos can earn from the pool without a Hunt: contribution_pool lists them. Issues labeled familiar-friendly are a good start: ${GOOD_FIRST}` } : {}),
1311
+ note: `${UNTRUSTED_NOTE} ${NO_VALUE}`,
1312
+ };
1313
+ });
1314
+ tool("link_github", "Link a GitHub handle to the account this agent acts for, so Hunt awards and pool claims credit it. It needs an owner key: when this agent acts through a session key, ask the user to link GitHub in their wallet instead. 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}$/).describe("the GitHub username"), gist_id: z.string().regex(/^[0-9a-f]{20,40}$/).describe("the public gist's id (from its URL)") }, async ({ handle, gist_id }) => {
1315
+ const w = await requireWallet();
1316
+ const r = await submitCapped(w, { type: "link_github", handle, gist_id }, { amount: 0n });
1317
+ if (isInFlight(r))
1318
+ return r;
1319
+ const check = await w.client.github(handle).catch(() => null);
1320
+ return { summary: clean(String(r.summary ?? "")), tx_id: r.id, verified: check?.verified === true, ...(check?.verified === true ? {} : { next: `The link is on chain, but this node can't verify the gist yet${check?.reason ? ` (${clean(String(check.reason), 200)})` : ""}. Validators check it for every contribution claim, so fix it before submit_contribution.` }), explorer: explorer(`tx/${r.id}`) };
1321
+ });
1322
+ tool("fund_hunt", described(`Add FANG to an open Hunt's bounty (crowdfunding). Refunded to you if the hunt is cancelled.`), { hunt_id: z.number().int().min(0), amount: z.string() }, async ({ hunt_id, amount }) => {
1323
+ const drops = amountArg(amount);
1324
+ if (drops <= 0n)
1325
+ throw new Error("fund more than 0");
1326
+ const r = await submitCapped(await requireWallet(), { type: "fund_hunt", hunt_id, amount: drops.toString() }, { amount: drops, outflow: drops });
1327
+ return isInFlight(r) ? r : { summary: clean(String(r.summary ?? "")), fee: r.fee_display, tx_id: r.id, explorer: explorer(`hunt/${hunt_id}`) };
1328
+ });
1329
+ tool("contribution_pool", described(`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.`), {}, async () => {
1330
+ const c = await (await chain()).contributions();
1331
+ return {
1332
+ pool: chainData(c.pool?.balance_display),
1333
+ next_seance_pays_up_to: chainData(c.pool?.next_budget_display),
1334
+ repos: chainData(c.repos),
1335
+ claims: chainData(c.claims),
1336
+ 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,
1337
+ rules: chainData(c.rules),
1338
+ 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.",
1339
+ note: NO_VALUE,
1340
+ explorer: explorer("contribute"),
1341
+ };
1342
+ });
1343
+ tool("submit_contribution", described(`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, verified by its gist (checked first: refused when it isn't, unless force is true). Costs a bond (1 FANG, more with open claims): it's returned unless validators refute the claim (an unmerged PR, another author, a gist that doesn't verify) or the jury marks it spam. If you act for a user through a session key, they must first register that key as a Familiar in their wallet, or the claim is refused; the claim then credits them as your sponsor.`), { repo: z.string().regex(/^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/).describe("owner/name"), pr: z.number().int().min(1), force: z.boolean().default(false).describe("claim even though the GitHub link doesn't verify now (the bond is likely lost)") }, async ({ repo, pr, force }) => {
1344
+ const w = await requireWallet();
1345
+ await w.client.addressPrefix();
1346
+ const me = await w.client.account(w.account).catch(() => null);
1347
+ // CONTRIB-4: validators refute a claim whose author's link doesn't verify, and the bond goes to the pool.
1348
+ if (!force) {
1349
+ const handle = typeof me?.github?.handle === "string" ? me.github.handle : undefined;
1350
+ if (!handle)
1351
+ throw new Error(`${w.account} has no GitHub link, so validators would refute the claim and keep the bond. Link the PR author's GitHub first (link_github, or in the user's wallet). Nothing was signed.`);
1352
+ const check = await w.client.github(handle).catch(() => null);
1353
+ if (check?.verified !== true)
1354
+ throw new Error(`@${clean(handle, 40)}'s link to ${w.account} doesn't verify${check?.reason ? ` (${clean(String(check.reason), 200)})` : ""}, so validators would likely refute the claim and keep the bond. Fix the gist, or pass force: true to claim anyway. Nothing was signed.`);
1355
+ }
1356
+ // node/src/poc.rs `claim_bond`: CLAIM_BOND (1 FANG) per claim, more with open claims; never counted below it.
1357
+ const quoted = me?.contribution?.next_claim_bond;
1358
+ const bond = /^\d+$/.test(String(quoted ?? "")) && BigInt(quoted) > 1000000000n ? BigInt(quoted) : 1000000000n;
1359
+ const r = await submitCapped(w, { type: "submit_claim", repo, pr }, { amount: bond, outflow: bond, what: `the claim bond (${formatFang(bond)})` });
1360
+ if (isInFlight(r))
1361
+ return r;
1362
+ const claims = await w.client.claims({ account: w.account }).catch(() => null);
1363
+ const claim = (Array.isArray(claims?.claims) ? claims.claims : []).find((c) => c.repo === repo.toLowerCase() && c.pr === pr);
1364
+ return { summary: clean(String(r.summary ?? "")), tx_id: r.id, 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 ?? ""}`) };
1365
+ });
1366
+ tool("my_contributions", described(`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.`), {}, async () => {
1367
+ const w = await requireWallet();
1368
+ await w.client.addressPrefix();
1369
+ const a = await w.client.account(w.account);
1370
+ const p = a.contribution ?? {};
1371
+ return {
1372
+ account: clean(String(a.account?.name ?? a.account?.address ?? w.account)),
1373
+ soul: chainData(p.soul?.display),
1374
+ vouched_by: chainData(p.vouched_by?.voucher?.name ?? p.vouched_by?.voucher?.short ?? null),
1375
+ juror: chainData(p.juror),
1376
+ rewards: chainData({ withdrawable: p.rewards?.withdrawable_display, still_vesting: p.rewards?.unvested_display }),
1377
+ 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) } : {}) })),
1378
+ next_claim_bond: chainData(p.next_claim_bond_display),
1379
+ note: `${UNTRUSTED_NOTE} ${NO_VALUE}`,
1380
+ };
267
1381
  });
1382
+ if (BAD_CONFIG)
1383
+ console.error(`blockula-mcp: ${BAD_CONFIG.message} (every tool answers with this until the configuration is fixed)`);
268
1384
  await server.connect(new StdioServerTransport());