@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/amounts.d.ts +51 -0
- package/dist/amounts.js +55 -0
- package/dist/explain.d.ts +59 -0
- package/dist/explain.js +209 -0
- package/dist/roles.d.ts +3 -0
- package/dist/roles.js +34 -3
- package/dist/server.js +620 -28
- package/dist/token-meta.d.ts +42 -0
- package/dist/token-meta.js +46 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
799
|
-
|
|
800
|
-
|
|
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
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
1026
|
-
`
|
|
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.",
|