@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/README.md +111 -13
- package/dist/accounts.d.ts +41 -0
- package/dist/accounts.js +117 -0
- package/dist/budget.d.ts +104 -0
- package/dist/budget.js +169 -0
- package/dist/index.js +1278 -162
- package/dist/locks.d.ts +61 -0
- package/dist/locks.js +222 -0
- package/dist/network.d.ts +169 -0
- package/dist/network.js +361 -0
- package/dist/release.d.ts +79 -0
- package/dist/release.js +193 -0
- package/dist/signed.d.ts +31 -0
- package/dist/signed.js +72 -0
- package/dist/spend.d.ts +52 -0
- package/dist/spend.js +199 -0
- package/dist/untrusted.d.ts +64 -0
- package/dist/untrusted.js +204 -0
- package/npm-shrinkwrap.json +1303 -0
- package/package.json +8 -6
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
|
-
//
|
|
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
|
|
9
|
-
// BLOCKULA_MAX_DAILY max FANG
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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,
|
|
15
|
-
import { spawn, execFileSync } from "node:child_process";
|
|
16
|
-
import {
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
245
|
+
return undefined;
|
|
68
246
|
}
|
|
69
|
-
return { day, spent: 0n };
|
|
70
247
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
const
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
|
115
|
-
const
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
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 \`${
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
|
189
|
-
const
|
|
190
|
-
if (!
|
|
191
|
-
throw new Error("No agent wallet yet
|
|
192
|
-
const w =
|
|
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
|
-
|
|
988
|
+
setActingFor(f, key, undefined);
|
|
989
|
+
writeWalletFile(WALLET_FILE, f);
|
|
990
|
+
return { acting_for: w.address };
|
|
195
991
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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("
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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("
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
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("
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
|
|
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
|
|
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("
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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("
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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());
|