@floatonnear/mcp 0.10.1 → 0.10.3
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/SKILL.md +144 -14
- package/dist/agent-key.d.ts +50 -0
- package/dist/agent-key.js +68 -2
- package/dist/create.d.ts +10 -0
- package/dist/create.js +23 -1
- package/dist/explain.d.ts +33 -0
- package/dist/explain.js +55 -0
- package/dist/keyring-path.d.ts +36 -0
- package/dist/keyring-path.js +117 -0
- package/dist/payloads.d.ts +10 -8
- package/dist/payloads.js +92 -23
- package/dist/roles.d.ts +29 -0
- package/dist/roles.js +49 -0
- package/dist/server.js +562 -85
- package/dist/token-meta.d.ts +16 -0
- package/dist/token-meta.js +18 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -56,13 +56,14 @@ import { z } from "zod";
|
|
|
56
56
|
import { fromBaseUnits, humaniseTreeNode, toBaseUnits } from "./amounts.js";
|
|
57
57
|
import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, planSetStrictAllocation, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
|
|
58
58
|
import { accountExists, rpcForNetwork, RPC_BY_NETWORK, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
|
|
59
|
-
import { AGENT_NODE_ID, generateAgentKey } from "./agent-key.js";
|
|
59
|
+
import { AGENT_NODE_ID, generateAgentKey, retireRole } from "./agent-key.js";
|
|
60
60
|
import { resolveKeySource } from "./key-file.js";
|
|
61
61
|
import { dirname, resolve } from "node:path";
|
|
62
62
|
import { existsSync, readFileSync } from "node:fs";
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
|
-
import { listAgents,
|
|
65
|
-
import {
|
|
64
|
+
import { listAgents, listUnloadable, selectRole } from "./roles.js";
|
|
65
|
+
import { resolveKeyringPath } from "./keyring-path.js";
|
|
66
|
+
import { displaySymbol, label as tokenLabel, tokenMeta } from "./token-meta.js";
|
|
66
67
|
/**
|
|
67
68
|
* A decimal NEAR string to yoctoNEAR, without floating point.
|
|
68
69
|
*
|
|
@@ -77,7 +78,7 @@ function toYoctoNear(n) {
|
|
|
77
78
|
}
|
|
78
79
|
import { buildCreatePlan, FACTORY_BY_NETWORK } from "./create.js";
|
|
79
80
|
import { buildRoute, sendRequestPayment } from "./sign.js";
|
|
80
|
-
import { explainOutcome } from "./explain.js";
|
|
81
|
+
import { explainOutcome, planVoice } from "./explain.js";
|
|
81
82
|
import { getQuote, signBinding, toBase64 } from "./quote.js";
|
|
82
83
|
/** How long a quote stays valid. Long enough to sign and send, short
|
|
83
84
|
* enough that a stale price is not honoured. */
|
|
@@ -157,6 +158,25 @@ if (!Object.hasOwn(RPC_BY_NETWORK, NETWORK)) {
|
|
|
157
158
|
`questions about the wrong chain.`);
|
|
158
159
|
process.exit(1);
|
|
159
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* A syntactically valid ed25519 key used ONLY to dry-run payload construction.
|
|
163
|
+
*
|
|
164
|
+
* `planAddRole` needs a public key to build args, and we want its exceptions
|
|
165
|
+
* BEFORE generating a real one. All zeroes in base58 is "1" repeated, which
|
|
166
|
+
* parses as a key and is obviously not anyone's.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Resolved ONCE, at startup, from the environment — never recomputed, so the
|
|
170
|
+
* path cannot change mid-session if something alters the working directory.
|
|
171
|
+
*
|
|
172
|
+
* The vault id is read straight from the env rather than from `cfg`, because
|
|
173
|
+
* `cfg` is what this feeds.
|
|
174
|
+
*/
|
|
175
|
+
const KEYRING = resolveKeyringPath({
|
|
176
|
+
explicit: process.env.NEAR_VAULT_AGENT_KEYS,
|
|
177
|
+
vault: process.env.NEAR_VAULT_CONTRACT,
|
|
178
|
+
});
|
|
179
|
+
const PLACEHOLDER_PUBLIC_KEY = `ed25519:${"1".repeat(44)}`;
|
|
160
180
|
const cfg = {
|
|
161
181
|
vault: process.env.NEAR_VAULT_CONTRACT ?? "",
|
|
162
182
|
/**
|
|
@@ -220,10 +240,17 @@ const cfg = {
|
|
|
220
240
|
// Guarded by `scripts/check-create-page.mjs`, which fetches the real link
|
|
221
241
|
// shape, follows redirects, and checks the wasm at the landing origin.
|
|
222
242
|
appUrl: process.env.NEAR_VAULT_APP_URL ?? "https://usefloat.xyz",
|
|
223
|
-
// The fleet keyring. Same
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
243
|
+
// The fleet keyring. Same file the adapter reads — two readers disagreeing
|
|
244
|
+
// about where credentials live is how an agent ends up "configured" and
|
|
245
|
+
// unreachable.
|
|
246
|
+
//
|
|
247
|
+
// RESOLVED, NOT DEFAULTED. This was the bare relative string
|
|
248
|
+
// `.float/agent-keys.json`, which Node resolves against the server's CWD —
|
|
249
|
+
// i.e. wherever the CLIENT happened to be launched. One server entry in
|
|
250
|
+
// `~/.claude.json` therefore meant a DIFFERENT keyring per directory, and a
|
|
251
|
+
// key written from one project was invisible from another. See
|
|
252
|
+
// `keyring-path.ts` for the resolution rule and the measurement.
|
|
253
|
+
agentKeysPath: KEYRING.path,
|
|
227
254
|
};
|
|
228
255
|
/**
|
|
229
256
|
* NO VAULT IS THE COMMONEST STATE, SO THE MESSAGE HAS TO BE AN ONRAMP.
|
|
@@ -269,12 +296,65 @@ function tokenFor(t) {
|
|
|
269
296
|
}
|
|
270
297
|
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
271
298
|
const json = (v) => text(JSON.stringify(v, null, 2));
|
|
272
|
-
/**
|
|
273
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Payload + summary + warnings + a runnable command, so approving needs no
|
|
301
|
+
* trust in this server — fronted by the plain-English fields.
|
|
302
|
+
*
|
|
303
|
+
* THE UX FIELDS COME FIRST, AND THEY ARE THE SAME THREE `vault_pay` RETURNS.
|
|
304
|
+
* This used to return five technical keys and no sentence, so agents described
|
|
305
|
+
* the mechanism — "returns an unsigned payload; the server does not sign; run
|
|
306
|
+
* `near contract call-function`…" — to someone who had asked to raise a limit.
|
|
307
|
+
* One shape across every tool means the instruction can be one sentence:
|
|
308
|
+
* relay `say`, add `next_step`, stop.
|
|
309
|
+
*
|
|
310
|
+
* `money_moved` is ALWAYS false here, and that is not a technicality worth
|
|
311
|
+
* hiding: a plan is a proposal. Nothing has moved, nothing has changed, and an
|
|
312
|
+
* agent that reports otherwise has lied about state the user is about to be
|
|
313
|
+
* asked to change themselves.
|
|
314
|
+
*
|
|
315
|
+
* `unsigned_payload` stays TOP-LEVEL rather than moving under `technical`:
|
|
316
|
+
* `scripts/check-mcp-live.mjs` asserts on it against the real server, and
|
|
317
|
+
* quietly renaming the key it reads would turn a live check green-by-absence.
|
|
318
|
+
*/
|
|
319
|
+
async function renderPlan(p) {
|
|
320
|
+
const voice = planVoice(p);
|
|
321
|
+
/**
|
|
322
|
+
* SWAP TOKEN IDS FOR SYMBOLS IN THE SENTENCE ONLY.
|
|
323
|
+
*
|
|
324
|
+
* `summary` is built in `payloads.ts`, which is pure and has no network — so
|
|
325
|
+
* it prints the token's ACCOUNT ID. For mainnet USDC that is 64 hex
|
|
326
|
+
* characters, and `planVoice` reuses the summary verbatim, so the sentence
|
|
327
|
+
* read: 'Create budget node "research" as the ROOT with
|
|
328
|
+
* 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1: 5/day'.
|
|
329
|
+
*
|
|
330
|
+
* Resolving here rather than in `payloads.ts` keeps that module pure and
|
|
331
|
+
* synchronously testable, which is worth more than saving this substitution.
|
|
332
|
+
* `technical.summary` is left EXACT — the id is the useful form there.
|
|
333
|
+
*
|
|
334
|
+
* Only the `say` is rewritten, and only for ids actually present, so a
|
|
335
|
+
* payload with no tokens costs nothing.
|
|
336
|
+
*/
|
|
337
|
+
let say = voice.say;
|
|
338
|
+
const ids = [
|
|
339
|
+
...new Set((Array.isArray(p.args.limits) ? p.args.limits : [])
|
|
340
|
+
.map((l) => l?.token)
|
|
341
|
+
.filter((t) => typeof t === "string" && say.includes(t))),
|
|
342
|
+
];
|
|
343
|
+
if (ids.length > 0) {
|
|
344
|
+
const metas = await metaFor(ids);
|
|
345
|
+
for (const id of ids) {
|
|
346
|
+
const sym = metas.get(id)?.symbol;
|
|
347
|
+
// Substituted ONLY when a symbol actually resolved. Falling back to
|
|
348
|
+
// "tokens" here would erase which token a limit applies to, and a
|
|
349
|
+
// multi-token payload would become ambiguous.
|
|
350
|
+
if (sym)
|
|
351
|
+
say = say.split(id).join(sym);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
274
354
|
return json({
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
355
|
+
say,
|
|
356
|
+
next_step: voice.next,
|
|
357
|
+
money_moved: false,
|
|
278
358
|
unsigned_payload: {
|
|
279
359
|
receiver_id: p.receiverId,
|
|
280
360
|
method_name: p.methodName,
|
|
@@ -282,6 +362,11 @@ function renderPlan(p) {
|
|
|
282
362
|
gas_tgas: p.gasTgas,
|
|
283
363
|
deposit_yocto: p.depositYocto,
|
|
284
364
|
},
|
|
365
|
+
technical: {
|
|
366
|
+
action: p.methodName,
|
|
367
|
+
summary: p.summary,
|
|
368
|
+
warnings: p.warnings,
|
|
369
|
+
},
|
|
285
370
|
approve_with: toCliCommand(p, cfg.network),
|
|
286
371
|
note: "This server did NOT sign this. Review the summary, then run the command " +
|
|
287
372
|
"or approve the payload in your wallet.",
|
|
@@ -381,7 +466,7 @@ WORTH KNOWING
|
|
|
381
466
|
An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
|
|
382
467
|
named account rather than retrying.
|
|
383
468
|
Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
|
|
384
|
-
const server = new McpServer({ name: "float", version: "0.10.
|
|
469
|
+
const server = new McpServer({ name: "float", version: "0.10.3" }, { instructions: INSTRUCTIONS });
|
|
385
470
|
/**
|
|
386
471
|
* THE FULL OPERATING GUIDE, AS AN MCP RESOURCE.
|
|
387
472
|
*
|
|
@@ -579,6 +664,11 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
|
|
|
579
664
|
}
|
|
580
665
|
if (config.owner !== owner) {
|
|
581
666
|
return json({
|
|
667
|
+
say: `That vault address already belongs to someone else, so it is not yours to use. ` +
|
|
668
|
+
`Nothing was changed. I can set one up for you under a different name instead — ` +
|
|
669
|
+
`want me to?`,
|
|
670
|
+
next_step: `Say the word and I'll create a vault under a label, on a different address.`,
|
|
671
|
+
money_moved: false,
|
|
582
672
|
status: "NOT_YOURS",
|
|
583
673
|
vault: target,
|
|
584
674
|
actualOwner: config.owner,
|
|
@@ -588,16 +678,116 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
|
|
|
588
678
|
`under a label instead, which lands on a different address.`,
|
|
589
679
|
});
|
|
590
680
|
}
|
|
681
|
+
/**
|
|
682
|
+
* WHAT ACTUALLY STOPS THIS VAULT PAYING, read from the chain.
|
|
683
|
+
*
|
|
684
|
+
* The old `nextSteps` were a FIXED list ending "nothing can spend from it
|
|
685
|
+
* until an agent is bound — vault_add_role". For a vault created with
|
|
686
|
+
* `bindAgent` that is simply false: the agent is already bound, and the
|
|
687
|
+
* real gate is that the vault holds no tokens. Measured 2026-11-13 — the
|
|
688
|
+
* agent relaying it had to correct the tool in front of the user.
|
|
689
|
+
*
|
|
690
|
+
* Worse, nothing told the owner HOW to fund it, or that funding is a plain
|
|
691
|
+
* wallet send to an address, or what that address is. This is the moment
|
|
692
|
+
* they need it: they have just signed, they are looking at the result, and
|
|
693
|
+
* the next thing standing between them and a working vault is money.
|
|
694
|
+
*
|
|
695
|
+
* So the steps are derived from live state instead of asserted. Three
|
|
696
|
+
* reads, once, on a path that runs once per vault.
|
|
697
|
+
*/
|
|
698
|
+
const rootToken = await (async () => {
|
|
699
|
+
// THE TREE IS AUTHORITATIVE, not cfg.token: at creation time
|
|
700
|
+
// NEAR_VAULT_CONTRACT is typically unset, so the configured token may be
|
|
701
|
+
// empty while the vault plainly names one in its root limits.
|
|
702
|
+
const tree = await viewFunction(rpc, target, "get_tree", {}).catch(() => null);
|
|
703
|
+
return tree?.find((n) => n.id === 0)?.limits?.[0]?.token;
|
|
704
|
+
})();
|
|
705
|
+
const meta = rootToken
|
|
706
|
+
? await tokenMeta(rpc, rootToken, (r, t) => viewFunction(r, t, "ft_metadata", {})).catch(() => null)
|
|
707
|
+
: null;
|
|
708
|
+
const symbol = meta?.symbol ?? "the token";
|
|
709
|
+
const [held, registered, agentKeys] = await Promise.all([
|
|
710
|
+
rootToken
|
|
711
|
+
? viewFunction(rpc, rootToken, "ft_balance_of", { account_id: target }).catch(() => null)
|
|
712
|
+
: Promise.resolve(null),
|
|
713
|
+
rootToken
|
|
714
|
+
? viewFunction(rpc, rootToken, "storage_balance_of", {
|
|
715
|
+
account_id: target,
|
|
716
|
+
}).catch(() => undefined)
|
|
717
|
+
: Promise.resolve(undefined),
|
|
718
|
+
viewAccessKeys(rpc, target)
|
|
719
|
+
// Narrowed the same way vault_gas does: `permission` is a union and
|
|
720
|
+
// only the gas-key arm carries this field.
|
|
721
|
+
.then((ks) => ks.filter((k) => k.access_key?.permission
|
|
722
|
+
?.GasKeyFunctionCall !== undefined).length)
|
|
723
|
+
.catch(() => null),
|
|
724
|
+
]);
|
|
725
|
+
const empty = held !== null && BigInt(held) === 0n;
|
|
726
|
+
const funding = {
|
|
727
|
+
token: rootToken ?? null,
|
|
728
|
+
symbol: rootToken ? symbol : null,
|
|
729
|
+
balance: held === null ? null : fromBaseUnits(BigInt(held), meta?.decimals ?? cfg.decimals),
|
|
730
|
+
/** `undefined` = could not read; `null` = read and NOT registered. */
|
|
731
|
+
registeredOnToken: registered === undefined ? null : registered !== null,
|
|
732
|
+
sendTo: target,
|
|
733
|
+
howTo: `Funding is a plain send from the owner's own wallet: send ${symbol} to ${target}. ` +
|
|
734
|
+
`This server cannot do it — the agent key cannot touch a token contract, which is the ` +
|
|
735
|
+
`point of the product.`,
|
|
736
|
+
};
|
|
737
|
+
const nextSteps = [
|
|
738
|
+
`Set NEAR_VAULT_CONTRACT=${target} so every other tool reads this vault.`,
|
|
739
|
+
];
|
|
740
|
+
/**
|
|
741
|
+
* UNREGISTERED IS NAMED FIRST, because it is the one that makes a transfer
|
|
742
|
+
* FAIL rather than merely be impossible. Vaults created before the factory
|
|
743
|
+
* registered them are still in this state; the fix is forward-only.
|
|
744
|
+
*/
|
|
745
|
+
if (registered === null) {
|
|
746
|
+
nextSteps.push(`REGISTER IT FIRST: ${target} is not registered on ${rootToken}, and a transfer to an ` +
|
|
747
|
+
`unregistered account FAILS. The owner must call storage_deposit on the token with ` +
|
|
748
|
+
`about 0.00125 NEAR attached and registration_only: true. Vaults created through the ` +
|
|
749
|
+
`current factory are registered automatically; this one was not.`);
|
|
750
|
+
}
|
|
751
|
+
if (empty) {
|
|
752
|
+
nextSteps.push(`FUND IT: the vault holds no ${symbol}. Tell the owner to send ${symbol} to ${target} ` +
|
|
753
|
+
`from their own wallet, and ASK how much they want to move. Nothing can be paid out ` +
|
|
754
|
+
`until it has a balance — the budget caps what MAY be spent, not what exists.`);
|
|
755
|
+
}
|
|
756
|
+
if (agentKeys === 0) {
|
|
757
|
+
nextSteps.push(`No agent is bound yet, so nothing can authorise a payment — vault_add_role.`);
|
|
758
|
+
}
|
|
759
|
+
if (!empty && agentKeys !== 0 && registered !== null) {
|
|
760
|
+
nextSteps.push(`Ready. vault_budget shows today's allowance; vault_pay makes a payment.`);
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
nextSteps.push(`vault_tree shows the budget shape; vault_budget shows what is left today.`);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* "LIVE" PLUS A CONFIG DUMP IS NOT AN ANSWER TO "did it work?".
|
|
767
|
+
*
|
|
768
|
+
* `funding` and `nextSteps` already hold everything a person needs; what
|
|
769
|
+
* was missing was the one sentence that orders them. The empty-vault case
|
|
770
|
+
* is the one that matters: a vault that exists but holds nothing cannot
|
|
771
|
+
* pay, and "LIVE" on its own reads as done.
|
|
772
|
+
*/
|
|
773
|
+
const liveSay = empty
|
|
774
|
+
? `Your vault is set up and ready. It has no money in it yet, so add funds to it and ` +
|
|
775
|
+
`then I can start paying from it.`
|
|
776
|
+
: agentKeys === 0
|
|
777
|
+
? `Your vault is set up and funded. One thing left: it needs an agent budget before ` +
|
|
778
|
+
`anything can be paid from it.`
|
|
779
|
+
: `Your vault is live, funded, and has an agent that can pay within its limits. ` +
|
|
780
|
+
`Nothing else to do.`;
|
|
591
781
|
return json({
|
|
782
|
+
say: liveSay,
|
|
783
|
+
next_step: nextSteps[0] ?? null,
|
|
784
|
+
money_moved: false,
|
|
592
785
|
status: "LIVE",
|
|
593
786
|
vault: target,
|
|
594
787
|
owner: config.owner,
|
|
595
788
|
config,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
`vault_tree shows the budget; vault_budget shows what is left today.`,
|
|
599
|
-
`Nothing can spend from it until an agent is bound with a budget — vault_add_role.`,
|
|
600
|
-
],
|
|
789
|
+
funding,
|
|
790
|
+
nextSteps,
|
|
601
791
|
});
|
|
602
792
|
});
|
|
603
793
|
/**
|
|
@@ -688,7 +878,33 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
|
|
|
688
878
|
const tok = tokenFor(token);
|
|
689
879
|
const meta = (await metaFor([tok])).get(tok) ?? null;
|
|
690
880
|
const d = meta?.decimals ?? cfg.decimals;
|
|
881
|
+
// The SYMBOL for the sentence; the full `SYMBOL (id)` label stays on the
|
|
882
|
+
// `token` field below, where the id is useful rather than alarming.
|
|
883
|
+
const sym = displaySymbol(meta);
|
|
884
|
+
const cap = fromBaseUnits(BigInt(b.daily_cap), d);
|
|
885
|
+
const used = fromBaseUnits(BigInt(b.executed), d);
|
|
886
|
+
const left = fromBaseUnits(BigInt(b.available), d);
|
|
887
|
+
const inFlight = fromBaseUnits(BigInt(b.pending), d);
|
|
888
|
+
/**
|
|
889
|
+
* A BUDGET READ IS THE COMMONEST QUESTION A PERSON ASKS, and it was
|
|
890
|
+
* answered with eight fields and no sentence — including `day: 20768`.
|
|
891
|
+
* "Today: 0.25 of 25 USDC used, 24.75 left" is the same facts in the order
|
|
892
|
+
* someone asked for them.
|
|
893
|
+
*
|
|
894
|
+
* `pending` is mentioned ONLY when it is non-zero. It is in-flight money —
|
|
895
|
+
* reserved, not yet settled — and naming it on every read invites the
|
|
896
|
+
* question "what's pending?" when the answer is almost always "nothing".
|
|
897
|
+
* But when it IS non-zero, leaving it out makes the arithmetic look wrong:
|
|
898
|
+
* cap minus used would not equal what is left.
|
|
899
|
+
*/
|
|
900
|
+
const pendingNote = BigInt(b.pending) > 0n ? ` ${inFlight} ${sym} is still in flight.` : "";
|
|
691
901
|
return json({
|
|
902
|
+
say: `Today: ${used} of ${cap} ${sym} used, ${left} ${sym} left.${pendingNote}` +
|
|
903
|
+
` Resets at midnight UTC.`,
|
|
904
|
+
next_step: null,
|
|
905
|
+
// A read moves nothing. Stated rather than omitted so the field means the
|
|
906
|
+
// same thing on every tool that has it.
|
|
907
|
+
money_moved: false,
|
|
692
908
|
node_id: nodeId,
|
|
693
909
|
token: tokenLabel(tok, meta),
|
|
694
910
|
/**
|
|
@@ -703,10 +919,10 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
|
|
|
703
919
|
date: new Date(Number(b.day) * 86_400_000).toISOString().slice(0, 10),
|
|
704
920
|
day: b.day,
|
|
705
921
|
resets: "00:00 UTC",
|
|
706
|
-
daily_cap:
|
|
707
|
-
executed:
|
|
708
|
-
pending:
|
|
709
|
-
available:
|
|
922
|
+
daily_cap: cap,
|
|
923
|
+
executed: used,
|
|
924
|
+
pending: inFlight,
|
|
925
|
+
available: left,
|
|
710
926
|
});
|
|
711
927
|
});
|
|
712
928
|
/**
|
|
@@ -907,33 +1123,135 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
|
|
|
907
1123
|
.describe("NEAR for the agent's gas key. Default 0.12 (~160 payments measured). Minimum 0.06, because NEAR reserves prepaid gas up front."),
|
|
908
1124
|
}, async ({ role, dailyCap, perTxCap, recipients, token, cooldownSec, fundingNear }) => {
|
|
909
1125
|
const vault = requireVault();
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1126
|
+
/**
|
|
1127
|
+
* A DUPLICATE ROLE IS REFUSED — BUT "IN THE KEYRING" IS NOT "ALIVE".
|
|
1128
|
+
*
|
|
1129
|
+
* The contract would happily create a second node with the same label, and
|
|
1130
|
+
* `selectRole` resolves with `find`, so the FIRST entry wins forever: the
|
|
1131
|
+
* older agent would stay bound and funded on chain with nothing able to
|
|
1132
|
+
* select it. That part of the old guard was right.
|
|
1133
|
+
*
|
|
1134
|
+
* What was wrong was treating a keyring entry as proof an agent exists. A
|
|
1135
|
+
* `vault_add_role` that writes a key and then fails to land on chain leaves
|
|
1136
|
+
* an ORPHAN — a role bound to nothing, no node, no gas key — and this guard
|
|
1137
|
+
* then refused the user's own retry, citing the wreckage of the attempt
|
|
1138
|
+
* that failed thirty seconds earlier. Reported 2026-11-13 for a `travel`
|
|
1139
|
+
* agent; the user had to go and find the file by hand.
|
|
1140
|
+
*
|
|
1141
|
+
* So ask the CHAIN, which is the only thing that knows:
|
|
1142
|
+
* bound to a node -> a real agent. Refuse, as before.
|
|
1143
|
+
* not bound -> an orphan. Retire it and carry on.
|
|
1144
|
+
* chain unreachable or no recorded key -> cannot tell. Refuse, and SAY
|
|
1145
|
+
* it is a "cannot tell", not a "there is one".
|
|
1146
|
+
*/
|
|
1147
|
+
let retired = null;
|
|
1148
|
+
const clash = listAgents(cfg).find((a) => a.role === role);
|
|
1149
|
+
if (clash) {
|
|
1150
|
+
const boundNode = clash.publicKey
|
|
1151
|
+
? await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
|
|
1152
|
+
public_key: clash.publicKey,
|
|
1153
|
+
}).catch(() => undefined)
|
|
1154
|
+
: undefined;
|
|
1155
|
+
if (boundNode === undefined) {
|
|
1156
|
+
return text(`There is already a key filed under the role '${role}' for this vault, and I could not ` +
|
|
1157
|
+
`confirm with ${vault} whether it is a working agent — ` +
|
|
1158
|
+
`${clash.publicKey ? "the network read failed" : "that entry predates recorded public keys"}. ` +
|
|
1159
|
+
`Refusing rather than guessing: adding a second '${role}' would permanently hide ` +
|
|
1160
|
+
`whichever was filed first, and if the existing one IS bound and funded, nothing ` +
|
|
1161
|
+
`would be able to select it again. Try vault_roles to see the fleet, or pick ` +
|
|
1162
|
+
`another name.`);
|
|
1163
|
+
}
|
|
1164
|
+
if (boundNode !== null) {
|
|
1165
|
+
return text(`This vault already has a working agent with the role '${role}' — it is bound to ` +
|
|
1166
|
+
`node ${boundNode}. A second one would hide it, and it would stay funded on chain ` +
|
|
1167
|
+
`with nothing able to select it. Pick another name, or just use the existing agent.`);
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* ORPHAN. The chain says this key is bound to nothing, so it cannot be
|
|
1171
|
+
* anyone's working agent. Its name is taken off it — the secret stays on
|
|
1172
|
+
* disk, see `retireRole` — and the request proceeds, which is what the
|
|
1173
|
+
* user asked for in the first place.
|
|
1174
|
+
*/
|
|
1175
|
+
retired = retireRole({
|
|
1176
|
+
keyringPath: cfg.agentKeysPath,
|
|
1177
|
+
vault,
|
|
1178
|
+
publicKey: clash.publicKey,
|
|
1179
|
+
});
|
|
919
1180
|
}
|
|
1181
|
+
/**
|
|
1182
|
+
* VALIDATE EVERYTHING BEFORE A KEY TOUCHES DISK.
|
|
1183
|
+
*
|
|
1184
|
+
* This used to generate and WRITE the agent's key, and only then build the
|
|
1185
|
+
* payload — which calls `tokenFor()` and the amount conversions, any of
|
|
1186
|
+
* which can throw. So a call that failed validation left a real keypair
|
|
1187
|
+
* filed under the role name, bound to nothing: no node, no gas key. The
|
|
1188
|
+
* user's retry then hit "this vault already has an agent with the role
|
|
1189
|
+
* 'travel'" and was blocked by the wreckage of their own failed attempt.
|
|
1190
|
+
*
|
|
1191
|
+
* Measured 2026-11-13: a `vault_add_role` that failed on a missing token
|
|
1192
|
+
* wrote `travel` to the keyring 55 seconds before the retry was refused.
|
|
1193
|
+
*
|
|
1194
|
+
* Everything that can throw now runs FIRST, against a throwaway public key,
|
|
1195
|
+
* so a failure writes nothing at all. The key is generated only once the
|
|
1196
|
+
* payload is known to be buildable.
|
|
1197
|
+
*/
|
|
920
1198
|
const fundingYocto = toYoctoNear(fundingNear ?? "0.12");
|
|
921
|
-
|
|
922
|
-
|
|
1199
|
+
let limits;
|
|
1200
|
+
try {
|
|
1201
|
+
limits = [{ token: tokenFor(token), dailyCap, perTxCap, decimals: cfg.decimals }];
|
|
1202
|
+
// Build it once with a placeholder to force every conversion and check
|
|
1203
|
+
// to run. Nothing is kept; this is a dry run for its exceptions.
|
|
1204
|
+
planAddRole(vault, role, limits, cooldownSec ?? 0, recipients ?? null, PLACEHOLDER_PUBLIC_KEY, fundingYocto);
|
|
1205
|
+
}
|
|
1206
|
+
catch (e) {
|
|
1207
|
+
return text(`${String(e.message)}${String.fromCharCode(10)}${String.fromCharCode(10)}` +
|
|
1208
|
+
`Nothing was written — no key was generated, so '${role}' is still free to use once ` +
|
|
1209
|
+
`this is corrected.`);
|
|
1210
|
+
}
|
|
1211
|
+
// Only now. `nodeId: null` — NOT YET KNOWN: `add_role` creates the node
|
|
1212
|
+
// inside the contract, so its id does not exist until the transaction lands.
|
|
923
1213
|
const generated = generateAgentKey({
|
|
924
1214
|
vault,
|
|
925
|
-
|
|
1215
|
+
// The FULL configured path, not its directory. Passing the directory
|
|
1216
|
+
// discarded the filename, so a keyring pinned to anything other than
|
|
1217
|
+
// `agent-keys.json` was read from one file and written to another.
|
|
1218
|
+
keyringPath: cfg.agentKeysPath,
|
|
926
1219
|
role,
|
|
927
1220
|
nodeId: null,
|
|
928
1221
|
});
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1222
|
+
const payload = planAddRole(vault, role, limits, cooldownSec ?? 0, recipients ?? null, generated.publicKey, fundingYocto);
|
|
1223
|
+
/**
|
|
1224
|
+
* THE SENTENCE FOR THE MOMENT THIS TOOL IS USED.
|
|
1225
|
+
*
|
|
1226
|
+
* This returned a public key, a keyring path, a payload and two warnings —
|
|
1227
|
+
* and agents duly reported "generated an ed25519 keypair, wrote it to the
|
|
1228
|
+
* keyring; the 0.12 NEAR deposit funds a NEP-611 gas key" to someone who
|
|
1229
|
+
* had asked for a news agent with a small budget.
|
|
1230
|
+
*
|
|
1231
|
+
* What the person needs is the BUDGET, in the units they named, and that
|
|
1232
|
+
* one approval stands between them and it. The key, the path and the
|
|
1233
|
+
* funding mechanics are real and stay in the payload for the agent.
|
|
1234
|
+
*
|
|
1235
|
+
* The caps are re-stated from the ARGUMENTS rather than the built payload,
|
|
1236
|
+
* because the payload holds base units and the caller spoke in tokens.
|
|
1237
|
+
*/
|
|
1238
|
+
/**
|
|
1239
|
+
* RESOLVE THE SYMBOL. Passing `null` metadata here printed the token's
|
|
1240
|
+
* ACCOUNT ID in the sentence — "1 usdc.testnet a day", and on mainnet a
|
|
1241
|
+
* 64-character hex string. One cached view call, and it degrades to
|
|
1242
|
+
* "tokens" rather than to an id when the chain cannot be reached, which
|
|
1243
|
+
* keeps this tool usable offline.
|
|
1244
|
+
*/
|
|
1245
|
+
const roleMeta = await tokenMeta(cfg.rpcUrl, tokenFor(token), (rpc, t) => viewFunction(rpc, t, "ft_metadata", {})).catch(() => null);
|
|
1246
|
+
const roleSay = `I'll set up a ${role} budget: ${dailyCap} ${displaySymbol(roleMeta)} a day, and no ` +
|
|
1247
|
+
`single payment over ${perTxCap}. You approve it once in your wallet, and after that ` +
|
|
1248
|
+
`I can pay within those limits — I can't raise them myself. Nothing has changed yet.`;
|
|
936
1249
|
return json({
|
|
1250
|
+
say: roleSay,
|
|
1251
|
+
next_step: `Approve it in your wallet, then tell me and I'll confirm the ${role} budget is live.`,
|
|
1252
|
+
// The approval moves 0.12 NEAR into the agent's gas key when signed, but
|
|
1253
|
+
// NOTHING has moved at this point — this is an unsigned proposal.
|
|
1254
|
+
money_moved: false,
|
|
937
1255
|
role,
|
|
938
1256
|
agent: {
|
|
939
1257
|
publicKey: generated.publicKey,
|
|
@@ -946,30 +1264,165 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
|
|
|
946
1264
|
`credential is filed under the ROLE, so vault_pay({role:"${role}"}) works ` +
|
|
947
1265
|
`without it.`,
|
|
948
1266
|
],
|
|
1267
|
+
// Only present when something was actually cleaned up, so the ordinary
|
|
1268
|
+
// case stays quiet. An orphan silently replaced would be the same
|
|
1269
|
+
// invisible-state problem in a new costume.
|
|
1270
|
+
...(retired
|
|
1271
|
+
? {
|
|
1272
|
+
replaced: `A key was already filed under '${role}' but ${vault} reports it bound to ` +
|
|
1273
|
+
`nothing — a leftover from an earlier attempt that never landed on chain. It has ` +
|
|
1274
|
+
`been renamed '${retired.retiredTo}' so it cannot shadow this one. Its secret ` +
|
|
1275
|
+
`was NOT deleted.`,
|
|
1276
|
+
}
|
|
1277
|
+
: {}),
|
|
949
1278
|
warnings: [
|
|
950
1279
|
`Show the owner this public key and have them confirm it before signing: ${generated.publicKey}`,
|
|
951
1280
|
`The private key is at ${generated.keyringPath}. Do not print it.`,
|
|
1281
|
+
// A second keyring holding keys for THIS vault means roles this server
|
|
1282
|
+
// cannot see. Said here rather than left to surface later as "my agent
|
|
1283
|
+
// vanished".
|
|
1284
|
+
...(KEYRING.alsoFound.length
|
|
1285
|
+
? [
|
|
1286
|
+
`Another keyring also holds keys for this vault and is NOT being read: ` +
|
|
1287
|
+
`${KEYRING.alsoFound.join(", ")}. This one was chosen. Set ` +
|
|
1288
|
+
`NEAR_VAULT_AGENT_KEYS to pin one deliberately.`,
|
|
1289
|
+
]
|
|
1290
|
+
: []),
|
|
952
1291
|
],
|
|
953
1292
|
});
|
|
954
1293
|
});
|
|
955
|
-
server.tool("vault_roles", "List the agents this server can sign as,
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1294
|
+
server.tool("vault_roles", "List the agents this server can sign as, CROSS-CHECKED against the vault. Says which keys are actually installed and which are dead. Use it before vault_pay when a vault has more than one agent — the signing key decides which budget is charged. No key material is returned.", {}, async () => {
|
|
1295
|
+
const vault = requireVault();
|
|
1296
|
+
/**
|
|
1297
|
+
* THE KEYRING IS A CLAIM. THE CHAIN IS THE FACT.
|
|
1298
|
+
*
|
|
1299
|
+
* This used to report `listAgents(cfg)` verbatim — role, nodeId and all —
|
|
1300
|
+
* straight out of the local file, without ever asking the vault whether it
|
|
1301
|
+
* had heard of those keys. So a keyring pointing at the wrong directory
|
|
1302
|
+
* reported a healthy, signable agent on node 1 that the vault had never
|
|
1303
|
+
* been told about, and `vault_pay` then signed with it and came back
|
|
1304
|
+
* NOT_VIRTUAL_AGENT.
|
|
1305
|
+
*
|
|
1306
|
+
* Measured 2026-11-13: that cost a user a whole session of forensics —
|
|
1307
|
+
* reading .claude.json by hand, diffing two keyring files, checking gas
|
|
1308
|
+
* balances — to find a single wrong path. The tool whose entire job is
|
|
1309
|
+
* "which agents can I sign as" was the one reporting the false positive,
|
|
1310
|
+
* which is why nothing caught it earlier.
|
|
1311
|
+
*
|
|
1312
|
+
* So every entry is now resolved against the vault:
|
|
1313
|
+
* get_virtual_agent_node(pk) -> the node the CHAIN binds it to, or null
|
|
1314
|
+
* the access-key list -> is a funded gas key actually installed
|
|
1315
|
+
*
|
|
1316
|
+
* And the INVERSE is reported too: a gas key on the vault that is not in
|
|
1317
|
+
* the keyring is an agent you cannot sign as. Both directions of the
|
|
1318
|
+
* disagreement matter, and only one of them was ever visible.
|
|
1319
|
+
*/
|
|
1320
|
+
const local = listAgents(cfg);
|
|
1321
|
+
const onChainKeys = await viewAccessKeys(cfg.rpcUrl, vault)
|
|
1322
|
+
.then((ks) => ks
|
|
1323
|
+
.map((k) => ({
|
|
1324
|
+
publicKey: k.public_key,
|
|
1325
|
+
gas: k.access_key?.permission?.GasKeyFunctionCall,
|
|
1326
|
+
}))
|
|
1327
|
+
.filter((k) => k.gas !== undefined))
|
|
1328
|
+
.catch(() => null);
|
|
1329
|
+
const resolved = await Promise.all(local.map(async (r) => {
|
|
1330
|
+
if (!r.publicKey) {
|
|
1331
|
+
return {
|
|
1332
|
+
role: r.role,
|
|
1333
|
+
agentId: r.agentId,
|
|
1334
|
+
status: "UNVERIFIABLE",
|
|
1335
|
+
detail: "This keyring entry has no public key recorded, so the chain cannot be asked " +
|
|
1336
|
+
"about it. It predates the field. Re-create the agent with vault_add_role.",
|
|
1337
|
+
};
|
|
970
1338
|
}
|
|
971
|
-
|
|
972
|
-
|
|
1339
|
+
const node = await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
|
|
1340
|
+
public_key: r.publicKey,
|
|
1341
|
+
}).catch(() => undefined);
|
|
1342
|
+
const installed = onChainKeys?.find((k) => k.publicKey === r.publicKey);
|
|
1343
|
+
const balance = installed?.gas?.balance;
|
|
1344
|
+
if (node === undefined) {
|
|
1345
|
+
return { role: r.role, agentId: r.agentId, status: "UNKNOWN", detail: "could not reach the chain" };
|
|
1346
|
+
}
|
|
1347
|
+
if (node === null) {
|
|
1348
|
+
return {
|
|
1349
|
+
role: r.role,
|
|
1350
|
+
agentId: r.agentId,
|
|
1351
|
+
publicKey: r.publicKey,
|
|
1352
|
+
status: "DEAD",
|
|
1353
|
+
detail: `This key is NOT bound on ${vault}. Signing with it returns NOT_VIRTUAL_AGENT. ` +
|
|
1354
|
+
`It is a key for a different vault, or left over from one that was torn down, or ` +
|
|
1355
|
+
`this client is reading the wrong keyring. Do NOT use it; vault_add_role creates ` +
|
|
1356
|
+
`one the vault will accept.`,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
return {
|
|
1360
|
+
role: r.role,
|
|
1361
|
+
agentId: r.agentId,
|
|
1362
|
+
nodeId: node,
|
|
1363
|
+
// The LOCAL nodeId is reported only when it disagrees, because a
|
|
1364
|
+
// disagreement means the file is stale and that is worth saying.
|
|
1365
|
+
...(r.nodeId !== undefined && r.nodeId !== node
|
|
1366
|
+
? { staleLocalNodeId: r.nodeId, note: "the keyring records a different node; the chain wins" }
|
|
1367
|
+
: {}),
|
|
1368
|
+
status: installed ? "USABLE" : "BOUND_BUT_NO_GAS_KEY",
|
|
1369
|
+
gasNear: balance ? fromBaseUnits(BigInt(balance), 24) : null,
|
|
1370
|
+
...(installed
|
|
1371
|
+
? {}
|
|
1372
|
+
: {
|
|
1373
|
+
detail: "Bound to a node, so it may AUTHORISE a payment, but no gas key is installed " +
|
|
1374
|
+
"on the vault, so it cannot SUBMIT one. float-agent installs and funds it.",
|
|
1375
|
+
}),
|
|
1376
|
+
};
|
|
1377
|
+
}));
|
|
1378
|
+
const heldKeys = new Set(local.map((r) => r.publicKey).filter(Boolean));
|
|
1379
|
+
const unheld = (onChainKeys ?? [])
|
|
1380
|
+
.filter((k) => !heldKeys.has(k.publicKey))
|
|
1381
|
+
.map((k) => ({
|
|
1382
|
+
publicKey: k.publicKey,
|
|
1383
|
+
status: "ON_CHAIN_BUT_NO_LOCAL_KEY",
|
|
1384
|
+
detail: "The vault has this agent installed, but this client does not hold its private key, " +
|
|
1385
|
+
"so it cannot sign as it. It belongs to another machine or another client.",
|
|
1386
|
+
}));
|
|
1387
|
+
const unloadable = listUnloadable(cfg);
|
|
1388
|
+
const usable = resolved.filter((r) => r.status === "USABLE").length;
|
|
1389
|
+
return json({
|
|
1390
|
+
vault,
|
|
1391
|
+
usable,
|
|
1392
|
+
/**
|
|
1393
|
+
* WHICH FILE THIS ANSWER CAME FROM.
|
|
1394
|
+
*
|
|
1395
|
+
* Every status below is a claim about credentials in one specific file,
|
|
1396
|
+
* and that file is CHOSEN (see `keyring-path.ts`) — so an answer that
|
|
1397
|
+
* does not name it cannot be checked. Omitting it is what turned "my
|
|
1398
|
+
* travel agent is missing" into a session of hand-diffing keyrings:
|
|
1399
|
+
* the tool had the path and did not say it.
|
|
1400
|
+
*/
|
|
1401
|
+
keyring: {
|
|
1402
|
+
path: cfg.agentKeysPath,
|
|
1403
|
+
chosenBecause: KEYRING.why,
|
|
1404
|
+
...(KEYRING.alsoFound.length
|
|
1405
|
+
? {
|
|
1406
|
+
NOT_being_read: KEYRING.alsoFound,
|
|
1407
|
+
warning: `Another keyring also holds keys for this vault. Agents filed there will NOT ` +
|
|
1408
|
+
`appear above. Set NEAR_VAULT_AGENT_KEYS to pin one deliberately.`,
|
|
1409
|
+
}
|
|
1410
|
+
: {}),
|
|
1411
|
+
},
|
|
1412
|
+
agents: resolved,
|
|
1413
|
+
...(unheld.length ? { onChainWithoutLocalKey: unheld } : {}),
|
|
1414
|
+
// Entries the loader refuses to read. Reported rather than dropped, so
|
|
1415
|
+
// "I have a key but this shows nothing" is answerable.
|
|
1416
|
+
...(unloadable.length ? { unreadableKeyringEntries: unloadable } : {}),
|
|
1417
|
+
...(usable === 0
|
|
1418
|
+
? {
|
|
1419
|
+
warning: `NO USABLE AGENT. vault_pay cannot succeed against ${vault} from this client. ` +
|
|
1420
|
+
`Every entry above is dead, unbound, or missing its gas key — the commonest cause ` +
|
|
1421
|
+
`is NEAR_VAULT_AGENT_KEYS pointing at a keyring for a different vault.`,
|
|
1422
|
+
}
|
|
1423
|
+
: {}),
|
|
1424
|
+
});
|
|
1425
|
+
});
|
|
973
1426
|
// ------------------------------------------------------------- onboarding
|
|
974
1427
|
server.tool("vault_create", "For a user who has NO vault yet. Works out where their vault will live, exactly what it costs to create, and returns a URL they approve in their own wallet. Needs no key and no existing vault — this is the only tool that works before NEAR_VAULT_CONTRACT is set. This server never signs it.", {
|
|
975
1428
|
owner: z
|
|
@@ -1300,7 +1753,12 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
|
|
|
1300
1753
|
limits: limitShape,
|
|
1301
1754
|
cooldownSec: z.number().int().min(0).default(0),
|
|
1302
1755
|
recipients: z.array(z.string()).nullable().default(null),
|
|
1303
|
-
}, async ({ parent, label, limits, cooldownSec, recipients }) =>
|
|
1756
|
+
}, async ({ parent, label, limits, cooldownSec, recipients }) => {
|
|
1757
|
+
// The PARENT's name, so the summary reads "under news" not "under node 1".
|
|
1758
|
+
// Only looked up when there IS a parent.
|
|
1759
|
+
const parentName = parent === null ? null : (await resolveNode(parent)).label;
|
|
1760
|
+
return renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients, parentName));
|
|
1761
|
+
});
|
|
1304
1762
|
/**
|
|
1305
1763
|
* Refuse to build a signable payload for a node that does not exist.
|
|
1306
1764
|
*
|
|
@@ -1322,19 +1780,38 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
|
|
|
1322
1780
|
* them just prompts another guess.
|
|
1323
1781
|
*/
|
|
1324
1782
|
async function assertNode(nodeId) {
|
|
1783
|
+
return (await resolveNode(nodeId)).missing;
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* One tree read, answering BOTH questions a plan handler has: does this node
|
|
1787
|
+
* exist, and what do humans call it?
|
|
1788
|
+
*
|
|
1789
|
+
* THE LABEL WAS ALREADY BEING FETCHED AND THROWN AWAY. `assertNode` read
|
|
1790
|
+
* `label` purely to list it in a "no such node" message, so every plan summary
|
|
1791
|
+
* said "node 1" while the word "news" sat in the same response. Returning it
|
|
1792
|
+
* costs nothing and is what lets `budgetName` do its job.
|
|
1793
|
+
*
|
|
1794
|
+
* A FAILED READ RETURNS NO LABEL AND NO COMPLAINT. The chain being briefly
|
|
1795
|
+
* unreachable is not evidence the node is missing — refusing here would turn a
|
|
1796
|
+
* blip into "your node does not exist" — and it is not evidence the node is
|
|
1797
|
+
* unnamed either, so the summary falls back to "this budget" rather than
|
|
1798
|
+
* inventing one.
|
|
1799
|
+
*/
|
|
1800
|
+
async function resolveNode(nodeId) {
|
|
1325
1801
|
const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
|
|
1326
|
-
// A failed READ must not block a plan: the chain being briefly unreachable
|
|
1327
|
-
// is not evidence the node is missing, and refusing here would turn a blip
|
|
1328
|
-
// into "your node does not exist".
|
|
1329
1802
|
if (!Array.isArray(tree))
|
|
1330
|
-
return null;
|
|
1331
|
-
|
|
1332
|
-
|
|
1803
|
+
return { missing: null, label: null };
|
|
1804
|
+
const hit = tree.find((n) => n.id === nodeId);
|
|
1805
|
+
if (hit)
|
|
1806
|
+
return { missing: null, label: hit.label ?? null };
|
|
1333
1807
|
// RETURNS THE FACTS, NOT A SENTENCE. The first version returned prose and
|
|
1334
1808
|
// the read path tried to reuse it by regex-stripping the signing clause,
|
|
1335
1809
|
// which left "no node 999, Nodes: ...". Composing at the call site costs two
|
|
1336
1810
|
// lines and cannot produce a mangled sentence.
|
|
1337
|
-
return {
|
|
1811
|
+
return {
|
|
1812
|
+
missing: { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") },
|
|
1813
|
+
label: null,
|
|
1814
|
+
};
|
|
1338
1815
|
}
|
|
1339
1816
|
/** For a PLAN: the cost of getting this wrong is a wasted signature. */
|
|
1340
1817
|
const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so that payload would be refused by the ` +
|
|
@@ -1342,33 +1819,33 @@ const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so t
|
|
|
1342
1819
|
/** For a READ: nothing is at stake, so just say what exists. */
|
|
1343
1820
|
const noNodeForRead = (nodeId, known) => `This vault has no node ${nodeId}. Nodes: ${known}.`;
|
|
1344
1821
|
server.tool("vault_plan_set_caps", "Build an UNSIGNED payload to change a node's daily and per-payment caps. Not signed by this server.", { nodeId: z.number().int().min(0), limits: limitShape }, async ({ nodeId, limits }) => {
|
|
1345
|
-
const
|
|
1346
|
-
return
|
|
1347
|
-
? text(noNodeForPlan(nodeId,
|
|
1348
|
-
: renderPlan(planSetNodeCaps(requireVault(), nodeId, limits));
|
|
1822
|
+
const n = await resolveNode(nodeId);
|
|
1823
|
+
return n.missing
|
|
1824
|
+
? text(noNodeForPlan(nodeId, n.missing.known))
|
|
1825
|
+
: renderPlan(planSetNodeCaps(requireVault(), nodeId, limits, n.label));
|
|
1349
1826
|
});
|
|
1350
1827
|
server.tool("vault_plan_bind_agent", "Build an UNSIGNED payload binding a signing key to a node, so that key may spend from it. Generate the keypair yourself and pass only the public half. Many keys may share one node. Not signed by this server.", {
|
|
1351
1828
|
nodeId: z.number().int().min(0),
|
|
1352
1829
|
publicKey: z.string().describe("ed25519:..."),
|
|
1353
|
-
}, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
|
|
1830
|
+
}, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey, (await resolveNode(nodeId)).label)));
|
|
1354
1831
|
server.tool("vault_plan_unbind_agent", "Build an UNSIGNED payload revoking a signing key's authority to spend. Does NOT return any gas left in that key's NEP-611 gas key — withdraw from the gas key separately. Not signed by this server.", { publicKey: z.string().describe("ed25519:...") }, async ({ publicKey }) => renderPlan(planUnbindVirtualAgent(requireVault(), publicKey)));
|
|
1355
1832
|
server.tool("vault_plan_freeze", "Build an UNSIGNED payload to freeze or unfreeze a node. Freezing blocks the node AND its whole subtree. Not signed by this server.", { nodeId: z.number().int().min(0), frozen: z.boolean() }, async ({ nodeId, frozen }) => {
|
|
1356
|
-
const
|
|
1357
|
-
return
|
|
1358
|
-
? text(noNodeForPlan(nodeId,
|
|
1359
|
-
: renderPlan(planFreezeNode(requireVault(), nodeId, frozen));
|
|
1833
|
+
const n = await resolveNode(nodeId);
|
|
1834
|
+
return n.missing
|
|
1835
|
+
? text(noNodeForPlan(nodeId, n.missing.known))
|
|
1836
|
+
: renderPlan(planFreezeNode(requireVault(), nodeId, frozen, n.label));
|
|
1360
1837
|
});
|
|
1361
1838
|
server.tool("vault_plan_set_recipients", "Build an UNSIGNED payload to set a node's payee allowlist. This REPLACES the inherited list rather than adding to it; pass null to inherit. Not signed by this server.", { nodeId: z.number().int().min(0), recipients: z.array(z.string()).nullable() }, async ({ nodeId, recipients }) => (async () => {
|
|
1362
|
-
const
|
|
1363
|
-
return
|
|
1364
|
-
? text(noNodeForPlan(nodeId,
|
|
1365
|
-
: renderPlan(planSetRecipients(requireVault(), nodeId, recipients));
|
|
1839
|
+
const n = await resolveNode(nodeId);
|
|
1840
|
+
return n.missing
|
|
1841
|
+
? text(noNodeForPlan(nodeId, n.missing.known))
|
|
1842
|
+
: renderPlan(planSetRecipients(requireVault(), nodeId, recipients, n.label));
|
|
1366
1843
|
})());
|
|
1367
1844
|
server.tool("vault_plan_set_cooldown", "Build an UNSIGNED payload to set the minimum seconds between a node's payments. Not signed by this server.", { nodeId: z.number().int().min(0), cooldownSec: z.number().int().min(0) }, async ({ nodeId, cooldownSec }) => {
|
|
1368
|
-
const
|
|
1369
|
-
return
|
|
1370
|
-
? text(noNodeForPlan(nodeId,
|
|
1371
|
-
: renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec));
|
|
1845
|
+
const n = await resolveNode(nodeId);
|
|
1846
|
+
return n.missing
|
|
1847
|
+
? text(noNodeForPlan(nodeId, n.missing.known))
|
|
1848
|
+
: renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec, n.label));
|
|
1372
1849
|
});
|
|
1373
1850
|
server.tool("vault_pending_approval", "Look up a payment that is HELD awaiting the owner's approval. Returns null if there is no ticket for that id — already approved, already rejected, or never held.", { requestId: z.string().min(1) }, async ({ requestId }) => json((await viewFunction(cfg.rpcUrl, requireVault(), "get_pending_approval", {
|
|
1374
1851
|
request_id: requestId,
|
|
@@ -1381,7 +1858,7 @@ server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requ
|
|
|
1381
1858
|
// planSetApprovalThreshold in payloads.ts for the measurement.
|
|
1382
1859
|
amount: z.string().min(1).nullable().describe('human units, e.g. "0.25" — NOT base units'),
|
|
1383
1860
|
decimals: z.number().int().min(0).max(38).default(6),
|
|
1384
|
-
}, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals)));
|
|
1861
|
+
}, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals, (await resolveNode(nodeId)).label)));
|
|
1385
1862
|
server.tool("vault_plan_approve_payment", "Build an UNSIGNED payload to APPROVE a held payment, which sends it. Every limit is re-checked at approval, so it can still be refused. Not signed by this server.", { requestId: z.string().min(1) }, async ({ requestId }) => renderPlan(planApprovePayment(requireVault(), requestId)));
|
|
1386
1863
|
server.tool("vault_plan_reject_payment", "Build an UNSIGNED payload to REJECT a held payment. Nothing moves and the ticket is discarded. Not signed by this server.", { requestId: z.string().min(1) }, async ({ requestId }) => renderPlan(planRejectPayment(requireVault(), requestId)));
|
|
1387
1864
|
/**
|