@floatonnear/mcp 0.6.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -53,13 +53,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
53
53
  import { topUpFor, paymentsAfterTopUp } from "./gas-math.js";
54
54
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
55
55
  import { z } from "zod";
56
- import { fromBaseUnits, toBaseUnits } from "./amounts.js";
57
- import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
58
- import { accountExists, rpcForNetwork, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
56
+ import { fromBaseUnits, humaniseTreeNode, toBaseUnits } from "./amounts.js";
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
+ import { accountExists, rpcForNetwork, RPC_BY_NETWORK, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
59
59
  import { AGENT_NODE_ID, generateAgentKey } from "./agent-key.js";
60
60
  import { resolveKeySource } from "./key-file.js";
61
61
  import { dirname } from "node:path";
62
62
  import { listAgents, listRoles, selectRole } from "./roles.js";
63
+ import { label as tokenLabel, tokenMeta } from "./token-meta.js";
63
64
  /**
64
65
  * A decimal NEAR string to yoctoNEAR, without floating point.
65
66
  *
@@ -74,6 +75,7 @@ function toYoctoNear(n) {
74
75
  }
75
76
  import { buildCreatePlan, FACTORY_BY_NETWORK } from "./create.js";
76
77
  import { buildRoute, sendRequestPayment } from "./sign.js";
78
+ import { explainOutcome } from "./explain.js";
77
79
  import { getQuote, signBinding, toBase64 } from "./quote.js";
78
80
  /** How long a quote stays valid. Long enough to sign and send, short
79
81
  * enough that a stale price is not honoured. */
@@ -123,8 +125,36 @@ for (const err of [agentKeySource.error, quoteSignerSource.error]) {
123
125
  *
124
126
  * It used to be read inside the object literal, which meant `rpcUrl` could not
125
127
  * see it and fell back to a constant. See below.
128
+ *
129
+ * MAINNET IS THE DEFAULT, and it used to be testnet. The five CLIs all default
130
+ * to `--network mainnet`, so an operator who ran `create-float` and then
131
+ * started this server got a MAINNET vault being read by a TESTNET client, with
132
+ * nothing anywhere saying so. The symptom is not an error: reads answer
133
+ * "account does not exist" about an account holding real money, which reads as
134
+ * "Float is broken" rather than "these two halves disagree".
135
+ *
136
+ * Testnet is still one env var away. The asymmetry was the bug, not the value.
137
+ */
138
+ const NETWORK = process.env.NEAR_VAULT_NETWORK ?? "mainnet";
139
+ /**
140
+ * An unrecognised network FAILS CLOSED.
141
+ *
142
+ * `rpcForNetwork` falls back to a testnet endpoint for any string it does not
143
+ * know, and that fallback is pinned by a test, so it stays. But it means
144
+ * `NEAR_VAULT_NETWORK=Mainnet` — a capital letter — would resolve to TESTNET
145
+ * and answer mainnet questions about the wrong chain, confidently. That is the
146
+ * exact failure this server already shipped once by a different route.
147
+ *
148
+ * So validate at the boundary rather than widening the fallback: a typo should
149
+ * stop the server, not quietly move it to another chain.
126
150
  */
127
- const NETWORK = process.env.NEAR_VAULT_NETWORK ?? "testnet";
151
+ if (!Object.hasOwn(RPC_BY_NETWORK, NETWORK)) {
152
+ console.error(`float: NEAR_VAULT_NETWORK is "${NETWORK}", which is not a network this server knows ` +
153
+ `(${Object.keys(RPC_BY_NETWORK).join(", ")}). Refusing to start rather than guessing a ` +
154
+ `chain — an unrecognised value would otherwise resolve to a testnet endpoint and answer ` +
155
+ `questions about the wrong chain.`);
156
+ process.exit(1);
157
+ }
128
158
  const cfg = {
129
159
  vault: process.env.NEAR_VAULT_CONTRACT ?? "",
130
160
  /**
@@ -280,19 +310,39 @@ WORKFLOW
280
310
  5. Winding down is a CLI job, not a tool: npx -p @floatonnear/create-float float-exit. It returns the tokens,
281
311
  the gas-key balances and the storage deposit. vault_teardown_report says what is recoverable.
282
312
 
283
- REFUSALS ARE ANSWERS. Report the reason code, do not retry blindly:
284
- DAILY_BUDGET this agent's own cap is spent.
285
- PARENT_DAILY_BUDGET a SIBLING drained the shared pool; this agent's own cap may look free.
286
- PER_TX_LIMIT one payment was too large; splitting it may still be refused by the cap.
287
- NO_RECIPIENTS_SET the node pays nobody yet; that is an owner decision, via vault_plan_set_recipients.
313
+ SPEAK LIKE A PERSON. NEVER SHOW A REASON CODE TO THE USER.
314
+ vault_pay returns "say" -- a plain-English sentence with the real numbers in it -- plus
315
+ "next_step" and "money_moved". RELAY "say". Add "next_step" if there is one. That is the
316
+ whole reply; do not append the code, the request id, or the raw JSON.
317
+
318
+ A user who asked to pay someone and got "PARENT_PER_TX_LIMIT" has been told nothing. The
319
+ codes are precise and they do matter -- they are under "technical" for debugging, and you
320
+ should quote them only if the user asks why, asks for detail, or is clearly a developer.
321
+
322
+ Say this: "That is too big for one payment -- the most per payment is 10 USDC, and this
323
+ asked for 15. Nothing was sent. Send 10 USDC or less, or ask the vault owner
324
+ to raise the limit."
325
+ Not this: "The payment was BLOCKED with reason_code PARENT_PER_TX_LIMIT."
326
+
327
+ On success too: "Sent 0.25 USDC to alice.near. There is 24.75 USDC left to spend today."
328
+ Not "status: SETTLED".
329
+
330
+ REFUSALS ARE ANSWERS, not errors. Do not retry blindly -- a refused payment is the contract
331
+ doing its job. "money_moved" tells you whether anything actually left the vault; never imply
332
+ money moved when it is false, and never imply money is lost when a refusal was free.
288
333
 
289
334
  WORTH KNOWING
290
335
  Budgets are hierarchical: a payment charges every ancestor, so a vault-wide ceiling holds even
291
336
  when a child's cap looks free.
337
+ Understand the difference between DAILY_BUDGET and PARENT_DAILY_BUDGET even though you must
338
+ not print either. DAILY_BUDGET means this agent spent its own allowance. PARENT_DAILY_BUDGET
339
+ means a SIBLING drained the shared pool, so this agent's own cap can still read as fully
340
+ available -- a user who checks their budget, sees room, and is told only "refused" will
341
+ reasonably think the product is broken. Neither is fixed by retrying.
292
342
  An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
293
343
  named account rather than retrying.
294
344
  Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
295
- const server = new McpServer({ name: "float", version: "0.6.4" }, { instructions: INSTRUCTIONS });
345
+ const server = new McpServer({ name: "float", version: "0.9.0" }, { instructions: INSTRUCTIONS });
296
346
  // ----------------------------------------------------------------- reads
297
347
  /**
298
348
  * Wait for a create to land, and prove the result belongs to the user.
@@ -388,6 +438,36 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
388
438
  }
389
439
  // THE OWNER READ IS THE POINT. Getting here only proves an account exists.
390
440
  const config = await viewFunction(rpc, target, "get_config", {}).catch(() => null);
441
+ /**
442
+ * A `get_config` WITH AN OWNER IS NOT PROOF OF A VAULT.
443
+ *
444
+ * The FACTORY also exports `get_config`, and it also returns an `owner` —
445
+ * so pointing this tool at `usefloat.near` with the factory's own owner
446
+ * returned **LIVE**, and the next step it printed was
447
+ * `NEAR_VAULT_CONTRACT=usefloat.near`. Every later read would then query a
448
+ * factory for budgets it does not have.
449
+ *
450
+ * Measured 2026-11-12 on mainnet: the two shapes are cleanly separable --
451
+ * a vault answers `vault_id, owner, network, paused, strict_allocation,
452
+ * node_count, clock_mode`; the factory answers `owner, code_hash, network,
453
+ * required_deposit, paused`. `vault_id` is the discriminator, and it is the
454
+ * field whose presence means "I am the vault you are asking about".
455
+ *
456
+ * Checked BEFORE the owner comparison, because "this is not a vault" is a
457
+ * different and more fundamental answer than "this vault is someone
458
+ * else's", and reporting the latter about a factory would send someone
459
+ * looking for a nonexistent rival owner.
460
+ */
461
+ if (config && typeof config.vault_id !== "string") {
462
+ return json({
463
+ status: "UNVERIFIED",
464
+ vault: target,
465
+ instruction: `${target} answers get_config but it is NOT a Float vault — a vault's config ` +
466
+ `carries vault_id, and this one does not. The factory exports get_config too, ` +
467
+ `with an owner field, so "it responded and the owner matched" is not proof. Do ` +
468
+ `NOT set NEAR_VAULT_CONTRACT to it.`,
469
+ });
470
+ }
391
471
  if (!config || typeof config.owner !== "string") {
392
472
  return json({
393
473
  status: "UNVERIFIED",
@@ -420,7 +500,64 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
420
500
  ],
421
501
  });
422
502
  });
423
- server.tool("vault_tree", "Read the whole budget tree: every node, its caps, parent, bound agent account, cooldown and freeze state. No key required.", {}, async () => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {})));
503
+ /**
504
+ * Resolve every token named in a tree, once, so output can say "USDC".
505
+ *
506
+ * Fetched in PARALLEL and deduplicated: a tree with five nodes sharing one
507
+ * token must make one call, not five. `tokenMeta` caches for the process
508
+ * lifetime, so the second `vault_tree` of a session makes none.
509
+ */
510
+ async function metaFor(tokens) {
511
+ const unique = [...new Set(tokens)];
512
+ const pairs = await Promise.all(unique.map(async (t) => [
513
+ t,
514
+ await tokenMeta(cfg.rpcUrl, t, (rpc, token) => viewFunction(rpc, token, "ft_metadata", {})),
515
+ ]));
516
+ return new Map(pairs);
517
+ }
518
+ server.tool("vault_tree", "Read the whole budget tree: every node, its caps, parent, bound agent account, cooldown and freeze state. No key required.", {}, async () => {
519
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}));
520
+ if (!Array.isArray(tree))
521
+ return json(tree);
522
+ const meta = await metaFor(tree.flatMap((n) => n.limits?.map((l) => l.token) ?? []));
523
+ /**
524
+ * THE APPROVAL THRESHOLD WAS WRITE-ONLY.
525
+ *
526
+ * `vault_plan_set_approval_threshold` builds a payload to set one, and
527
+ * `get_effective_threshold` has always existed on the contract — but
528
+ * nothing read it back. So an owner could impose a rule and then had no
529
+ * way to see it, and an agent could not warn "this payment will be HELD"
530
+ * before making one. A policy you cannot read is a policy you cannot
531
+ * trust.
532
+ *
533
+ * EFFECTIVE, not the node's own: a threshold INHERITS down the tree, so
534
+ * the figure that actually governs a node may have been set on an
535
+ * ancestor. Reporting the local one would under-report the rule in force.
536
+ */
537
+ const thresholds = await Promise.all(tree.map((n) => viewFunction(cfg.rpcUrl, requireVault(), "get_effective_threshold", {
538
+ node_id: n.id,
539
+ }).catch(() => null)));
540
+ return json(tree.map((n, i) => {
541
+ const node = humaniseTreeNode(n, { token: cfg.token, decimals: cfg.decimals, meta });
542
+ const raw = thresholds[i];
543
+ if (raw === null || raw === undefined)
544
+ return node;
545
+ const tok = n.limits?.[0]?.token;
546
+ const d = (tok ? meta.get(tok)?.decimals : undefined) ?? cfg.decimals;
547
+ let human = String(raw);
548
+ try {
549
+ human = fromBaseUnits(BigInt(human), d);
550
+ }
551
+ catch {
552
+ /* surface what the contract said */
553
+ }
554
+ return {
555
+ ...node,
556
+ approval_threshold: human,
557
+ approval_note: `Payments above ${human} from this node are HELD until the owner approves them.`,
558
+ };
559
+ }));
560
+ });
424
561
  server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed, in-flight (pending) and available, in human units. Omit `day` for today. No key required.", {
425
562
  nodeId: z.number().int().min(0),
426
563
  token: z.string().optional(),
@@ -431,20 +568,225 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
431
568
  token: tokenFor(token),
432
569
  day: day ?? null,
433
570
  }));
434
- if (!b)
435
- return text(`node ${nodeId} has no limit for that token`);
436
- const d = cfg.decimals;
571
+ if (!b) {
572
+ // "no limit for that token" ASSERTED THAT THE NODE EXISTS. Asked about
573
+ // node 999 of a two-node vault it said the same thing, which sends a
574
+ // user off to set a limit on a node that is not there. Two different
575
+ // facts had one message.
576
+ const missing = await assertNode(nodeId);
577
+ if (missing)
578
+ return text(noNodeForRead(nodeId, missing.known));
579
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
580
+ const here = tree?.find((n) => n.id === nodeId)?.limits?.map((l) => l.token) ?? [];
581
+ return text(`Node ${nodeId} exists but has no limit for ${tokenFor(token)}. ` +
582
+ (here.length
583
+ ? `It has limits for: ${here.join(", ")}.`
584
+ : `It has no limits for any token, so it can pay nothing — set one with vault_plan_set_caps.`));
585
+ }
586
+ // The TOKEN's own decimals, not the configured guess. A vault may hold
587
+ // caps for several tokens and only one of them is NEAR_VAULT_TOKEN.
588
+ const tok = tokenFor(token);
589
+ const meta = (await metaFor([tok])).get(tok) ?? null;
590
+ const d = meta?.decimals ?? cfg.decimals;
437
591
  return json({
438
592
  node_id: nodeId,
593
+ token: tokenLabel(tok, meta),
594
+ /**
595
+ * `day: 20768` MEANS NOTHING TO A PERSON. It is days since the Unix
596
+ * epoch, which is the right key for a ledger and the wrong thing to
597
+ * show someone asking what they have left. The number stays — it is
598
+ * what the contract indexes by, and a support conversation needs it —
599
+ * but the date leads, and the reset is stated because "daily" does not
600
+ * say WHEN, and a budget that resets at midnight in a timezone you do
601
+ * not live in is a surprise worth pre-empting.
602
+ */
603
+ date: new Date(Number(b.day) * 86_400_000).toISOString().slice(0, 10),
439
604
  day: b.day,
605
+ resets: "00:00 UTC",
440
606
  daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
441
607
  executed: fromBaseUnits(BigInt(b.executed), d),
442
608
  pending: fromBaseUnits(BigInt(b.pending), d),
443
609
  available: fromBaseUnits(BigInt(b.available), d),
444
610
  });
445
611
  });
446
- server.tool("vault_request", "Read one stored payment request: status, reason code, amount, the ancestor chain it charged, and the UTC day it was reserved against. No key required.", { requestId: z.string() }, async ({ requestId }) => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_request", { request_id: requestId })));
447
- server.tool("vault_config", "Read vault config: owner, paused, strict-vs-overcommit allocation, node count, and clock mode. No key required.", {}, async () => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_config", {})));
612
+ /**
613
+ * THE ACTIVITY FEED, which nothing exposed.
614
+ *
615
+ * `list_requests` has been on the contract all along. The MCP had only
616
+ * `vault_request(requestId)` — a lookup by an id you can obtain *only* from a
617
+ * `vault_pay` response — so a user asking "what has my agent been spending
618
+ * on?" had nothing but running totals. For the one question people ask most
619
+ * about an autonomous spender, the answer was a number with no history behind
620
+ * it.
621
+ *
622
+ * THE CAVEAT IS LOAD-BEARING AND THE CONTRACT SAYS SO ITSELF: a BLOCKED
623
+ * request is never stored. The hot path returns the refusal as a value and
624
+ * emits an event, deliberately, because storing rejected attempts would let
625
+ * an agent inflate the vault's storage bill at will. So this feed shows
626
+ * in-flight, settled and failed payments — and an agent that was refused
627
+ * every time looks identical to one that never tried.
628
+ *
629
+ * Reporting that silently would be the worst kind of wrong: "no payments" is
630
+ * exactly what a user wants to hear when the real answer is "twelve refusals
631
+ * you cannot see here". So the caveat ships WITH the data, every time, rather
632
+ * than in a doc nobody opens.
633
+ */
634
+ server.tool("vault_history", "The vault's payment activity, newest first: what was paid, to whom, when, and how each one ended. Use this for 'what has my agent been spending on?'. No key required. IMPORTANT: refused payments are NOT recorded here — the contract deliberately does not store them, so an agent that was blocked every time looks the same as one that never tried.", {
635
+ limit: z
636
+ .number()
637
+ .int()
638
+ .min(1)
639
+ .max(100)
640
+ .optional()
641
+ .describe("how many to return, newest first. Default 20."),
642
+ fromIndex: z
643
+ .number()
644
+ .int()
645
+ .min(0)
646
+ .optional()
647
+ .describe("skip this many of the newest, to page backwards through history."),
648
+ }, async ({ limit, fromIndex }) => {
649
+ const rows = (await viewFunction(cfg.rpcUrl, requireVault(), "list_requests", {
650
+ from_index: fromIndex ?? 0,
651
+ limit: limit ?? 20,
652
+ }));
653
+ if (!Array.isArray(rows))
654
+ return json(rows);
655
+ const meta = await metaFor(rows.map((r) => String(r.token)));
656
+ const payments = rows.map((r) => {
657
+ const m = meta.get(String(r.token)) ?? null;
658
+ const d = m?.decimals ?? cfg.decimals;
659
+ let amount = String(r.amount);
660
+ try {
661
+ amount = fromBaseUnits(BigInt(amount), d);
662
+ }
663
+ catch {
664
+ /* a figure the contract produced is a fact; surface it unchanged */
665
+ }
666
+ return {
667
+ request_id: r.request_id,
668
+ status: r.status,
669
+ reason: r.reason_code,
670
+ amount,
671
+ token: tokenLabel(String(r.token), m),
672
+ to: r.recipient,
673
+ node_id: r.node_id,
674
+ ...(r.memo ? { memo: r.memo } : {}),
675
+ // Nanoseconds are what the contract stores and not what anyone reads.
676
+ when: new Date(Number(BigInt(String(r.created_at_ns)) / 1000000n)).toISOString(),
677
+ charged_on_utc_day: r.reserved_day,
678
+ };
679
+ });
680
+ return json({
681
+ count: payments.length,
682
+ payments,
683
+ not_shown: "REFUSED payments are not in this list. The contract does not store a blocked " +
684
+ "request — that would let an agent grow the vault's storage bill by spamming " +
685
+ "refusals — so it returns the reason to the caller and emits an event instead. " +
686
+ "An empty list therefore means 'nothing was paid', NOT 'nothing was attempted'.",
687
+ ...(payments.length === (limit ?? 20)
688
+ ? { more: `Possibly more: pass fromIndex=${(fromIndex ?? 0) + payments.length}.` }
689
+ : {}),
690
+ });
691
+ });
692
+ server.tool("vault_request", "Read one stored payment request: status, reason code, amount, the ancestor chain it charged, and the UTC day it was reserved against. No key required.", { requestId: z.string() }, async ({ requestId }) => {
693
+ const r = await viewFunction(cfg.rpcUrl, requireVault(), "get_request", {
694
+ request_id: requestId,
695
+ });
696
+ // A BARE `null` IS AMBIGUOUS and this tool returned one. To a reader it is
697
+ // indistinguishable from a failed call, and to an agent it is a value to
698
+ // guess about rather than a fact to relay. Requests are also PURGEABLE, so
699
+ // "not found" has two very different causes and the honest answer names
700
+ // both instead of implying the payment never happened.
701
+ if (r === null || r === undefined) {
702
+ return text(`No request "${requestId}" on ${cfg.vault}. Either the id is wrong, or the ` +
703
+ `request was settled and later removed by purge_settled — purging is how a ` +
704
+ `vault reclaims storage, so an OLD successful payment can legitimately be gone. ` +
705
+ `vault_tree and vault_budget still reflect what it spent.`);
706
+ }
707
+ return json(r);
708
+ });
709
+ server.tool("vault_config", "Read vault config: owner, paused, strict-vs-overcommit allocation, node count, and clock mode. No key required.", {}, async () => {
710
+ const c = (await viewFunction(cfg.rpcUrl, requireVault(), "get_config", {}));
711
+ if (!c || typeof c !== "object")
712
+ return json(c);
713
+ /**
714
+ * EXPLAIN THE HIERARCHY, because it is the whole product and nothing
715
+ * showed it.
716
+ *
717
+ * `vault_tree` prints `child_cap_sum` and leaves the reader to work out
718
+ * what it implies. The implication is the entire differentiator: budgets
719
+ * are hierarchical, a payment charges every ancestor, and so a SIBLING
720
+ * spending first can exhaust a shared pool while your own cap still reads
721
+ * "25 of 25 available". That is the moment `PARENT_DAILY_BUDGET` arrives
722
+ * and looks like a bug.
723
+ *
724
+ * `strict_allocation` is the switch that decides whether that can happen
725
+ * at all, and as a bare boolean it told nobody anything — a reader cannot
726
+ * tell whether `false` is the safe setting or the dangerous one.
727
+ *
728
+ * Computed from the TREE rather than restated from config, so the numbers
729
+ * are the ones actually in force.
730
+ */
731
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
732
+ let allocation;
733
+ const root = Array.isArray(tree) ? tree.find((n) => n.id === 0) : null;
734
+ if (root) {
735
+ const lim = root.limits?.[0];
736
+ const sum = root.child_cap_sum?.[0];
737
+ if (lim && sum) {
738
+ const meta = (await metaFor([lim.token])).get(lim.token) ?? null;
739
+ const d = meta?.decimals ?? cfg.decimals;
740
+ const cap = BigInt(lim.daily_cap);
741
+ const allocated = BigInt(sum[1]);
742
+ const strict = c.strict_allocation === true;
743
+ allocation = {
744
+ token: tokenLabel(lim.token, meta),
745
+ root_daily_cap: fromBaseUnits(cap, d),
746
+ allocated_to_children: fromBaseUnits(allocated, d),
747
+ unallocated: fromBaseUnits(cap > allocated ? cap - allocated : 0n, d),
748
+ mode: strict ? "strict" : "overcommit",
749
+ meaning: strict
750
+ ? "Children's caps may not sum above the parent's, so each child's cap is " +
751
+ "genuinely its own and a sibling cannot take it."
752
+ : "Children's caps MAY sum above the parent's. A sibling that spends first can " +
753
+ "exhaust the shared pool, and the others are then refused PARENT_DAILY_BUDGET " +
754
+ "while their OWN cap still shows as available. That refusal is the design " +
755
+ "working, not a fault.",
756
+ ...(allocated >= cap && cap > 0n
757
+ ? {
758
+ note: "Children are allocated the ENTIRE root budget, so any second agent would " +
759
+ "share this pool rather than add to it.",
760
+ }
761
+ : {}),
762
+ };
763
+ }
764
+ }
765
+ /**
766
+ * AN OWNERSHIP TRANSFER IN FLIGHT WAS INVISIBLE.
767
+ *
768
+ * `get_pending_owner` exists and nothing read it. A two-step transfer is
769
+ * the safe design — the new owner must accept — but a half-finished one
770
+ * is exactly the state worth seeing, and `owner` alone shows the OLD
771
+ * owner with no hint that control is mid-handover.
772
+ */
773
+ const pendingOwner = await viewFunction(cfg.rpcUrl, requireVault(), "get_pending_owner", {}).catch(() => null);
774
+ return json({
775
+ ...c,
776
+ ...(pendingOwner
777
+ ? {
778
+ pending_owner: pendingOwner,
779
+ ownership_note: `A transfer to ${pendingOwner} is IN FLIGHT. ${c.owner} still controls this ` +
780
+ `vault until ${pendingOwner} accepts.`,
781
+ }
782
+ : {}),
783
+ // A bare `clock_mode: "production"` invites the question it does not
784
+ // answer. The test clock exists so integration tests can roll a UTC day
785
+ // without waiting; seeing it on a real vault would be alarming.
786
+ clock: c.clock_mode === "production" ? "real UTC time" : `TEST CLOCK (${c.clock_mode})`,
787
+ ...(allocation ? { allocation } : {}),
788
+ });
789
+ });
448
790
  server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault — e.g. a 'marketing' or 'news' agent with its own budget. Generates the agent's key, files it under the role name, and returns an UNSIGNED payload for the owner to approve. One FunctionCall creates the node, binds the key and installs its funded gas key. This server never signs it.", {
449
791
  role: z
450
792
  .string()
@@ -779,7 +1121,77 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
779
1121
  gasTgas: DEFAULT_GAS_TGAS,
780
1122
  route,
781
1123
  });
782
- return json(out);
1124
+ /**
1125
+ * HAND THE AGENT THE SENTENCE, do not hope it writes a good one.
1126
+ *
1127
+ * This used to `return json(out)` — just the contract's four fields — and
1128
+ * the instructions said "Report the reason code", so a user who asked to
1129
+ * pay someone got `PARENT_PER_TX_LIMIT` back. Correct, and useless to them.
1130
+ *
1131
+ * A good explanation needs NUMBERS the payment response never carried: the
1132
+ * per-payment cap, what is left today. So read them HERE, on the way out,
1133
+ * and only when they will be used. The budget read is one view call; the
1134
+ * caps come from the tree, fetched only when a limit actually fired.
1135
+ *
1136
+ * The code is NOT discarded — it moves under `technical`. Knowing exactly
1137
+ * which rule fired is a real asset (PARENT_DAILY_BUDGET meaning "a sibling
1138
+ * drained the shared pool" is something a flat policy model cannot say),
1139
+ * it is just not what a person wants first.
1140
+ */
1141
+ const o = out;
1142
+ const limitFired = /PER_TX_LIMIT|DAILY_BUDGET/.test(o.reason_code ?? "");
1143
+ const settled = o.status === "SETTLED";
1144
+ const tok = tokenFor(token);
1145
+ // The token's own symbol and decimals, cached process-wide, so the sentence
1146
+ // says "USDC" and not a 64-character contract id.
1147
+ const meta = await tokenMeta(cfg.rpcUrl, tok, (rpc, t) => viewFunction(rpc, t, "ft_metadata", {})).catch(() => null);
1148
+ const dec = meta?.decimals ?? cfg.decimals;
1149
+ let perTxCap;
1150
+ let available;
1151
+ let dailyCap;
1152
+ const nodeId = o.node_id;
1153
+ if (nodeId !== undefined && (limitFired || settled)) {
1154
+ // One view call, and it is the figure BOTH a success and a daily-cap
1155
+ // refusal want.
1156
+ const b = await viewFunction(cfg.rpcUrl, vault, "get_node_budget", { node_id: nodeId, token: tok }).catch(() => null);
1157
+ if (b) {
1158
+ available = fromBaseUnits(BigInt(b.available), dec);
1159
+ dailyCap = fromBaseUnits(BigInt(b.daily_cap), dec);
1160
+ }
1161
+ if (limitFired) {
1162
+ const tree = await viewFunction(cfg.rpcUrl, vault, "get_tree", {}).catch(() => null);
1163
+ if (tree) {
1164
+ const byId = new Map(tree.map((n) => [n.id, n]));
1165
+ let smallest;
1166
+ for (let cur = byId.get(nodeId); cur !== undefined; cur = cur.parent === null ? undefined : byId.get(cur.parent)) {
1167
+ const lim = (cur.limits ?? []).find((l) => l.token === tok);
1168
+ if (!lim)
1169
+ continue;
1170
+ const v = BigInt(lim.per_tx_cap);
1171
+ if (smallest === undefined || v < smallest)
1172
+ smallest = v;
1173
+ }
1174
+ if (smallest !== undefined)
1175
+ perTxCap = fromBaseUnits(smallest, dec);
1176
+ }
1177
+ }
1178
+ }
1179
+ const explained = explainOutcome({
1180
+ status: o.status,
1181
+ reason_code: o.reason_code,
1182
+ amount,
1183
+ recipient,
1184
+ symbol: meta?.symbol ?? "tokens",
1185
+ perTxCap,
1186
+ available,
1187
+ dailyCap,
1188
+ });
1189
+ return json({
1190
+ say: explained.say,
1191
+ next_step: explained.next,
1192
+ money_moved: explained.moved,
1193
+ technical: out,
1194
+ });
783
1195
  });
784
1196
  // ------------------------------------------------------- plans (unsigned)
785
1197
  server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget node. Pass parent=null for the root. Not signed by this server.", {
@@ -789,15 +1201,75 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
789
1201
  cooldownSec: z.number().int().min(0).default(0),
790
1202
  recipients: z.array(z.string()).nullable().default(null),
791
1203
  }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
792
- 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 }) => renderPlan(planSetNodeCaps(requireVault(), nodeId, limits)));
1204
+ /**
1205
+ * Refuse to build a signable payload for a node that does not exist.
1206
+ *
1207
+ * PLAN TOOLS HANDED OUT PAYLOADS FOR ANY `nodeId`. Measured 2026-11-10:
1208
+ * `vault_plan_set_caps({nodeId: 999})` against a two-node vault returned a
1209
+ * cheerful summary — "Set node 999 caps to … 1/day" — and a complete unsigned
1210
+ * transaction. The user approves it in their wallet, pays the gas, and the
1211
+ * CONTRACT rejects it. The cost is small and the experience is not: the only
1212
+ * signal anything was wrong arrives after a signature, from a layer the user
1213
+ * cannot read.
1214
+ *
1215
+ * A plan tool's entire job is to produce something safe to sign. "Safe to
1216
+ * sign" has to include "addresses something real".
1217
+ *
1218
+ * Costs one view call per plan. That is the right trade against a wasted
1219
+ * signature, and these are human-speed operations — nobody plans in a loop.
1220
+ *
1221
+ * Returns the node ids that DO exist, because "999 does not exist" without
1222
+ * them just prompts another guess.
1223
+ */
1224
+ async function assertNode(nodeId) {
1225
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
1226
+ // A failed READ must not block a plan: the chain being briefly unreachable
1227
+ // is not evidence the node is missing, and refusing here would turn a blip
1228
+ // into "your node does not exist".
1229
+ if (!Array.isArray(tree))
1230
+ return null;
1231
+ if (tree.some((n) => n.id === nodeId))
1232
+ return null;
1233
+ // RETURNS THE FACTS, NOT A SENTENCE. The first version returned prose and
1234
+ // the read path tried to reuse it by regex-stripping the signing clause,
1235
+ // which left "no node 999, Nodes: ...". Composing at the call site costs two
1236
+ // lines and cannot produce a mangled sentence.
1237
+ return { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") };
1238
+ }
1239
+ /** For a PLAN: the cost of getting this wrong is a wasted signature. */
1240
+ const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so that payload would be refused by the ` +
1241
+ `contract AFTER you signed and paid for it. Nodes: ${known}.`;
1242
+ /** For a READ: nothing is at stake, so just say what exists. */
1243
+ const noNodeForRead = (nodeId, known) => `This vault has no node ${nodeId}. Nodes: ${known}.`;
1244
+ 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 }) => {
1245
+ const bad = await assertNode(nodeId);
1246
+ return bad
1247
+ ? text(noNodeForPlan(nodeId, bad.known))
1248
+ : renderPlan(planSetNodeCaps(requireVault(), nodeId, limits));
1249
+ });
793
1250
  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.", {
794
1251
  nodeId: z.number().int().min(0),
795
1252
  publicKey: z.string().describe("ed25519:..."),
796
1253
  }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
797
1254
  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)));
798
- 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 }) => renderPlan(planFreezeNode(requireVault(), nodeId, frozen)));
799
- 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 }) => renderPlan(planSetRecipients(requireVault(), nodeId, recipients)));
800
- 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 }) => renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec)));
1255
+ 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 }) => {
1256
+ const bad = await assertNode(nodeId);
1257
+ return bad
1258
+ ? text(noNodeForPlan(nodeId, bad.known))
1259
+ : renderPlan(planFreezeNode(requireVault(), nodeId, frozen));
1260
+ });
1261
+ 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 () => {
1262
+ const bad = await assertNode(nodeId);
1263
+ return bad
1264
+ ? text(noNodeForPlan(nodeId, bad.known))
1265
+ : renderPlan(planSetRecipients(requireVault(), nodeId, recipients));
1266
+ })());
1267
+ 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 }) => {
1268
+ const bad = await assertNode(nodeId);
1269
+ return bad
1270
+ ? text(noNodeForPlan(nodeId, bad.known))
1271
+ : renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec));
1272
+ });
801
1273
  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", {
802
1274
  request_id: requestId,
803
1275
  })) ?? null));
@@ -812,11 +1284,74 @@ server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requ
812
1284
  }, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals)));
813
1285
  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)));
814
1286
  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)));
1287
+ /**
1288
+ * THE TOOL THAT WAS MISSING, and the gap had teeth.
1289
+ *
1290
+ * `vault_config` reports `strict_allocation` and now explains, at length,
1291
+ * that `overcommit` lets a sibling drain a shared pool and leave another
1292
+ * agent refused `PARENT_DAILY_BUDGET` with its own cap apparently free. The
1293
+ * payload builder `planSetStrictAllocation` has existed all along, and the
1294
+ * DASHBOARD exposes it as an owner action — but no MCP tool did.
1295
+ *
1296
+ * So the server described a mode worth changing and offered no way to change
1297
+ * it, through the one interface an agent can reach. Found by listing all 25
1298
+ * tools against the payload builders rather than by anyone hitting it.
1299
+ */
1300
+ server.tool("vault_plan_set_strict_allocation", "Build an UNSIGNED payload switching the vault between STRICT and OVERCOMMIT allocation. Strict refuses any change that would let children's caps sum above their parent's, so each child's budget is genuinely its own. Overcommit allows it, and then a sibling spending first can exhaust the shared pool — the others are refused PARENT_DAILY_BUDGET while their own cap still reads available. Read the current mode with vault_config. Not signed by this server.", {
1301
+ strict: z
1302
+ .boolean()
1303
+ .describe("true = children may not be allocated more than their parent holds. false = they may, and siblings then compete for the pool."),
1304
+ }, async ({ strict }) => renderPlan(planSetStrictAllocation(requireVault(), strict)));
815
1305
  server.tool("vault_plan_pause", "Build an UNSIGNED payload to pause or resume the whole vault. Pausing halts every agent payment. Not signed by this server.", { paused: z.boolean() }, async ({ paused }) => renderPlan(planSetPaused(requireVault(), paused)));
816
- server.tool("vault_plan_purge", "Build an UNSIGNED payload to delete settled payment records older than a UTC day. Spend totals are NOT refunded, but a deleted request id becomes reusable. Not signed by this server.", {
817
- beforeDay: z.number().int().min(0),
818
- limit: z.number().int().min(0).max(100).default(100),
819
- }, async ({ beforeDay, limit }) => renderPlan(planPurgeSettled(requireVault(), beforeDay, limit)));
1306
+ server.tool("vault_plan_purge", "Build an UNSIGNED payload deleting SETTLED payment records older than a cutoff, to reclaim the vault's storage stake. Spend totals are NOT refunded — purging frees bytes, it does not restore budget. Give `beforeDate` as YYYY-MM-DD; `beforeDay` (a Unix day number) is accepted for exactness. Not signed by this server.", {
1307
+ /**
1308
+ * A DATE, because the raw form asked a human for a Unix day number.
1309
+ *
1310
+ * `beforeDay: 20768` is what the contract indexes by and is unusable as an
1311
+ * input: nobody knows today's number, and an off-by-one silently purges a
1312
+ * day more or less than intended — of records that are then gone. The same
1313
+ * figure was shown in `vault_budget` output and fixed there for the same
1314
+ * reason; this is the input side of it.
1315
+ *
1316
+ * `beforeDay` survives for callers that genuinely have one, and because a
1317
+ * test-clock vault's day is NOT derivable from the wall clock.
1318
+ */
1319
+ beforeDate: z
1320
+ .string()
1321
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "beforeDate must be YYYY-MM-DD")
1322
+ .optional()
1323
+ .describe("purge records from strictly BEFORE this UTC date, e.g. 2026-10-01"),
1324
+ beforeDay: z
1325
+ .number()
1326
+ .int()
1327
+ .min(0)
1328
+ .optional()
1329
+ .describe("the raw Unix day number, if you have it. Overrides beforeDate."),
1330
+ limit: z
1331
+ .number()
1332
+ .int()
1333
+ .min(1)
1334
+ .max(1000)
1335
+ .optional()
1336
+ .describe("how many records at most, default 100. Large purges may exceed the gas limit."),
1337
+ }, async ({ beforeDate, beforeDay, limit }) => {
1338
+ // EXACTLY ONE CUTOFF, and no default. A purge deletes records
1339
+ // irreversibly; guessing "today" for a caller who forgot the argument
1340
+ // would silently purge everything settled.
1341
+ if (beforeDay === undefined && beforeDate === undefined) {
1342
+ return text(`Give a cutoff: beforeDate "YYYY-MM-DD" (records strictly before it are removed), ` +
1343
+ `or beforeDay if you have the raw number. There is deliberately no default — ` +
1344
+ `a purge cannot be undone, and "today" would remove every settled record.`);
1345
+ }
1346
+ let day = beforeDay;
1347
+ if (day === undefined) {
1348
+ const ms = Date.parse(`${beforeDate}T00:00:00Z`);
1349
+ if (Number.isNaN(ms))
1350
+ return text(`"${beforeDate}" is not a real date.`);
1351
+ day = Math.floor(ms / 86_400_000);
1352
+ }
1353
+ return renderPlan(planPurgeSettled(requireVault(), day, limit ?? 100));
1354
+ });
820
1355
  server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT of the vault to any account. The owner approves it in their own wallet; this server never signs. This is the step that decides whether money survives a teardown: deleting a NEAR account does NOT return fungible tokens, so they must be moved first.", {
821
1356
  amount: z
822
1357
  .string()
@@ -835,6 +1370,11 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
835
1370
  * zero a delete needs.
836
1371
  */
837
1372
  let base;
1373
+ // "Send 42 out of the vault" reads the same whether 42 is part of the
1374
+ // balance or ALL of it, and those are different decisions: the second
1375
+ // empties the vault, can fail in-flight payments, and is the step before a
1376
+ // teardown. The signer sees a summary, not this code.
1377
+ let emptying = false;
838
1378
  if (amount === undefined) {
839
1379
  const bal = await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", {
840
1380
  account_id: vault,
@@ -847,13 +1387,23 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
847
1387
  return text(`${vault} holds no ${tok}. Nothing to withdraw.`);
848
1388
  }
849
1389
  base = String(bal);
1390
+ emptying = true;
850
1391
  }
851
1392
  else {
852
1393
  // `toBaseUnits` returns a bigint; the payload carries base units as a
853
1394
  // STRING, because that is what the contract's U128 arg expects.
854
1395
  base = toBaseUnits(amount, cfg.decimals).toString();
855
1396
  }
856
- return renderPlan(planWithdraw(vault, tok, base, receiverId, cfg.decimals));
1397
+ const plan = planWithdraw(vault, tok, base, receiverId, cfg.decimals);
1398
+ if (emptying) {
1399
+ plan.warnings = [
1400
+ `This is the vault's ENTIRE ${tok} balance — it will hold zero afterwards. ` +
1401
+ `You did not name an amount, so the figure above was read from the chain.`,
1402
+ ...plan.warnings,
1403
+ ];
1404
+ plan.summary = `${plan.summary} (the ENTIRE balance)`;
1405
+ }
1406
+ return renderPlan(plan);
857
1407
  });
858
1408
  /**
859
1409
  * WHY A QUOTE AND NOT A PLAN, which every other owner action here is.
@@ -1021,9 +1571,51 @@ server.tool("vault_teardown_report", "Read-only. Report exactly what closing thi
1021
1571
  "0.08322 NEAR as tokens_burnt. Always withdrawFromGasKey first. teardown-vault.mjs " +
1022
1572
  "does this and exits rather than continuing if it fails.",
1023
1573
  },
1574
+ /**
1575
+ * POINT AT THE USER-FACING CLI, not the repo's internal script.
1576
+ *
1577
+ * This emitted `NETWORK=… VAULT=… OWNER=… TOKEN=… CONFIRM_MAINNET=1
1578
+ * EXPECT_TOKENS=1 node scripts/teardown-vault.mjs` — six environment
1579
+ * variables, a path only someone who cloned the repo has, no dry run,
1580
+ * and a delete at the end. For the one action a user takes when they are
1581
+ * already nervous, that is the worst possible interface, and it is not
1582
+ * what the product ships.
1583
+ *
1584
+ * `float-exit` is the published CLI for exactly this, and it is better
1585
+ * in every way that matters here: two flags, `--dry-run`, numbered steps,
1586
+ * "would" phrasing for everything it has not done yet, and an explicit
1587
+ * "This is irreversible" before the delete. It works via npx, so it needs
1588
+ * no checkout. `--network` defaults to mainnet.
1589
+ *
1590
+ * I pointed at the internal script for a week and then spent a round
1591
+ * fixing ITS ergonomics — adding the missing CONFIRM_MAINNET and
1592
+ * EXPECT_TOKENS — when the friendly path already existed. Fixing the
1593
+ * hard road is not the same as checking whether the easy one is there.
1594
+ *
1595
+ * THE DRY RUN LEADS, deliberately. The destructive form is given second
1596
+ * and separately, so copying the first thing you see cannot delete a
1597
+ * vault.
1598
+ *
1599
+ * THE `-p` IS LOAD-BEARING. float-exit is a BIN of create-float, not a
1600
+ * package of its own, so invoking it by bare scoped name 404s on the
1601
+ * registry. `scripts/check-npx-commands.mjs` enforces this — and it
1602
+ * flagged THIS comment when the wrong form was written out as a
1603
+ * counter-example, which is the check working: it cannot tell a warning
1604
+ * from an instruction, and neither can someone copying a line out of a
1605
+ * file. So the bad form is described rather than spelled.
1606
+ */
1024
1607
  command: signable
1025
- ? `NETWORK=${cfg.network} VAULT=${vault} OWNER=${config?.owner ?? "<owner>"} ` +
1026
- `TOKEN=${tok ?? "<token>"} node scripts/teardown-vault.mjs`
1608
+ ? {
1609
+ first_look: `npx -p @floatonnear/create-float float-exit --owner ${config?.owner ?? "<owner>"} ` +
1610
+ `--vault ${vault}${cfg.network === "mainnet" ? "" : ` --network ${cfg.network}`} --dry-run`,
1611
+ then_for_real: `npx -p @floatonnear/create-float float-exit --owner ${config?.owner ?? "<owner>"} ` +
1612
+ `--vault ${vault}${cfg.network === "mainnet" ? "" : ` --network ${cfg.network}`}`,
1613
+ what_the_dry_run_shows: "Every step it WOULD take, the exact NEAR and tokens coming back, and which " +
1614
+ "account they go to — without touching anything. Run it first.",
1615
+ needs: `A full-access key for ${config?.owner ?? "the owner"} in ~/.near-credentials. ` +
1616
+ `The owner's key is already full-access ON the vault (the factory adds it at ` +
1617
+ `creation), so no separate vault key is needed.`,
1618
+ }
1027
1619
  : "(none — nothing can sign as this vault)",
1028
1620
  note: "Read-only. This server holds at most a restricted agent key and signed nothing. " +
1029
1621
  "Run the command yourself, after the token withdrawal has settled.",