@blockula/mcp 0.5.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -19
- package/dist/accounts.d.ts +44 -0
- package/dist/accounts.js +118 -0
- package/dist/budget.d.ts +104 -0
- package/dist/budget.js +169 -0
- package/dist/index.js +548 -207
- package/dist/locks.d.ts +61 -0
- package/dist/locks.js +222 -0
- package/dist/network.d.ts +82 -13
- package/dist/network.js +170 -38
- package/dist/spend.d.ts +16 -6
- package/dist/spend.js +60 -26
- package/dist/untrusted.d.ts +56 -3
- package/dist/untrusted.js +160 -10
- package/npm-shrinkwrap.json +22 -22
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
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 (
|
|
4
|
-
// claude mcp add blockula -- npx -y @blockula/mcp
|
|
3
|
+
// claude mcp add blockula -- npx -y @blockula/mcp (a `blockula dev` chain)
|
|
4
|
+
// claude mcp add blockula -e BLOCKULA_RPC=https://<node> -- npx -y @blockula/mcp (a chain you launched)
|
|
5
5
|
//
|
|
6
6
|
// Env:
|
|
7
|
-
// BLOCKULA_NETWORK
|
|
8
|
-
// BLOCKULA_RPC node URL (default:
|
|
9
|
-
//
|
|
10
|
-
// BLOCKULA_CHAIN_ID / BLOCKULA_GENESIS_HASH pin the chain the node must be on
|
|
7
|
+
// BLOCKULA_NETWORK local (default), testnet or custom (same as --network)
|
|
8
|
+
// BLOCKULA_RPC node URL (default: http://localhost:7070 for local; required for testnet,
|
|
9
|
+
// whose public node was retired, and for custom)
|
|
10
|
+
// BLOCKULA_CHAIN_ID / BLOCKULA_GENESIS_HASH pin the chain the node must be on (a node that says
|
|
11
|
+
// it's on blockula-1 is refused unless BLOCKULA_CHAIN_ID=blockula-1)
|
|
11
12
|
// BLOCKULA_WITNESSES other nodes of the same chain (comma-separated URLs): .boo names that get
|
|
12
13
|
// signed (send, stake, unstake, use_account) are proven against them, and the
|
|
13
14
|
// height each tx is signed against must not be ahead of their heads (nor, in
|
|
14
15
|
// any case, past what the chain's genesis clock allows). Without
|
|
15
16
|
// them a send signs the name itself (the chain resolves it when it runs), and
|
|
16
17
|
// where an address is needed (stake, unstake, use_account) a name is the
|
|
17
|
-
// node's word: on
|
|
18
|
-
//
|
|
18
|
+
// node's word: on blockula-1 (BLOCKULA_CHAIN_ID=blockula-1) the address must be
|
|
19
|
+
// confirmed first. Unset, no node is a witness (no chain has public RPCs
|
|
20
|
+
// built in); `none` says so explicitly.
|
|
19
21
|
// BLOCKULA_HOME where the agent wallet lives (default ~/.blockula)
|
|
20
22
|
// BLOCKULA_MAX_SEND max FANG one call moves (sent, staked, escrowed, paid for a name, bonded; default 100)
|
|
21
|
-
// BLOCKULA_MAX_DAILY max FANG leaving the account per UTC day through this MCP, fees
|
|
22
|
-
// (default BLOCKULA_MAX_SEND + 1 FANG for fees, so one full-size call fits)
|
|
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)
|
|
23
25
|
// BLOCKULA_TIMEOUT_MS how long one request to the node may take (default 15000, at most 2147483647)
|
|
24
26
|
// BLOCKULA_TX_WAIT_MS how long a tx tool waits for finality before answering "pending" (default 15000)
|
|
25
27
|
// BLOCKULA_BIN path to the `blockula` binary (default: of ~/.blockula/bin/blockula from `blockula release
|
|
@@ -29,43 +31,38 @@
|
|
|
29
31
|
// BLOCKULA_RELEASE_KEY_ID the release signing key id to trust (until it's built in; it must
|
|
30
32
|
// agree with the built-in one and with ~/.blockula/release-key-id, if pinned)
|
|
31
33
|
// The network never changes while the server runs (WAL-4): see network.ts. Spending caps: see
|
|
32
|
-
// spend.ts (reserved before a tx is sent)
|
|
33
|
-
//
|
|
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).
|
|
34
38
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
35
39
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
36
|
-
import { ApiError, Client, HeightAheadError,
|
|
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";
|
|
37
41
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
38
42
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39
43
|
import { homedir, tmpdir } from "node:os";
|
|
40
44
|
import { dirname, join } from "node:path";
|
|
41
45
|
import { z } from "zod";
|
|
42
|
-
import { KNOWN_CLOCKS, KNOWN_GENESIS, KnownChains, clockFrom, completePin, effectivePin, mismatch, mismatchError, resolveNetwork } from "./network.js";
|
|
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";
|
|
43
50
|
import { PIN_FILE, REPO, RELEASE_KEY_ID, binPaths, checkBinaryVersion, fetchVerifiedRelease, latestTagFrom, pickNodeBinary, readReleasePin, releaseKeyIdFrom } from "./release.js";
|
|
44
51
|
import { SignedTxs, onChain } from "./signed.js";
|
|
45
|
-
import { SpendLedger } from "./spend.js";
|
|
46
|
-
import { SERVER_INSTRUCTIONS, UNTRUSTED_NOTE, chainData, clean } from "./untrusted.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";
|
|
47
54
|
const HOME = process.env.BLOCKULA_HOME ?? join(homedir(), ".blockula");
|
|
48
55
|
const WALLET_FILE = join(HOME, "agent-wallet.json");
|
|
49
|
-
const
|
|
56
|
+
const SPEND_FILE = join(HOME, "agent-spend.json");
|
|
50
57
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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).
|
|
54
61
|
*/
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const msEnv = (name, fallback) => {
|
|
60
|
-
const v = process.env[name];
|
|
61
|
-
if (v === undefined || v === "")
|
|
62
|
-
return fallback;
|
|
63
|
-
if (!/^\d+$/.test(v))
|
|
64
|
-
throw new Error(`${name} must be a positive number of milliseconds`);
|
|
65
|
-
return checkTimeoutMs(Number(v), name);
|
|
66
|
-
};
|
|
67
|
-
const TIMEOUT_MS = msEnv("BLOCKULA_TIMEOUT_MS", 15_000);
|
|
68
|
-
const TX_WAIT_MS = msEnv("BLOCKULA_TX_WAIT_MS", 15_000);
|
|
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);
|
|
69
66
|
const KNOWN_CHAINS = new KnownChains(join(HOME, "known-chains.json"));
|
|
70
67
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
71
68
|
// install_node's binary has a path of its own: it never replaces the one `blockula release install`
|
|
@@ -89,8 +86,8 @@ function nodeBinary() {
|
|
|
89
86
|
}
|
|
90
87
|
/** Says which binary ran, and when it isn't this MCP's version. */
|
|
91
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.` : "."}`;
|
|
92
|
-
/**
|
|
93
|
-
const NO_VALUE = "
|
|
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.";
|
|
94
91
|
function releaseTarget() {
|
|
95
92
|
const key = `${process.platform}-${process.arch}`;
|
|
96
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;
|
|
@@ -105,14 +102,34 @@ catch (e) {
|
|
|
105
102
|
NET_ERROR = e;
|
|
106
103
|
}
|
|
107
104
|
const RPC = NET?.rpc ?? "";
|
|
105
|
+
/** A Hunt or contribution tool's description: `text`, then NO_VALUE. */
|
|
106
|
+
const described = (text) => `${text} ${NO_VALUE}`;
|
|
108
107
|
let pinned = null;
|
|
109
108
|
/** The chain `pinned` is held to (tx_status reads a signed tx's record only on the chain it was signed for). */
|
|
110
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;
|
|
111
114
|
function network() {
|
|
112
|
-
if (!NET)
|
|
113
|
-
throw new Error(`The Blockula MCP isn't configured correctly: ${
|
|
115
|
+
if (!NET || BAD_CONFIG)
|
|
116
|
+
throw new Error(`The Blockula MCP isn't configured correctly: ${BAD_CONFIG?.message}`);
|
|
114
117
|
return NET;
|
|
115
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
|
+
}
|
|
116
133
|
/**
|
|
117
134
|
* The client for the configured network, pinned to its chain. The first call checks the node's
|
|
118
135
|
* chain id, genesis hash and prefix against the network's pins (and what was remembered for it);
|
|
@@ -124,17 +141,30 @@ async function chain() {
|
|
|
124
141
|
const net = network();
|
|
125
142
|
const pin = effectivePin(net, KNOWN_CHAINS.get(net));
|
|
126
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);
|
|
127
149
|
const why = mismatch(pin, status);
|
|
128
150
|
if (why)
|
|
129
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);
|
|
130
156
|
const full = completePin(pin, status);
|
|
131
157
|
KNOWN_CHAINS.save(net, full);
|
|
132
158
|
const pins = { chainId: full.chainId, genesisHash: full.genesisHash, prefix: full.prefix, timeoutMs: TIMEOUT_MS };
|
|
133
159
|
// Witnesses are held to the same chain; with them, names are proven (Client.resolution) and a
|
|
134
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).
|
|
135
163
|
const genesisClock = await clockOf(net, full);
|
|
136
|
-
|
|
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)) });
|
|
137
166
|
pinnedChain = { chainId: full.chainId, genesisHash: full.genesisHash, prefix: full.prefix };
|
|
167
|
+
activeWitnesses = witnesses;
|
|
138
168
|
return pinned;
|
|
139
169
|
}
|
|
140
170
|
/**
|
|
@@ -181,91 +211,194 @@ async function moduleRead(what, read) {
|
|
|
181
211
|
throw e;
|
|
182
212
|
}
|
|
183
213
|
}
|
|
184
|
-
// ---- spending caps (BLOCKULA_MAX_SEND per call, BLOCKULA_MAX_DAILY per UTC day) ----
|
|
185
|
-
const LEDGER = new SpendLedger(SPEND_FILE,
|
|
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);
|
|
186
216
|
/** The txs this MCP signed (their valid_until, for tx_status): see signed.ts. */
|
|
187
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"));
|
|
188
221
|
/**
|
|
189
|
-
* Whether this is
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* every signature it makes is
|
|
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.
|
|
194
228
|
*/
|
|
195
|
-
async function
|
|
196
|
-
return
|
|
229
|
+
async function onBlockula1(client) {
|
|
230
|
+
return (await client.status()).chain_id === BLOCKULA_1;
|
|
197
231
|
}
|
|
198
232
|
const isInFlight = (r) => typeof r === "object" && r !== null && "tx_id" in r && ["pending", "unknown", "dropped"].includes(r.status);
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
* (its fee was charged), dropped, pending, timed out, unknown, and refused too, since the node's
|
|
204
|
-
* refusal is only its word and it may relay the signed tx anyway (audit F68, F72, F74). A refused
|
|
205
|
-
* tx stops counting twice once this MCP signs another tx with its nonce (the next send, after an
|
|
206
|
-
* honest refusal): see spend.ts.
|
|
207
|
-
*/
|
|
208
|
-
async function submitCapped(w, action, spend, memo = "") {
|
|
209
|
-
const perCall = spend.perCall ?? spend.amount;
|
|
210
|
-
if (perCall > MAX_SEND)
|
|
211
|
-
throw new Error(`${spend.what ?? "amount"} (${formatFang(perCall)}) exceeds this agent's per-call limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
|
|
212
|
-
const tx = await w.build(action, memo, { resolutions: spend.resolutions ?? [] });
|
|
213
|
-
const id = txId(tx);
|
|
214
|
-
const maxFee = BigInt(tx.fee);
|
|
215
|
-
const slot = { account: tx.from, nonce: tx.nonce };
|
|
216
|
-
const reservation = LEDGER.reserve(spend.amount + (spend.extra ?? 0n) + maxFee, slot);
|
|
217
|
-
const counted = formatFang(LEDGER.held(slot));
|
|
218
|
-
// Before it can reach any node: tx_status reads its valid_until from here (review-fix-clients-2).
|
|
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) {
|
|
219
237
|
try {
|
|
220
|
-
|
|
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;
|
|
221
243
|
}
|
|
222
244
|
catch {
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
const inFlight = (status, why) => ({
|
|
226
|
-
status,
|
|
227
|
-
tx_id: id,
|
|
228
|
-
valid_until: tx.valid_until,
|
|
229
|
-
counted_toward_daily_limit: counted,
|
|
230
|
-
next: `${why} It can still execute until block ${tx.valid_until}, so it already counts toward the daily limit. Do NOT send it again: check it with tx_status (tx_id ${id}) and tell the user it isn't confirmed yet.`,
|
|
231
|
-
explorer: explorer(`tx/${id}`),
|
|
232
|
-
});
|
|
233
|
-
try {
|
|
234
|
-
await w.client.submit(tx, false);
|
|
245
|
+
return undefined;
|
|
235
246
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
}
|
|
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 });
|
|
252
|
+
}
|
|
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));
|
|
242
273
|
}
|
|
243
|
-
// Admitted, but the first read of its status failed: wait for it below like any other.
|
|
244
|
-
if (!(e instanceof TxPendingError))
|
|
245
|
-
return inFlight("unknown", `The node didn't answer clearly (${clean(String(e?.message ?? e), 200)}), so the tx (${id}) may or may not have arrived.`);
|
|
246
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.`);
|
|
247
372
|
try {
|
|
248
373
|
const done = await w.client.waitForTx(id, TX_WAIT_MS, tx.valid_until);
|
|
374
|
+
await ran();
|
|
249
375
|
// Only a whole number of drops below the max fee gives anything back (audit gap-7-4).
|
|
250
376
|
const charged = /^\d+$/.test(String(done.fee ?? "")) ? BigInt(done.fee) : maxFee;
|
|
251
377
|
if (charged < maxFee)
|
|
252
378
|
LEDGER.release(reservation, maxFee - charged);
|
|
379
|
+
if (done && typeof done === "object")
|
|
380
|
+
COST.set(done, { fee: charged < maxFee ? charged : maxFee, final: true });
|
|
253
381
|
return done;
|
|
254
382
|
}
|
|
255
383
|
catch (e) {
|
|
256
384
|
// Dropped from this node's mempool unrun: nothing was charged, but it isn't dead (audit review-fix-clients-2).
|
|
257
|
-
if (e instanceof TxFailedError && e.dropped)
|
|
385
|
+
if (e instanceof TxFailedError && e.dropped) {
|
|
386
|
+
await settle();
|
|
258
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.`);
|
|
259
|
-
|
|
388
|
+
}
|
|
389
|
+
if (e instanceof TxFailedError) {
|
|
390
|
+
await ran();
|
|
260
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
|
+
}
|
|
261
393
|
return inFlight("pending", `Submitted (tx ${id}), but not final after ${Math.round(TX_WAIT_MS / 1000)} s.`);
|
|
262
394
|
}
|
|
263
395
|
}
|
|
264
396
|
/**
|
|
265
|
-
* What opening `to`'s account would burn on top of a transfer (0 if it exists)
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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.
|
|
269
402
|
*/
|
|
270
403
|
async function openingFee(client, to) {
|
|
271
404
|
let a = null;
|
|
@@ -276,6 +409,9 @@ async function openingFee(client, to) {
|
|
|
276
409
|
if (!(e instanceof ApiError && e.status === 404))
|
|
277
410
|
throw e;
|
|
278
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);
|
|
279
415
|
const exists = (/^\d+$/.test(String(a?.balance ?? "")) && BigInt(a.balance) > 0n) || (Number.isSafeInteger(a?.nonce) && a.nonce > 0);
|
|
280
416
|
return exists ? 0n : reported((await client.fees()).account_creation_fee, "the account creation fee");
|
|
281
417
|
}
|
|
@@ -303,14 +439,14 @@ async function namePrices(client, name) {
|
|
|
303
439
|
/**
|
|
304
440
|
* A name or address resolved for a field that needs an address (staking's validator, the account
|
|
305
441
|
* use_account acts for; audit gap-7-1). With BLOCKULA_WITNESSES a name is proven against them
|
|
306
|
-
* (`Client.resolution`). Without, it's the node's word: on
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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.
|
|
310
446
|
*/
|
|
311
447
|
async function resolved(client, input, role) {
|
|
312
448
|
const res = await client.resolution(input);
|
|
313
|
-
if (!res.proven && (await
|
|
449
|
+
if (!res.proven && (await onBlockula1(client))) {
|
|
314
450
|
return {
|
|
315
451
|
confirm: {
|
|
316
452
|
status: "not_sent",
|
|
@@ -318,7 +454,7 @@ async function resolved(client, input, role) {
|
|
|
318
454
|
[role]: clean(input, 100),
|
|
319
455
|
resolves_to: res.address,
|
|
320
456
|
resolved_by: clean(res.how, 300),
|
|
321
|
-
next: `Nothing was signed.
|
|
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.)`,
|
|
322
458
|
},
|
|
323
459
|
};
|
|
324
460
|
}
|
|
@@ -332,34 +468,61 @@ const recipientView = (r, role) => r.name === null ? {} : r.byChain ? { [`${role
|
|
|
332
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}`) };
|
|
333
469
|
const server = new McpServer({ name: "blockula", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
334
470
|
const text = (v) => ({ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toString() : x), 2) }] });
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const f = readWalletFile();
|
|
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);
|
|
340
475
|
if (!f)
|
|
341
476
|
return null;
|
|
342
|
-
const w = Wallet.fromSeed(f
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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;
|
|
346
490
|
}
|
|
347
491
|
async function requireWallet() {
|
|
348
492
|
const w = await loadWallet();
|
|
349
493
|
if (!w)
|
|
350
|
-
throw new Error("No agent wallet yet
|
|
494
|
+
throw new Error("No agent wallet yet: call create_wallet first.");
|
|
351
495
|
return w;
|
|
352
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 };
|
|
353
513
|
function tool(name, description, shape, run) {
|
|
354
|
-
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) => {
|
|
355
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}`);
|
|
356
519
|
return text(await run(args));
|
|
357
520
|
}
|
|
358
521
|
catch (e) {
|
|
359
522
|
// Node errors are relayed, so they're cleaned like any chain text.
|
|
360
523
|
const msg = clean(String(e?.message ?? e), 1000);
|
|
361
524
|
const hint = /fetch failed|ECONNREFUSED/.test(msg)
|
|
362
|
-
? ` (no node at ${RPC}${NET?.key === "local" ? " — call start_dev_node" : ""})`
|
|
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` : ""})`
|
|
363
526
|
: e instanceof RequestTimeoutError
|
|
364
527
|
? ` (the node at ${RPC} isn't answering)`
|
|
365
528
|
: e instanceof HeightAheadError
|
|
@@ -369,7 +532,7 @@ function tool(name, description, shape, run) {
|
|
|
369
532
|
}
|
|
370
533
|
}));
|
|
371
534
|
}
|
|
372
|
-
tool("status", "Blockula network status: which network and node you're connected to (and the chain it's pinned to), chain id, height, supply, fees burned, and the crypto in use. The network, its pins and how to change them are this MCP's own facts; `node_reports` is what the node says.", {}, async () => {
|
|
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 () => {
|
|
373
536
|
const net = network();
|
|
374
537
|
const client = await chain();
|
|
375
538
|
const s = await client.status();
|
|
@@ -377,11 +540,13 @@ tool("status", "Blockula network status: which network and node you're connected
|
|
|
377
540
|
// this MCP states about the network (audit F73).
|
|
378
541
|
const pick = ["chain_id", "height", "finalized_height", "syncing", "epoch", "active_validators", "supply_display", "burned_display", "version", "protocol_version", "head_age_ms", "crypto"];
|
|
379
542
|
return {
|
|
380
|
-
network: net.
|
|
381
|
-
real_money: net.mainnet || s.chain_id === MAINNET_CHAIN_ID,
|
|
543
|
+
network: networkLabel(net, pinnedChain.chainId),
|
|
382
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.`,
|
|
383
548
|
pinned: chainData({ chain_id: s.chain_id, genesis_hash: s.genesis_hash, address_prefix: s.address_prefix }),
|
|
384
|
-
how_to_change: "The network is fixed while this MCP runs. The user changes it in the MCP's configuration (--network
|
|
549
|
+
how_to_change: "The network is fixed while this MCP runs. The user changes it in the MCP's configuration (--network local|testnet|custom, or BLOCKULA_RPC).",
|
|
385
550
|
explorer: net.rpc,
|
|
386
551
|
mcp_version: VERSION,
|
|
387
552
|
node_reports: chainData(Object.fromEntries(pick.filter((k) => k in s).map((k) => [k, s[k]]))),
|
|
@@ -656,7 +821,7 @@ tool("start_dev_node", "Start a local Blockula dev chain (faucet + built-in expl
|
|
|
656
821
|
}
|
|
657
822
|
throw new Error(`node didn't start. See ${join(HOME, "dev-node.log")}`);
|
|
658
823
|
});
|
|
659
|
-
tool("install_node", "Download the prebuilt `blockula` node + CLI for this machine from the official GitHub release
|
|
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 }) => {
|
|
660
825
|
const target = releaseTarget();
|
|
661
826
|
if (!target)
|
|
662
827
|
throw new Error(`No prebuilt node for ${process.platform}/${process.arch}. Build from source: https://github.com/${REPO}`);
|
|
@@ -719,91 +884,194 @@ tool("install_node", "Download the prebuilt `blockula` node + CLI for this machi
|
|
|
719
884
|
rmSync(tmp, { recursive: true, force: true });
|
|
720
885
|
}
|
|
721
886
|
});
|
|
722
|
-
tool("summon_chain", "Create a brand-new Blockula chain (fresh genesis + validator key) that you can run as your own network.
|
|
887
|
+
tool("summon_chain", "Create a brand-new Blockula chain (fresh genesis + validator key) that you can run as your own network. `name`, `description` and `token` (its ticker) are what wallets and explorers show; the genesis hash binds them. A person launching one by hand runs `blockula launch`.", {
|
|
888
|
+
chain_id: z.string().regex(/^[a-z0-9-]{3,40}$/),
|
|
889
|
+
faucet: z.boolean().default(true),
|
|
890
|
+
name: z.string().min(1).max(48).optional(),
|
|
891
|
+
description: z.string().max(280).optional(),
|
|
892
|
+
token: z.string().regex(/^[A-Z][A-Z0-9]{1,7}$/).optional(),
|
|
893
|
+
}, async ({ chain_id, faucet, name, description, token }) => {
|
|
723
894
|
const bin = nodeBinary();
|
|
724
|
-
const
|
|
895
|
+
const brand = [...(name ? ["--name", name] : []), ...(name && description ? ["--description", description] : []), ...(token ? ["--token", token] : [])];
|
|
896
|
+
const out = execFileSync(bin.path, ["summon", chain_id, ...(faucet ? [] : ["--no-faucet"]), ...brand], { encoding: "utf8" });
|
|
725
897
|
return `${out.trim()}\n${ranWith(bin)}`;
|
|
726
898
|
});
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
899
|
+
/**
|
|
900
|
+
* What the agent can spend for an account now (AGENT-7), from its `/api/account` answer: what it
|
|
901
|
+
* can move, what's locked and until when, the session key's allowance this window, this MCP's
|
|
902
|
+
* caps, and the smallest of them. wallet_info and use_account return it.
|
|
903
|
+
*/
|
|
904
|
+
async function budgetOf(w, a) {
|
|
905
|
+
const client = w.client;
|
|
906
|
+
const head = safeInt((await client.status()).height) ?? 0;
|
|
907
|
+
// As of the next block, the first a tx can run in.
|
|
908
|
+
const height = head + 1;
|
|
909
|
+
const blockTimeMs = client.genesisClock?.blockTimeMs ?? 1000;
|
|
910
|
+
const key = keyOn(a, w.keyId);
|
|
911
|
+
const allowance = allowanceOf(key ?? undefined, height);
|
|
912
|
+
const transferable = dropsOf(a?.transferable);
|
|
913
|
+
const locked = dropsOf(a?.locked);
|
|
914
|
+
const today = LEDGER.spent(chainNow());
|
|
915
|
+
const dailyLeft = CAPS.maxDaily > today ? CAPS.maxDaily - today : 0n;
|
|
916
|
+
const spendable = spendableNow({ transferable, allowance, height, blockTimeMs, dailyLeft, caps: CAPS });
|
|
917
|
+
const locks = (Array.isArray(a?.locks) ? a.locks : []).filter((l) => safeInt(l?.until) !== undefined && dropsOf(l?.amount) !== undefined);
|
|
918
|
+
const lastEnd = locks.reduce((m, l) => Math.max(m, l.until), 0);
|
|
919
|
+
return {
|
|
920
|
+
...(transferable !== undefined ? { transferable: formatFang(transferable) } : {}),
|
|
921
|
+
...(locked !== undefined && locked > 0n
|
|
922
|
+
? {
|
|
923
|
+
locked: formatFang(locked),
|
|
924
|
+
...(lastEnd > 0 ? { locked_until_block: lastEnd, locked_until: lastEnd > head ? `${aboutBlocks(lastEnd - head, blockTimeMs)} from now` : "now" } : {}),
|
|
925
|
+
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 })),
|
|
926
|
+
}
|
|
927
|
+
: {}),
|
|
928
|
+
spendable_now: formatFang(spendable.amount),
|
|
929
|
+
spendable_now_limited_by: spendable.limitedBy,
|
|
930
|
+
spending_limits: spendable.limits,
|
|
931
|
+
...(allowance
|
|
932
|
+
? {
|
|
933
|
+
session_key: {
|
|
934
|
+
limit: `${formatFang(allowance.limit)} per ${aboutBlocks(allowance.periodBlocks, blockTimeMs).replace(/^about /, "")}`,
|
|
935
|
+
spent_this_period: formatFang(allowance.spent),
|
|
936
|
+
left: formatFang(allowance.expired ? 0n : allowance.left),
|
|
937
|
+
...(allowance.resetsAt !== undefined ? { resets_at_block: allowance.resetsAt, resets_in: aboutBlocks(allowance.resetsAt - head, blockTimeMs) } : {}),
|
|
938
|
+
expires_at_block: allowance.expiresAt,
|
|
939
|
+
expires_in: allowance.expired ? "expired" : aboutBlocks(allowance.expiresAt - head, blockTimeMs),
|
|
940
|
+
},
|
|
941
|
+
}
|
|
942
|
+
: {}),
|
|
943
|
+
caps: { per_call: formatFang(CAPS.maxSend), per_day: formatFang(CAPS.maxDaily) },
|
|
944
|
+
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)`,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/** An address of another network's prefix gets a clear answer (AGENT-14), not "isn't a valid address". */
|
|
948
|
+
function otherNetwork(input, prefix) {
|
|
949
|
+
const p = addressPrefix(input.trim());
|
|
950
|
+
if (p === undefined || p === prefix)
|
|
951
|
+
return;
|
|
952
|
+
const which = p === "tboo" ? "a testnet address" : "another network's address";
|
|
953
|
+
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.`);
|
|
954
|
+
}
|
|
955
|
+
/** What a payment cost (AGENT-1): the amount, the fee charged and what opening the recipient's account burned. */
|
|
956
|
+
function costView(r, amount, opening) {
|
|
957
|
+
const c = COST.get(r);
|
|
958
|
+
return {
|
|
959
|
+
...(opening > 0n ? { account_opening_fee_burned: formatFang(opening) } : {}),
|
|
960
|
+
...(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" }) } : {}),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
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 () => {
|
|
964
|
+
const client = await chain();
|
|
965
|
+
let st = await walletState();
|
|
966
|
+
const existed = !!st;
|
|
967
|
+
if (!st) {
|
|
968
|
+
const fresh = Wallet.generate(client);
|
|
969
|
+
await client.addressPrefix();
|
|
970
|
+
// Never over a key another session made meanwhile: that one is kept and used.
|
|
971
|
+
createWalletFile(WALLET_FILE, newWalletFile(fresh.address, fresh.keyId, fresh.seedHex));
|
|
972
|
+
st = (await walletState());
|
|
735
973
|
}
|
|
736
|
-
|
|
974
|
+
const { w } = st;
|
|
737
975
|
return {
|
|
738
976
|
key_id: w.keyId,
|
|
977
|
+
check_code: checkCode(w.keyId),
|
|
739
978
|
own_address: w.address,
|
|
740
979
|
acting_for: w.account,
|
|
741
980
|
existed,
|
|
742
|
-
network: network().
|
|
981
|
+
network: networkLabel(network(), pinnedChain.chainId),
|
|
743
982
|
stored_at: WALLET_FILE,
|
|
744
|
-
next_steps: "To spend from the user's account: ask which account (their .boo name), then call use_account with it. Until they've authorized this key it answers with a grant_code (this key's signed consent to joining that one account) for them to paste in the Blockula web wallet → Devices & agents → 'Give an agent a budget'. A key id alone can't be added. (On test chains, faucet funds this key's own address for testing.)",
|
|
983
|
+
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.)",
|
|
745
984
|
};
|
|
746
985
|
});
|
|
747
|
-
tool("use_account", "Act for a user's account
|
|
748
|
-
const
|
|
749
|
-
if (!
|
|
750
|
-
throw new Error("No agent wallet yet
|
|
751
|
-
const
|
|
752
|
-
const
|
|
986
|
+
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 }) => {
|
|
987
|
+
const st = await walletState();
|
|
988
|
+
if (!st)
|
|
989
|
+
throw new Error("No agent wallet yet: call create_wallet first.");
|
|
990
|
+
const { w, f, chain: key } = st;
|
|
991
|
+
const client = w.client;
|
|
992
|
+
if (!key)
|
|
993
|
+
throw new Error(`${RPC} doesn't report its chain's genesis hash, so the account can't be kept for this chain`);
|
|
753
994
|
if (account === "self") {
|
|
754
|
-
|
|
995
|
+
setActingFor(f, key, undefined);
|
|
996
|
+
writeWalletFile(WALLET_FILE, f);
|
|
997
|
+
return { acting_for: w.address };
|
|
755
998
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
999
|
+
otherNetwork(account, await client.addressPrefix());
|
|
1000
|
+
// The account is an address every later tx names: on blockula-1 an unproven name waits for the
|
|
1001
|
+
// user to confirm the address (audit gap-7-1).
|
|
1002
|
+
const { res, confirm } = await resolved(client, account, "account");
|
|
1003
|
+
if (confirm)
|
|
1004
|
+
return confirm;
|
|
1005
|
+
const addr = res.address;
|
|
1006
|
+
const resolution = res.name === null ? {} : { name_resolution: res.proven ? clean(res.how, 300) : `UNPROVEN: ${clean(res.how, 300)}` };
|
|
1007
|
+
// The account's own key list, not /api/key's list of accounts (which others can pad: audit gap-7-2).
|
|
1008
|
+
const a = await client.account(addr);
|
|
1009
|
+
// An address with no account can't add a key (that takes a tx, and a tx takes FANG): no grant to hand out.
|
|
1010
|
+
if (a?.exists === false) {
|
|
1011
|
+
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.` };
|
|
1012
|
+
}
|
|
1013
|
+
const grant = keyOn(a, w.keyId);
|
|
1014
|
+
if (!grant) {
|
|
1015
|
+
// Adding a key needs its consent to the account (audit gap-7-2): this key signs it for this
|
|
1016
|
+
// one address, and the user's wallet refuses a code made for any other account.
|
|
1017
|
+
const wallet = pinnedChain.chainId ? WALLET_URLS[pinnedChain.chainId] : undefined;
|
|
1018
|
+
// The resolution itself, so the SDK refuses it too if it's unproven on blockula-1.
|
|
1019
|
+
const grantCode = encodeKeyGrant(await w.keyGrant(res));
|
|
1020
|
+
// AGENT-5: thousands of characters are easy to mangle when retyped; a file can be copied whole.
|
|
1021
|
+
// Its name is a chain id `chain()` checked (no path separator can be in it) and an address.
|
|
1022
|
+
const grantFile = join(HOME, `grant-${pinnedChain.chainId ?? "chain"}-${addr}.txt`);
|
|
1023
|
+
let saved = false;
|
|
1024
|
+
try {
|
|
1025
|
+
mkdirSync(HOME, { recursive: true });
|
|
1026
|
+
writeFileSync(grantFile, `${grantCode}\n`, { mode: 0o600 });
|
|
1027
|
+
saved = true;
|
|
779
1028
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1029
|
+
catch {
|
|
1030
|
+
/* the code is in the answer anyway */
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
status: "not_authorized_yet",
|
|
1034
|
+
account: clean(account, 100),
|
|
1035
|
+
address: addr,
|
|
1036
|
+
...resolution,
|
|
1037
|
+
key_id: w.keyId,
|
|
1038
|
+
check_code: checkCode(w.keyId),
|
|
1039
|
+
...(wallet ? { wallet_url: wallet } : {}),
|
|
1040
|
+
grant_code: grantCode,
|
|
1041
|
+
...(saved ? { grant_file: grantFile, copy_it: `${process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"} < ${grantFile}` } : {}),
|
|
1042
|
+
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.`,
|
|
1043
|
+
};
|
|
783
1044
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1045
|
+
setActingFor(f, key, addr);
|
|
1046
|
+
writeWalletFile(WALLET_FILE, f);
|
|
1047
|
+
w.account = addr;
|
|
1048
|
+
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)) };
|
|
787
1049
|
});
|
|
788
|
-
tool("wallet_info", "This agent's address, balance, .boo names, recent activity (others' txs naming this account included: their summaries are untrusted text) and today's spending
|
|
789
|
-
const
|
|
790
|
-
|
|
1050
|
+
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 () => {
|
|
1051
|
+
const st = await walletState();
|
|
1052
|
+
if (!st)
|
|
1053
|
+
throw new Error("No agent wallet yet: call create_wallet first.");
|
|
1054
|
+
const { w, acting } = st;
|
|
1055
|
+
const prefix = await w.client.addressPrefix();
|
|
791
1056
|
const a = await w.client.account(w.account).catch(() => null);
|
|
792
|
-
const mine =
|
|
1057
|
+
const mine = keyOn(a, w.keyId);
|
|
793
1058
|
return {
|
|
794
1059
|
key_id: w.keyId,
|
|
795
|
-
network: network().
|
|
1060
|
+
network: networkLabel(network(), pinnedChain.chainId),
|
|
796
1061
|
acting_for: clean(String(a?.account?.name ?? w.account)),
|
|
1062
|
+
...(acting.ignored ? { previous_account_ignored: ignoredNote(acting.ignored, prefix) } : {}),
|
|
797
1063
|
my_permissions: mine ? chainData(mine) : "not authorized on this account",
|
|
798
1064
|
address: w.account,
|
|
799
1065
|
balance: clean(String(a?.balance_display ?? "0 FANG")),
|
|
1066
|
+
...(await budgetOf(w, a)),
|
|
800
1067
|
names: chainData(a?.names ?? []),
|
|
801
1068
|
recent: (Array.isArray(a?.recent_txs) ? a.recent_txs : []).slice(0, 10).map((t) => `${clean(String(t.summary ?? ""), 300)} (${clean(String(t.id ?? "")).slice(0, 12)}…)`),
|
|
802
|
-
spent_today: `${formatFang(LEDGER.spent())} of ${formatFang(MAX_DAILY)} (BLOCKULA_MAX_DAILY; txs that are pending, dropped or refused still count until they're final or their nonce is reused)`,
|
|
803
1069
|
note: UNTRUSTED_NOTE,
|
|
804
1070
|
};
|
|
805
1071
|
});
|
|
806
|
-
|
|
1072
|
+
/** What `faucet` answers when the node says its chain has none. */
|
|
1073
|
+
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. Dev chains (start_dev_node) have faucets.";
|
|
1074
|
+
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 }) => {
|
|
807
1075
|
const client = await chain();
|
|
808
1076
|
let target = to;
|
|
809
1077
|
if (!target) {
|
|
@@ -811,57 +1079,118 @@ tool("faucet", "Get 100 test FANG on a dev/test chain (to this agent's wallet, o
|
|
|
811
1079
|
await w.client.addressPrefix();
|
|
812
1080
|
target = w.account;
|
|
813
1081
|
}
|
|
814
|
-
|
|
815
|
-
|
|
1082
|
+
try {
|
|
1083
|
+
const tx = await client.faucet(target);
|
|
1084
|
+
return { summary: clean(String(tx.summary ?? "")), tx_id: tx.id, explorer: explorer(`tx/${tx.id}`) };
|
|
1085
|
+
}
|
|
1086
|
+
catch (e) {
|
|
1087
|
+
// The node's answer on a chain whose genesis has no faucet (node/src/api.rs `faucet`).
|
|
1088
|
+
if (e instanceof ApiError && e.status === 400 && /no faucet/i.test(e.message))
|
|
1089
|
+
return { status: "no_faucet", summary: NO_FAUCET };
|
|
1090
|
+
throw e;
|
|
1091
|
+
}
|
|
816
1092
|
});
|
|
817
|
-
tool("send", "Send FANG to a .boo name or address. Amounts are in FANG, e.g. \"1.5\". A name is signed as the name (the chain pays whoever owns it when the tx runs) unless BLOCKULA_WITNESSES proved its owner. Counts toward BLOCKULA_MAX_SEND / BLOCKULA_MAX_DAILY (with the fee
|
|
818
|
-
|
|
1093
|
+
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.", {
|
|
1094
|
+
to: z.string().describe("a .boo name or an address of this network"),
|
|
1095
|
+
amount: z.string().describe("FANG, e.g. \"1.5\" (up to 9 decimals)"),
|
|
1096
|
+
memo: z.string().max(256).optional().describe("public and permanent: anyone can read it, forever. Never put personal data in it"),
|
|
1097
|
+
}, async ({ to, amount, memo }) => {
|
|
1098
|
+
const drops = amountArg(amount);
|
|
819
1099
|
if (drops <= 0n)
|
|
820
1100
|
throw new Error("send more than 0");
|
|
821
1101
|
const w = await requireWallet();
|
|
822
|
-
await w.client.addressPrefix();
|
|
1102
|
+
otherNetwork(to, await w.client.addressPrefix());
|
|
823
1103
|
// With witnesses a name's proven owner is signed; without, the name itself, which the chain
|
|
824
1104
|
// resolves when the tx runs: a lying node can't point it at another account now, though one
|
|
825
1105
|
// holding the signed tx could release it later in its validity window (audit gap-7-1,
|
|
826
1106
|
// review-r3-clients-2).
|
|
827
1107
|
const rc = await w.client.recipient(to);
|
|
1108
|
+
if (rc.address === w.account)
|
|
1109
|
+
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.`);
|
|
828
1110
|
const opening = await openingFee(w.client, rc.address);
|
|
829
|
-
const r = await submitCapped(w, { type: "transfer", to: rc.signed, amount: drops.toString() }, { amount: drops, extra: opening }, memo ?? "");
|
|
830
|
-
|
|
1111
|
+
const r = await submitCapped(w, { type: "transfer", to: rc.signed, amount: drops.toString() }, { amount: drops, extra: opening, outflow: drops }, memo ?? "");
|
|
1112
|
+
const view = { ...recipientView(rc, "recipient"), ...costView(r, drops, opening) };
|
|
1113
|
+
return isInFlight(r) ? { ...r, ...view } : txResult(r, view);
|
|
831
1114
|
});
|
|
832
1115
|
tool("check_name", "Check whether a .boo name is available and what it costs, or who owns it and when it expires.", { name: z.string() }, async ({ name }) => chainData(await (await chain()).name(name)));
|
|
833
|
-
tool("register_name", "Claim a .boo name for the account this agent acts for (3–32 chars, a-z 0-9 -). Shorter names cost more
|
|
1116
|
+
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 }) => {
|
|
834
1117
|
const w = await requireWallet();
|
|
835
1118
|
const n = bareName(name);
|
|
836
1119
|
const { price } = await namePrices(w.client, n);
|
|
837
|
-
return txResult(await submitCapped(w, { type: "register_name", name: n }, { amount: price, what: "the name's price" }), { price: formatFang(price) });
|
|
1120
|
+
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" });
|
|
838
1121
|
});
|
|
839
|
-
tool("renew_name", "Extend a .boo name the account this agent acts for owns, by 1–10 years (
|
|
1122
|
+
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 }) => {
|
|
840
1123
|
const w = await requireWallet();
|
|
841
1124
|
const n = bareName(name);
|
|
842
1125
|
const { renewal, info } = await namePrices(w.client, n);
|
|
843
1126
|
const cost = renewal * BigInt(years);
|
|
844
|
-
return txResult(await submitCapped(w, { type: "renew_name", name: n, years }, { amount: cost, what: "the renewal" }), { cost: formatFang(cost), ...(typeof info?.expires_at === "number" ? { expired_at_before: info.expires_at } : {}) });
|
|
1127
|
+
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 } : {}) });
|
|
845
1128
|
});
|
|
846
|
-
const LOOKUP_TYPES = new Set(["block", "tx", "account", "name"]);
|
|
847
|
-
tool("lookup", `Look up anything — a .boo name, address, block number/hash
|
|
1129
|
+
const LOOKUP_TYPES = new Set(["block", "tx", "account", "name", "program", "code"]);
|
|
1130
|
+
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 }) => {
|
|
848
1131
|
const client = await chain();
|
|
849
1132
|
const hit = await client.search(query);
|
|
850
1133
|
const type = String(hit?.type ?? "");
|
|
851
1134
|
const id = String(hit?.id ?? "");
|
|
852
1135
|
if (!LOOKUP_TYPES.has(type))
|
|
853
1136
|
throw new Error(`nothing found for ${clean(query, 100)}`);
|
|
1137
|
+
// 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).
|
|
1138
|
+
if (type === "program" || type === "code") {
|
|
1139
|
+
const data = type === "program" ? await programShown(client, id) : codeView(await client.code(id));
|
|
1140
|
+
return { type, ...(type === "program" ? { explorer: explorer(`program/${encodeURIComponent(id)}`) } : {}), data, note: PROGRAM_NOTE };
|
|
1141
|
+
}
|
|
854
1142
|
const data = type === "block" ? await client.block(id) : type === "tx" ? await client.tx(id) : type === "account" ? await client.account(id) : await client.name(id);
|
|
855
1143
|
// The node's JSON is nested (it can't override `type` or `explorer`), cleaned and labeled.
|
|
856
1144
|
return { type, explorer: explorer(`${type}/${encodeURIComponent(id)}`), data: chainData(data), note: UNTRUSTED_NOTE };
|
|
857
1145
|
});
|
|
1146
|
+
// ---- programs (docs/PROGRAMS-V0.md §8.5): third-party WebAssembly, read only. No tool here signs a program transaction: an agent's
|
|
1147
|
+
// calls need the confirmation flow of PRG 11.5 (the user is asked, an allowlist), a later release. What a program or its deployer
|
|
1148
|
+
// chose reaches the agent only as untrusted_* (untrusted.ts), and no description below holds any of it. ----
|
|
1149
|
+
/**
|
|
1150
|
+
* A program as `program_show` shows it. With BLOCKULA_WITNESSES its record is proven (its Merkle proof against the witnesses' state
|
|
1151
|
+
* 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
|
|
1152
|
+
* proof on every field no transaction changes (`provenDifferences`), else it's refused. What isn't proven (methods and types, the code's
|
|
1153
|
+
* 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
|
|
1154
|
+
* for consistency by the SDK (its version digest is the one its own code, interface and version compute), and says so.
|
|
1155
|
+
*/
|
|
1156
|
+
async function programShown(client, address) {
|
|
1157
|
+
const info = await client.program(address);
|
|
1158
|
+
if (!client.witnesses.length)
|
|
1159
|
+
return programView(info);
|
|
1160
|
+
const witnesses = client.witnesses;
|
|
1161
|
+
const record = await client.provenProgram(address, { witnesses });
|
|
1162
|
+
if (!record)
|
|
1163
|
+
throw new Error(`${address} isn't a program: its record is provably absent against ${witnesses.length} witness(es), whatever ${RPC} says`);
|
|
1164
|
+
const odd = provenDifferences(info, record);
|
|
1165
|
+
if (odd.length)
|
|
1166
|
+
throw new Error(`${RPC} and its witnesses disagree about the program at ${address}: ${odd.join("; ")}. Nothing it said was used`);
|
|
1167
|
+
// The balance, proven at its own block (a program holds an account from its create on; none proves 0).
|
|
1168
|
+
const account = await client.provenRecord(NAMESPACES.ACCOUNTS, address, { witnesses });
|
|
1169
|
+
return programView(info, { record, balance: { drops: (account.value === null ? 0n : decodeAccount(account.value).balance).toString(), height: account.height }, witnesses: witnesses.length });
|
|
1170
|
+
}
|
|
1171
|
+
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 }) => {
|
|
1172
|
+
const client = await chain();
|
|
1173
|
+
return { ...(await programShown(client, address)), explorer: explorer(`program/${encodeURIComponent(address)}`) };
|
|
1174
|
+
});
|
|
1175
|
+
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, {
|
|
1176
|
+
address: z.string().describe("the program's address (boo1… or tboo1…)"),
|
|
1177
|
+
method: z.string().regex(/^[a-z][a-z0-9_]{0,31}$/).describe("a `view` method of its interface (see program_show)"),
|
|
1178
|
+
input: z.string().regex(/^([0-9a-f]{2})*$/).max(65536).default("").describe("what the method reads, lowercase hex of whole bytes (default none)"),
|
|
1179
|
+
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)"),
|
|
1180
|
+
fuel: z.number().int().min(1).optional().describe("the most fuel the view may use (the node caps it too)"),
|
|
1181
|
+
}, async ({ address, method, input, bindings, fuel }) => {
|
|
1182
|
+
const client = await chain();
|
|
1183
|
+
const v = await client.view(address, method, input, { bindings: bindings.map((b) => ({ program: b.program, versionDigest: b.version_digest })), fuel });
|
|
1184
|
+
return viewView(address, method, v, RPC);
|
|
1185
|
+
});
|
|
1186
|
+
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)));
|
|
858
1187
|
tool("latest_blocks", "The most recent blocks, summarized.", { limit: z.number().int().min(1).max(50).default(10) }, async ({ limit }) => {
|
|
859
1188
|
const blocks = await (await chain()).blocks(limit);
|
|
860
1189
|
return (Array.isArray(blocks) ? blocks : []).map((b) => `${clean(String(b.summary ?? ""), 200)} · ${clean(String(b.hash ?? "")).slice(0, 12)}…`);
|
|
861
1190
|
});
|
|
862
1191
|
// ---- staking ----
|
|
863
|
-
tool("stake", "Stake (delegate) FANG from the account this agent acts for in a validator's pool. Staked FANG earns a share of issuance, is slashed if the validator misbehaves, and takes the unbonding period (21 days
|
|
864
|
-
const drops =
|
|
1192
|
+
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 }) => {
|
|
1193
|
+
const drops = amountArg(amount);
|
|
865
1194
|
if (drops <= 0n)
|
|
866
1195
|
throw new Error("delegate more than 0");
|
|
867
1196
|
const w = await requireWallet();
|
|
@@ -872,7 +1201,7 @@ tool("stake", "Stake (delegate) FANG from the account this agent acts for in a v
|
|
|
872
1201
|
return isInFlight(r) ? { ...r, ...resolvedView(res, "validator") } : txResult(r, { ...resolvedView(res, "validator"), next: "my_stake shows the stake and its value" });
|
|
873
1202
|
});
|
|
874
1203
|
tool("unstake", "Start unbonding stake from a validator's pool: an amount in FANG (signed as that amount: the chain converts it to pool shares at its own price when the tx runs) or \"all\". It stops earning now and can be withdrawn with withdraw_stake once the unbonding period is over.", { validator: z.string(), amount: z.string().default("all").describe("FANG, or \"all\"") }, async ({ validator, amount }) => {
|
|
875
|
-
const drops = amount === "all" ? null :
|
|
1204
|
+
const drops = amount === "all" ? null : amountArg(amount);
|
|
876
1205
|
if (drops !== null && drops <= 0n)
|
|
877
1206
|
throw new Error("unstake more than 0 FANG, or \"all\"");
|
|
878
1207
|
const w = await requireWallet();
|
|
@@ -950,8 +1279,8 @@ tool("list_proposals", `On-chain governance proposals (parameter changes, treasu
|
|
|
950
1279
|
const list = (Array.isArray(res.proposals) ? res.proposals : []);
|
|
951
1280
|
return { proposals: list.slice(0, limit).map((p) => proposalView(proposalSummary(p, res.params))), note: UNTRUSTED_NOTE };
|
|
952
1281
|
});
|
|
953
|
-
tool("vote", "Vote on a governance proposal for the account this agent acts for. Pass the proposal_digest list_proposals returned with what you showed the user: the vote binds exactly that content, and is refused (nothing signed) if the proposal reads differently now. `amount` FANG of its unlocked holdings is locked: conviction 0 locks nothing and counts 0.1×; 1–6 lock for 1, 2, 4, 8, 16 or 32 lock periods (a week each
|
|
954
|
-
const drops =
|
|
1282
|
+
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 }) => {
|
|
1283
|
+
const drops = amountArg(amount);
|
|
955
1284
|
if (drops <= 0n)
|
|
956
1285
|
throw new Error("vote with more than 0");
|
|
957
1286
|
// The amount is locked, not spent: it's held to BLOCKULA_MAX_SEND, and only the fee counts toward the daily limit.
|
|
@@ -977,31 +1306,34 @@ tool("vote", "Vote on a governance proposal for the account this agent acts for.
|
|
|
977
1306
|
return isInFlight(r) ? { ...r, voted_on } : { summary: clean(String(r.summary ?? "")), fee: r.fee_display, tx_id: r.id, voted_on, explorer: explorer(`proposal/${proposal_id}`) };
|
|
978
1307
|
});
|
|
979
1308
|
// ---- Hunts and the contribution pool ----
|
|
980
|
-
|
|
1309
|
+
/** Where to find work when no Hunt is open (AGENT-4). */
|
|
1310
|
+
const GOOD_FIRST = "https://github.com/jackdishman/blockula/issues?q=is%3Aopen+label%3Afamiliar-friendly";
|
|
1311
|
+
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 }) => {
|
|
981
1312
|
const res = await (await chain()).hunts(status);
|
|
982
1313
|
const hunts = (Array.isArray(res) ? res : Array.isArray(res?.hunts) ? res.hunts : []);
|
|
983
1314
|
const funders = (f) => (Array.isArray(f) ? f.length : f && typeof f === "object" ? Object.keys(f).length : 0);
|
|
984
1315
|
return {
|
|
985
|
-
hunts: hunts.slice(0, 100).map((h) => ({ hunt_id: Number(h.id), bounty: clean(String(h.amount_display ?? ""), 40), untrusted_title: clean(String(h.title ?? ""), 120), issue: clean(String(h.issue_url ?? ""), 200), funders: funders(h.funders), explorer: explorer(`hunt/${Number(h.id)}`) })),
|
|
1316
|
+
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)}`) })),
|
|
1317
|
+
...(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}` } : {}),
|
|
986
1318
|
note: `${UNTRUSTED_NOTE} ${NO_VALUE}`,
|
|
987
1319
|
};
|
|
988
1320
|
});
|
|
989
|
-
tool("link_github", "Link a GitHub handle to the account this agent acts for, so Hunt awards
|
|
1321
|
+
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 }) => {
|
|
990
1322
|
const w = await requireWallet();
|
|
991
1323
|
const r = await submitCapped(w, { type: "link_github", handle, gist_id }, { amount: 0n });
|
|
992
1324
|
if (isInFlight(r))
|
|
993
1325
|
return r;
|
|
994
1326
|
const check = await w.client.github(handle).catch(() => null);
|
|
995
|
-
return { summary: clean(String(r.summary ?? "")), verified: check?.verified === true, explorer: explorer(`tx/${r.id}`) };
|
|
1327
|
+
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}`) };
|
|
996
1328
|
});
|
|
997
|
-
tool("fund_hunt", `Add FANG to an open Hunt's bounty (crowdfunding). Refunded to you if the hunt is cancelled
|
|
998
|
-
const drops =
|
|
1329
|
+
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 }) => {
|
|
1330
|
+
const drops = amountArg(amount);
|
|
999
1331
|
if (drops <= 0n)
|
|
1000
1332
|
throw new Error("fund more than 0");
|
|
1001
|
-
const r = await submitCapped(await requireWallet(), { type: "fund_hunt", hunt_id, amount: drops.toString() }, { amount: drops });
|
|
1333
|
+
const r = await submitCapped(await requireWallet(), { type: "fund_hunt", hunt_id, amount: drops.toString() }, { amount: drops, outflow: drops });
|
|
1002
1334
|
return isInFlight(r) ? r : { summary: clean(String(r.summary ?? "")), fee: r.fee_display, tx_id: r.id, explorer: explorer(`hunt/${hunt_id}`) };
|
|
1003
1335
|
});
|
|
1004
|
-
tool("contribution_pool", `Blockula's contribution pool (Proof of Contribution): its balance, which GitHub repos earn from it, the rules, and the Séance (review jury) that's open now. Merged PRs to pool repos can be claimed for a share; payouts are judged by human juries, rate a sponsor's claims in a Séance together (one pooled win rate; each next a quarter of the one before), and vest over ~90 days
|
|
1336
|
+
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 () => {
|
|
1005
1337
|
const c = await (await chain()).contributions();
|
|
1006
1338
|
return {
|
|
1007
1339
|
pool: chainData(c.pool?.balance_display),
|
|
@@ -1015,21 +1347,30 @@ tool("contribution_pool", `Blockula's contribution pool (Proof of Contribution):
|
|
|
1015
1347
|
explorer: explorer("contribute"),
|
|
1016
1348
|
};
|
|
1017
1349
|
});
|
|
1018
|
-
tool("submit_contribution", `Claim a merged PR for the contribution pool, for the account this agent acts for. The PR's author must be that account's linked GitHub handle. Costs a
|
|
1350
|
+
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 }) => {
|
|
1019
1351
|
const w = await requireWallet();
|
|
1020
1352
|
await w.client.addressPrefix();
|
|
1021
1353
|
const me = await w.client.account(w.account).catch(() => null);
|
|
1354
|
+
// CONTRIB-4: validators refute a claim whose author's link doesn't verify, and the bond goes to the pool.
|
|
1355
|
+
if (!force) {
|
|
1356
|
+
const handle = typeof me?.github?.handle === "string" ? me.github.handle : undefined;
|
|
1357
|
+
if (!handle)
|
|
1358
|
+
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.`);
|
|
1359
|
+
const check = await w.client.github(handle).catch(() => null);
|
|
1360
|
+
if (check?.verified !== true)
|
|
1361
|
+
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.`);
|
|
1362
|
+
}
|
|
1022
1363
|
// node/src/poc.rs `claim_bond`: CLAIM_BOND (1 FANG) per claim, more with open claims; never counted below it.
|
|
1023
1364
|
const quoted = me?.contribution?.next_claim_bond;
|
|
1024
1365
|
const bond = /^\d+$/.test(String(quoted ?? "")) && BigInt(quoted) > 1000000000n ? BigInt(quoted) : 1000000000n;
|
|
1025
|
-
const r = await submitCapped(w, { type: "submit_claim", repo, pr }, { amount: bond, what: `the claim bond (${formatFang(bond)})` });
|
|
1366
|
+
const r = await submitCapped(w, { type: "submit_claim", repo, pr }, { amount: bond, outflow: bond, what: `the claim bond (${formatFang(bond)})` });
|
|
1026
1367
|
if (isInFlight(r))
|
|
1027
1368
|
return r;
|
|
1028
1369
|
const claims = await w.client.claims({ account: w.account }).catch(() => null);
|
|
1029
1370
|
const claim = (Array.isArray(claims?.claims) ? claims.claims : []).find((c) => c.repo === repo.toLowerCase() && c.pr === pr);
|
|
1030
|
-
return { summary: clean(String(r.summary ?? "")), claim_id: claim?.id, status: claim ? clean(String(claim.status)) : undefined, next: "Validators check GitHub within a minute or two; then it waits for the revert window and the next Séance. Track it with my_contributions.", note: NO_VALUE, explorer: explorer(`claim/${claim?.id ?? ""}`) };
|
|
1371
|
+
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 ?? ""}`) };
|
|
1031
1372
|
});
|
|
1032
|
-
tool("my_contributions", `The Soul (reputation), contribution claims, vesting rewards, vouch and jury status of the account this agent acts for. A claim's untrusted_note is written by whoever judged or rejected it
|
|
1373
|
+
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 () => {
|
|
1033
1374
|
const w = await requireWallet();
|
|
1034
1375
|
await w.client.addressPrefix();
|
|
1035
1376
|
const a = await w.client.account(w.account);
|
|
@@ -1045,6 +1386,6 @@ tool("my_contributions", `The Soul (reputation), contribution claims, vesting re
|
|
|
1045
1386
|
note: `${UNTRUSTED_NOTE} ${NO_VALUE}`,
|
|
1046
1387
|
};
|
|
1047
1388
|
});
|
|
1048
|
-
if (
|
|
1049
|
-
console.error(`blockula-mcp: ${
|
|
1389
|
+
if (BAD_CONFIG)
|
|
1390
|
+
console.error(`blockula-mcp: ${BAD_CONFIG.message} (every tool answers with this until the configuration is fixed)`);
|
|
1050
1391
|
await server.connect(new StdioServerTransport());
|