@floatonnear/mcp 0.6.5 → 0.9.1
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 +16 -0
- package/dist/amounts.js +30 -7
- package/dist/explain.d.ts +59 -0
- package/dist/explain.js +209 -0
- package/dist/server.js +649 -31
- package/dist/token-meta.d.ts +42 -0
- package/dist/token-meta.js +46 -0
- package/package.json +1 -1
package/dist/amounts.d.ts
CHANGED
|
@@ -52,4 +52,20 @@ export interface TreeNode {
|
|
|
52
52
|
export declare function humaniseTreeNode(node: TreeNode, cfg: {
|
|
53
53
|
token?: string;
|
|
54
54
|
decimals: number;
|
|
55
|
+
/**
|
|
56
|
+
* Per-token metadata, when it could be read from the chain.
|
|
57
|
+
*
|
|
58
|
+
* THIS REMOVES THE CAVEAT THE FIRST VERSION SHIPPED WITH. It could only
|
|
59
|
+
* humanise the one token whose decimals the operator had configured;
|
|
60
|
+
* every other kept base units, because scaling by a guessed exponent
|
|
61
|
+
* prints a figure wrong by orders of magnitude while looking tidy. The
|
|
62
|
+
* chain knows the real answer, so the guess was never necessary.
|
|
63
|
+
*
|
|
64
|
+
* A token still absent from this map keeps base units — "could not read
|
|
65
|
+
* the metadata" is not a licence to assume six.
|
|
66
|
+
*/
|
|
67
|
+
meta?: Map<string, {
|
|
68
|
+
symbol: string;
|
|
69
|
+
decimals: number;
|
|
70
|
+
} | null>;
|
|
55
71
|
}): TreeNode;
|
package/dist/amounts.js
CHANGED
|
@@ -56,8 +56,15 @@ export function fromBaseUnits(units, decimals) {
|
|
|
56
56
|
export function humaniseTreeNode(node, cfg) {
|
|
57
57
|
if (!node || typeof node !== "object")
|
|
58
58
|
return node;
|
|
59
|
-
|
|
60
|
-
const
|
|
59
|
+
// Prefer what the CHAIN said; fall back to the configured token only.
|
|
60
|
+
const dp = (token) => {
|
|
61
|
+
const m = cfg.meta?.get(token);
|
|
62
|
+
if (m)
|
|
63
|
+
return m.decimals;
|
|
64
|
+
return cfg.token && token === cfg.token ? cfg.decimals : null;
|
|
65
|
+
};
|
|
66
|
+
const sym = (token) => cfg.meta?.get(token)?.symbol ?? null;
|
|
67
|
+
const human = (v, decimals) => {
|
|
61
68
|
// EXPLICIT SHAPE CHECK, because `BigInt("")` is `0n` rather than a throw —
|
|
62
69
|
// and `BigInt(" ")` is too. A try/catch alone therefore converted an
|
|
63
70
|
// empty or whitespace cap into "0", which is not a parse failure the
|
|
@@ -66,7 +73,7 @@ export function humaniseTreeNode(node, cfg) {
|
|
|
66
73
|
if (typeof v !== "string" || !/^\d+$/.test(v))
|
|
67
74
|
return v;
|
|
68
75
|
try {
|
|
69
|
-
return fromBaseUnits(BigInt(v),
|
|
76
|
+
return fromBaseUnits(BigInt(v), decimals);
|
|
70
77
|
}
|
|
71
78
|
catch {
|
|
72
79
|
// Still guarded: fromBaseUnits rejects decimals outside its own range,
|
|
@@ -75,10 +82,26 @@ export function humaniseTreeNode(node, cfg) {
|
|
|
75
82
|
return v;
|
|
76
83
|
}
|
|
77
84
|
};
|
|
78
|
-
const limits = node.limits?.map((l) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
const limits = node.limits?.map((l) => {
|
|
86
|
+
const d = dp(l.token);
|
|
87
|
+
const symbol = sym(l.token);
|
|
88
|
+
if (d === null)
|
|
89
|
+
return { ...l, decimals: null, units: "base" };
|
|
90
|
+
return {
|
|
91
|
+
...l,
|
|
92
|
+
// The SYMBOL beside the id, never instead of it: anyone can deploy a
|
|
93
|
+
// token calling itself USDC, and a cap that names only a symbol cannot
|
|
94
|
+
// be checked against the one the user meant.
|
|
95
|
+
...(symbol ? { token_symbol: symbol } : {}),
|
|
96
|
+
daily_cap: human(l.daily_cap, d),
|
|
97
|
+
per_tx_cap: human(l.per_tx_cap, d),
|
|
98
|
+
decimals: d,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const childCapSum = node.child_cap_sum?.map(([token, amount]) => {
|
|
102
|
+
const d = dp(token);
|
|
103
|
+
return (d === null ? [token, amount] : [token, human(amount, d)]);
|
|
104
|
+
});
|
|
82
105
|
return {
|
|
83
106
|
...node,
|
|
84
107
|
...(limits ? { limits } : {}),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a payment outcome into something a person would actually say.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE SERVER DOES THIS AND NOT THE MODEL. Two reasons, and the second is
|
|
5
|
+
* the one that decides it.
|
|
6
|
+
*
|
|
7
|
+
* First, the old instructions literally said "Report the reason code", so the
|
|
8
|
+
* agent did: a user asking to pay someone got `PARENT_PER_TX_LIMIT` back. That
|
|
9
|
+
* is a correct answer to a question nobody asked. Telling the model "be
|
|
10
|
+
* friendly" instead would help, but phrasing drifts between models, between
|
|
11
|
+
* versions, and between one conversation and the next.
|
|
12
|
+
*
|
|
13
|
+
* Second, and decisively: a USEFUL explanation needs NUMBERS the model does
|
|
14
|
+
* not have. "That's over the limit" is nearly as useless as the raw code. What
|
|
15
|
+
* a person needs is "the most you can send in one payment is 10, and this asked
|
|
16
|
+
* for 15 — try 10 or less". Those figures live in the budget tree, which the
|
|
17
|
+
* payment response never carried. So the server reads them and writes the
|
|
18
|
+
* sentence; the agent relays it.
|
|
19
|
+
*
|
|
20
|
+
* THE CODE IS KEPT, not discarded. Precision about WHICH rule fired is a real
|
|
21
|
+
* asset — `PARENT_DAILY_BUDGET` meaning "a sibling drained the shared pool" is
|
|
22
|
+
* something a flat policy model cannot express, and developers and the pitch
|
|
23
|
+
* both want it. So it stays in the payload under `technical`, out of the way
|
|
24
|
+
* of the sentence a user sees.
|
|
25
|
+
*
|
|
26
|
+
* HOUSE STYLE, since this is product copy:
|
|
27
|
+
* - one or two short sentences, no jargon, no ALL-CAPS codes
|
|
28
|
+
* - name the real numbers and the real token symbol
|
|
29
|
+
* - say what to do next when there IS something to do
|
|
30
|
+
* - never imply money moved when it did not, and never imply it is gone
|
|
31
|
+
* when the refusal was free
|
|
32
|
+
*/
|
|
33
|
+
/** What the contract returned, plus whatever context the handler could gather. */
|
|
34
|
+
export interface OutcomeContext {
|
|
35
|
+
status?: string;
|
|
36
|
+
reason_code?: string;
|
|
37
|
+
/** Human-units amount that was requested, e.g. "15". */
|
|
38
|
+
amount: string;
|
|
39
|
+
recipient: string;
|
|
40
|
+
/** Token symbol if known ("USDC"), else a short id. */
|
|
41
|
+
symbol: string;
|
|
42
|
+
/** Per-payment cap in human units, when known. */
|
|
43
|
+
perTxCap?: string | undefined;
|
|
44
|
+
/** Remaining budget today in human units, when known. */
|
|
45
|
+
available?: string | undefined;
|
|
46
|
+
/** Daily cap in human units, when known. */
|
|
47
|
+
dailyCap?: string | undefined;
|
|
48
|
+
/** Seconds left on a cooldown, when known. */
|
|
49
|
+
cooldownLeftSec?: number | undefined;
|
|
50
|
+
}
|
|
51
|
+
export interface Explanation {
|
|
52
|
+
/** The sentence(s) to say to the user. No codes, no jargon. */
|
|
53
|
+
say: string;
|
|
54
|
+
/** A concrete next step, or null when there is nothing for the user to do. */
|
|
55
|
+
next: string | null;
|
|
56
|
+
/** True when value actually moved. */
|
|
57
|
+
moved: boolean;
|
|
58
|
+
}
|
|
59
|
+
export declare function explainOutcome(c: OutcomeContext): Explanation;
|
package/dist/explain.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a payment outcome into something a person would actually say.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE SERVER DOES THIS AND NOT THE MODEL. Two reasons, and the second is
|
|
5
|
+
* the one that decides it.
|
|
6
|
+
*
|
|
7
|
+
* First, the old instructions literally said "Report the reason code", so the
|
|
8
|
+
* agent did: a user asking to pay someone got `PARENT_PER_TX_LIMIT` back. That
|
|
9
|
+
* is a correct answer to a question nobody asked. Telling the model "be
|
|
10
|
+
* friendly" instead would help, but phrasing drifts between models, between
|
|
11
|
+
* versions, and between one conversation and the next.
|
|
12
|
+
*
|
|
13
|
+
* Second, and decisively: a USEFUL explanation needs NUMBERS the model does
|
|
14
|
+
* not have. "That's over the limit" is nearly as useless as the raw code. What
|
|
15
|
+
* a person needs is "the most you can send in one payment is 10, and this asked
|
|
16
|
+
* for 15 — try 10 or less". Those figures live in the budget tree, which the
|
|
17
|
+
* payment response never carried. So the server reads them and writes the
|
|
18
|
+
* sentence; the agent relays it.
|
|
19
|
+
*
|
|
20
|
+
* THE CODE IS KEPT, not discarded. Precision about WHICH rule fired is a real
|
|
21
|
+
* asset — `PARENT_DAILY_BUDGET` meaning "a sibling drained the shared pool" is
|
|
22
|
+
* something a flat policy model cannot express, and developers and the pitch
|
|
23
|
+
* both want it. So it stays in the payload under `technical`, out of the way
|
|
24
|
+
* of the sentence a user sees.
|
|
25
|
+
*
|
|
26
|
+
* HOUSE STYLE, since this is product copy:
|
|
27
|
+
* - one or two short sentences, no jargon, no ALL-CAPS codes
|
|
28
|
+
* - name the real numbers and the real token symbol
|
|
29
|
+
* - say what to do next when there IS something to do
|
|
30
|
+
* - never imply money moved when it did not, and never imply it is gone
|
|
31
|
+
* when the refusal was free
|
|
32
|
+
*/
|
|
33
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
34
|
+
/** "2 minutes", "3 hours" — a duration a person can act on. */
|
|
35
|
+
function human(sec) {
|
|
36
|
+
if (sec < 60)
|
|
37
|
+
return `${sec} ${plural(sec, "second", "seconds")}`;
|
|
38
|
+
const m = Math.ceil(sec / 60);
|
|
39
|
+
if (m < 60)
|
|
40
|
+
return `${m} ${plural(m, "minute", "minutes")}`;
|
|
41
|
+
const h = Math.ceil(m / 60);
|
|
42
|
+
return `${h} ${plural(h, "hour", "hours")}`;
|
|
43
|
+
}
|
|
44
|
+
export function explainOutcome(c) {
|
|
45
|
+
const amt = `${c.amount} ${c.symbol}`;
|
|
46
|
+
const code = c.reason_code ?? "";
|
|
47
|
+
// ---- It worked. Say what happened, in that order: what, to whom, what is left.
|
|
48
|
+
if (c.status === "SETTLED" || code === "SETTLED") {
|
|
49
|
+
const left = c.available !== undefined
|
|
50
|
+
? ` There's ${c.available} ${c.symbol} left to spend today.`
|
|
51
|
+
: "";
|
|
52
|
+
return { say: `Sent ${amt} to ${c.recipient}.${left}`, next: null, moved: true };
|
|
53
|
+
}
|
|
54
|
+
// ---- In flight. Do NOT claim it arrived.
|
|
55
|
+
if (c.status === "TRANSFER_PENDING" || code === "TRANSFER_PENDING") {
|
|
56
|
+
return {
|
|
57
|
+
say: `The ${amt} payment to ${c.recipient} is on its way — it hasn't confirmed yet.`,
|
|
58
|
+
next: "Check again in a moment to see whether it completed.",
|
|
59
|
+
moved: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* ---- Approved by the rules, then the transfer itself failed.
|
|
64
|
+
*
|
|
65
|
+
* Said carefully: the budget was NOT charged, and people assume a failed
|
|
66
|
+
* payment means missing money. The usual cause is a payee not registered on
|
|
67
|
+
* the token, which is a real thing they can fix.
|
|
68
|
+
*/
|
|
69
|
+
if (c.status === "TRANSFER_FAILED" || code === "TRANSFER_FAILED") {
|
|
70
|
+
return {
|
|
71
|
+
say: `The ${amt} payment to ${c.recipient} didn't go through, so nothing left the vault. ` +
|
|
72
|
+
`Usually that means ${c.recipient} isn't set up to receive ${c.symbol} yet.`,
|
|
73
|
+
next: `Ask ${c.recipient} to register for ${c.symbol}, then try again.`,
|
|
74
|
+
moved: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const nothingMoved = " Nothing was sent.";
|
|
78
|
+
switch (code) {
|
|
79
|
+
// ---- The two limit shapes, which are the ones users actually meet.
|
|
80
|
+
case "PER_TX_LIMIT":
|
|
81
|
+
case "PARENT_PER_TX_LIMIT": {
|
|
82
|
+
const cap = c.perTxCap;
|
|
83
|
+
const whose = code === "PARENT_PER_TX_LIMIT"
|
|
84
|
+
? "a limit set higher up the budget"
|
|
85
|
+
: "this agent's limit";
|
|
86
|
+
return {
|
|
87
|
+
say: cap
|
|
88
|
+
? `That's too big for one payment — ${whose} caps each payment at ${cap} ${c.symbol}, ` +
|
|
89
|
+
`and this asked for ${c.amount}.${nothingMoved}`
|
|
90
|
+
: `That's more than ${whose} allows in a single payment.${nothingMoved}`,
|
|
91
|
+
next: cap
|
|
92
|
+
? `Send ${cap} ${c.symbol} or less, or ask the vault owner to raise the per-payment limit.`
|
|
93
|
+
: "Try a smaller amount, or ask the vault owner to raise the per-payment limit.",
|
|
94
|
+
moved: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case "DAILY_BUDGET": {
|
|
98
|
+
const left = c.available;
|
|
99
|
+
return {
|
|
100
|
+
say: left
|
|
101
|
+
? `That would go past today's spending limit — ${left} ${c.symbol} is left of ` +
|
|
102
|
+
`${c.dailyCap ?? "today's"} ${c.symbol}, and this asked for ${c.amount}.${nothingMoved}`
|
|
103
|
+
: `Today's spending limit is used up.${nothingMoved}`,
|
|
104
|
+
next: left && Number(left) > 0
|
|
105
|
+
? `Send ${left} ${c.symbol} or less, or wait — the limit resets at midnight UTC.`
|
|
106
|
+
: "The limit resets at midnight UTC, or the owner can raise it.",
|
|
107
|
+
moved: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The one worth explaining properly, because the obvious reading is wrong:
|
|
112
|
+
* this agent's OWN allowance can look completely free.
|
|
113
|
+
*/
|
|
114
|
+
case "PARENT_DAILY_BUDGET":
|
|
115
|
+
return {
|
|
116
|
+
say: `This agent still has room, but the shared pool it spends from is used up for today — ` +
|
|
117
|
+
`another agent got there first.${nothingMoved}`,
|
|
118
|
+
next: "The pool resets at midnight UTC, or the owner can raise the shared limit.",
|
|
119
|
+
moved: false,
|
|
120
|
+
};
|
|
121
|
+
case "COOLDOWN":
|
|
122
|
+
case "PARENT_COOLDOWN":
|
|
123
|
+
return {
|
|
124
|
+
say: `Payments from here are spaced out, and the next one isn't due yet` +
|
|
125
|
+
`${c.cooldownLeftSec ? ` — about ${human(c.cooldownLeftSec)} to go` : ""}.${nothingMoved}`,
|
|
126
|
+
next: "Try again shortly.",
|
|
127
|
+
moved: false,
|
|
128
|
+
};
|
|
129
|
+
// ---- Owner decisions. The user cannot fix these themselves, so say who can.
|
|
130
|
+
case "NO_RECIPIENTS_SET":
|
|
131
|
+
return {
|
|
132
|
+
say: `This agent isn't cleared to pay anyone yet.${nothingMoved}`,
|
|
133
|
+
next: "The vault owner needs to approve a list of payees first.",
|
|
134
|
+
moved: false,
|
|
135
|
+
};
|
|
136
|
+
case "RECIPIENT_NOT_ALLOWED":
|
|
137
|
+
return {
|
|
138
|
+
say: `${c.recipient} isn't on this agent's approved list of payees.${nothingMoved}`,
|
|
139
|
+
next: "The vault owner decides who can be paid, and can add them.",
|
|
140
|
+
moved: false,
|
|
141
|
+
};
|
|
142
|
+
case "VAULT_PAUSED":
|
|
143
|
+
return {
|
|
144
|
+
say: `The vault is paused, so no payments can go out right now.${nothingMoved}`,
|
|
145
|
+
next: "The vault owner can unpause it.",
|
|
146
|
+
moved: false,
|
|
147
|
+
};
|
|
148
|
+
case "NODE_FROZEN":
|
|
149
|
+
return {
|
|
150
|
+
say: `This agent is frozen, so it can't spend.${nothingMoved}`,
|
|
151
|
+
next: "The vault owner can unfreeze it.",
|
|
152
|
+
moved: false,
|
|
153
|
+
};
|
|
154
|
+
case "TOKEN_NOT_ALLOWED":
|
|
155
|
+
return {
|
|
156
|
+
say: `This agent doesn't have a ${c.symbol} budget.${nothingMoved}`,
|
|
157
|
+
next: `The vault owner can give it one, or pay in a token it does have.`,
|
|
158
|
+
moved: false,
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Not a refusal — a pause. Must not read as a failure, and must not read
|
|
162
|
+
* as done either.
|
|
163
|
+
*/
|
|
164
|
+
case "APPROVAL_REQUIRED":
|
|
165
|
+
return {
|
|
166
|
+
say: `${amt} to ${c.recipient} is large enough to need the owner's sign-off, so it's ` +
|
|
167
|
+
`waiting for them. Nothing has been sent yet.`,
|
|
168
|
+
next: "Ask the vault owner to approve it.",
|
|
169
|
+
moved: false,
|
|
170
|
+
};
|
|
171
|
+
case "APPROVAL_REJECTED":
|
|
172
|
+
return {
|
|
173
|
+
say: `The vault owner turned down the ${amt} payment to ${c.recipient}.${nothingMoved}`,
|
|
174
|
+
next: null,
|
|
175
|
+
moved: false,
|
|
176
|
+
};
|
|
177
|
+
case "DUPLICATE_REQUEST":
|
|
178
|
+
return {
|
|
179
|
+
say: "That exact payment was already submitted, so it wasn't sent twice.",
|
|
180
|
+
next: "Check whether the first one went through before trying again.",
|
|
181
|
+
moved: false,
|
|
182
|
+
};
|
|
183
|
+
case "INVALID_AMOUNT":
|
|
184
|
+
return { say: `${c.amount} isn't a valid amount to send.`, next: "Use an amount above zero.", moved: false };
|
|
185
|
+
case "INVALID_MEMO":
|
|
186
|
+
return {
|
|
187
|
+
say: "The note attached to that payment isn't allowed — it's too long or has odd characters.",
|
|
188
|
+
next: "Shorten the note and try again.",
|
|
189
|
+
moved: false,
|
|
190
|
+
};
|
|
191
|
+
default: {
|
|
192
|
+
/**
|
|
193
|
+
* UNKNOWN CODE: say plainly that it was refused and that nothing moved,
|
|
194
|
+
* and THEN give the code — because an unrecognised code is the one case
|
|
195
|
+
* where the raw value is genuinely the most useful thing a user can pass
|
|
196
|
+
* on to whoever can help. Guessing a friendly sentence for a rule we
|
|
197
|
+
* cannot describe would be worse than admitting it.
|
|
198
|
+
*/
|
|
199
|
+
if (!code) {
|
|
200
|
+
return { say: `The ${amt} payment to ${c.recipient} didn't go through.${nothingMoved}`, next: null, moved: false };
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
say: `The payment was refused by the vault's rules, so nothing was sent.`,
|
|
204
|
+
next: `If you need to report this, the reason given was "${code}".`,
|
|
205
|
+
moved: false,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -54,12 +54,13 @@ import { topUpFor, paymentsAfterTopUp } from "./gas-math.js";
|
|
|
54
54
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
55
55
|
import { z } from "zod";
|
|
56
56
|
import { fromBaseUnits, humaniseTreeNode, 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";
|
|
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
|
/**
|
|
@@ -193,9 +223,40 @@ const cfg = {
|
|
|
193
223
|
// "configured" and unreachable.
|
|
194
224
|
agentKeysPath: process.env.NEAR_VAULT_AGENT_KEYS ?? ".float/agent-keys.json",
|
|
195
225
|
};
|
|
226
|
+
/**
|
|
227
|
+
* NO VAULT IS THE COMMONEST STATE, SO THE MESSAGE HAS TO BE AN ONRAMP.
|
|
228
|
+
*
|
|
229
|
+
* This said "NEAR_VAULT_CONTRACT is not set" — true, and a dead end. Measured
|
|
230
|
+
* 2026-11-12: an agent asked to pay someone got that from `vault_config` and
|
|
231
|
+
* `vault_tree`, concluded the NEAR rail was unavailable, then searched three
|
|
232
|
+
* unrelated wallets for something that could reach a `.near` address, and
|
|
233
|
+
* finally asked the user to paste a contract id. It never suggested MAKING a
|
|
234
|
+
* vault, because nothing in the failure told it that was possible without one.
|
|
235
|
+
*
|
|
236
|
+
* The WORKFLOW preamble already said "No vault yet? vault_create" and that was
|
|
237
|
+
* not enough: at the moment of failure an agent reads the ERROR, not the
|
|
238
|
+
* instructions it was handed at startup. So the routing belongs here.
|
|
239
|
+
*
|
|
240
|
+
* It is the FIRST thing a new install hits, and the product's answer is
|
|
241
|
+
* genuinely good: `vault_create` needs no key, no existing vault and no
|
|
242
|
+
* configuration. An error that does not say so turns the normal first step
|
|
243
|
+
* into a support question.
|
|
244
|
+
*
|
|
245
|
+
* Names BOTH cases — "you have none" and "you have one this client does not
|
|
246
|
+
* know about" — because the fix differs completely and the message cannot tell
|
|
247
|
+
* which it is.
|
|
248
|
+
*/
|
|
196
249
|
function requireVault() {
|
|
197
|
-
if (!cfg.vault)
|
|
198
|
-
throw new Error("
|
|
250
|
+
if (!cfg.vault) {
|
|
251
|
+
throw new Error("No vault is configured, so there is nothing to read yet. " +
|
|
252
|
+
"If the user does not have a vault, call vault_create — it needs NO key, no existing " +
|
|
253
|
+
"vault and no configuration, and returns a link they approve in their own wallet. That " +
|
|
254
|
+
"is the normal first step, not an error. " +
|
|
255
|
+
"If they DO have one, set NEAR_VAULT_CONTRACT to its address and restart the client " +
|
|
256
|
+
"(servers are read once at startup); vault_create with an `owner` reports the address " +
|
|
257
|
+
"the factory derives for them. " +
|
|
258
|
+
"Do not go looking for another way to send money — no other rail can reach a NEAR vault.");
|
|
259
|
+
}
|
|
199
260
|
return cfg.vault;
|
|
200
261
|
}
|
|
201
262
|
function tokenFor(t) {
|
|
@@ -280,19 +341,39 @@ WORKFLOW
|
|
|
280
341
|
5. Winding down is a CLI job, not a tool: npx -p @floatonnear/create-float float-exit. It returns the tokens,
|
|
281
342
|
the gas-key balances and the storage deposit. vault_teardown_report says what is recoverable.
|
|
282
343
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
344
|
+
SPEAK LIKE A PERSON. NEVER SHOW A REASON CODE TO THE USER.
|
|
345
|
+
vault_pay returns "say" -- a plain-English sentence with the real numbers in it -- plus
|
|
346
|
+
"next_step" and "money_moved". RELAY "say". Add "next_step" if there is one. That is the
|
|
347
|
+
whole reply; do not append the code, the request id, or the raw JSON.
|
|
348
|
+
|
|
349
|
+
A user who asked to pay someone and got "PARENT_PER_TX_LIMIT" has been told nothing. The
|
|
350
|
+
codes are precise and they do matter -- they are under "technical" for debugging, and you
|
|
351
|
+
should quote them only if the user asks why, asks for detail, or is clearly a developer.
|
|
352
|
+
|
|
353
|
+
Say this: "That is too big for one payment -- the most per payment is 10 USDC, and this
|
|
354
|
+
asked for 15. Nothing was sent. Send 10 USDC or less, or ask the vault owner
|
|
355
|
+
to raise the limit."
|
|
356
|
+
Not this: "The payment was BLOCKED with reason_code PARENT_PER_TX_LIMIT."
|
|
357
|
+
|
|
358
|
+
On success too: "Sent 0.25 USDC to alice.near. There is 24.75 USDC left to spend today."
|
|
359
|
+
Not "status: SETTLED".
|
|
360
|
+
|
|
361
|
+
REFUSALS ARE ANSWERS, not errors. Do not retry blindly -- a refused payment is the contract
|
|
362
|
+
doing its job. "money_moved" tells you whether anything actually left the vault; never imply
|
|
363
|
+
money moved when it is false, and never imply money is lost when a refusal was free.
|
|
288
364
|
|
|
289
365
|
WORTH KNOWING
|
|
290
366
|
Budgets are hierarchical: a payment charges every ancestor, so a vault-wide ceiling holds even
|
|
291
367
|
when a child's cap looks free.
|
|
368
|
+
Understand the difference between DAILY_BUDGET and PARENT_DAILY_BUDGET even though you must
|
|
369
|
+
not print either. DAILY_BUDGET means this agent spent its own allowance. PARENT_DAILY_BUDGET
|
|
370
|
+
means a SIBLING drained the shared pool, so this agent's own cap can still read as fully
|
|
371
|
+
available -- a user who checks their budget, sees room, and is told only "refused" will
|
|
372
|
+
reasonably think the product is broken. Neither is fixed by retrying.
|
|
292
373
|
An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
|
|
293
374
|
named account rather than retrying.
|
|
294
375
|
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.
|
|
376
|
+
const server = new McpServer({ name: "float", version: "0.9.1" }, { instructions: INSTRUCTIONS });
|
|
296
377
|
// ----------------------------------------------------------------- reads
|
|
297
378
|
/**
|
|
298
379
|
* Wait for a create to land, and prove the result belongs to the user.
|
|
@@ -388,6 +469,36 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
|
|
|
388
469
|
}
|
|
389
470
|
// THE OWNER READ IS THE POINT. Getting here only proves an account exists.
|
|
390
471
|
const config = await viewFunction(rpc, target, "get_config", {}).catch(() => null);
|
|
472
|
+
/**
|
|
473
|
+
* A `get_config` WITH AN OWNER IS NOT PROOF OF A VAULT.
|
|
474
|
+
*
|
|
475
|
+
* The FACTORY also exports `get_config`, and it also returns an `owner` —
|
|
476
|
+
* so pointing this tool at `usefloat.near` with the factory's own owner
|
|
477
|
+
* returned **LIVE**, and the next step it printed was
|
|
478
|
+
* `NEAR_VAULT_CONTRACT=usefloat.near`. Every later read would then query a
|
|
479
|
+
* factory for budgets it does not have.
|
|
480
|
+
*
|
|
481
|
+
* Measured 2026-11-12 on mainnet: the two shapes are cleanly separable --
|
|
482
|
+
* a vault answers `vault_id, owner, network, paused, strict_allocation,
|
|
483
|
+
* node_count, clock_mode`; the factory answers `owner, code_hash, network,
|
|
484
|
+
* required_deposit, paused`. `vault_id` is the discriminator, and it is the
|
|
485
|
+
* field whose presence means "I am the vault you are asking about".
|
|
486
|
+
*
|
|
487
|
+
* Checked BEFORE the owner comparison, because "this is not a vault" is a
|
|
488
|
+
* different and more fundamental answer than "this vault is someone
|
|
489
|
+
* else's", and reporting the latter about a factory would send someone
|
|
490
|
+
* looking for a nonexistent rival owner.
|
|
491
|
+
*/
|
|
492
|
+
if (config && typeof config.vault_id !== "string") {
|
|
493
|
+
return json({
|
|
494
|
+
status: "UNVERIFIED",
|
|
495
|
+
vault: target,
|
|
496
|
+
instruction: `${target} answers get_config but it is NOT a Float vault — a vault's config ` +
|
|
497
|
+
`carries vault_id, and this one does not. The factory exports get_config too, ` +
|
|
498
|
+
`with an owner field, so "it responded and the owner matched" is not proof. Do ` +
|
|
499
|
+
`NOT set NEAR_VAULT_CONTRACT to it.`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
391
502
|
if (!config || typeof config.owner !== "string") {
|
|
392
503
|
return json({
|
|
393
504
|
status: "UNVERIFIED",
|
|
@@ -420,11 +531,63 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
|
|
|
420
531
|
],
|
|
421
532
|
});
|
|
422
533
|
});
|
|
534
|
+
/**
|
|
535
|
+
* Resolve every token named in a tree, once, so output can say "USDC".
|
|
536
|
+
*
|
|
537
|
+
* Fetched in PARALLEL and deduplicated: a tree with five nodes sharing one
|
|
538
|
+
* token must make one call, not five. `tokenMeta` caches for the process
|
|
539
|
+
* lifetime, so the second `vault_tree` of a session makes none.
|
|
540
|
+
*/
|
|
541
|
+
async function metaFor(tokens) {
|
|
542
|
+
const unique = [...new Set(tokens)];
|
|
543
|
+
const pairs = await Promise.all(unique.map(async (t) => [
|
|
544
|
+
t,
|
|
545
|
+
await tokenMeta(cfg.rpcUrl, t, (rpc, token) => viewFunction(rpc, token, "ft_metadata", {})),
|
|
546
|
+
]));
|
|
547
|
+
return new Map(pairs);
|
|
548
|
+
}
|
|
423
549
|
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 () => {
|
|
424
550
|
const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}));
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
551
|
+
if (!Array.isArray(tree))
|
|
552
|
+
return json(tree);
|
|
553
|
+
const meta = await metaFor(tree.flatMap((n) => n.limits?.map((l) => l.token) ?? []));
|
|
554
|
+
/**
|
|
555
|
+
* THE APPROVAL THRESHOLD WAS WRITE-ONLY.
|
|
556
|
+
*
|
|
557
|
+
* `vault_plan_set_approval_threshold` builds a payload to set one, and
|
|
558
|
+
* `get_effective_threshold` has always existed on the contract — but
|
|
559
|
+
* nothing read it back. So an owner could impose a rule and then had no
|
|
560
|
+
* way to see it, and an agent could not warn "this payment will be HELD"
|
|
561
|
+
* before making one. A policy you cannot read is a policy you cannot
|
|
562
|
+
* trust.
|
|
563
|
+
*
|
|
564
|
+
* EFFECTIVE, not the node's own: a threshold INHERITS down the tree, so
|
|
565
|
+
* the figure that actually governs a node may have been set on an
|
|
566
|
+
* ancestor. Reporting the local one would under-report the rule in force.
|
|
567
|
+
*/
|
|
568
|
+
const thresholds = await Promise.all(tree.map((n) => viewFunction(cfg.rpcUrl, requireVault(), "get_effective_threshold", {
|
|
569
|
+
node_id: n.id,
|
|
570
|
+
}).catch(() => null)));
|
|
571
|
+
return json(tree.map((n, i) => {
|
|
572
|
+
const node = humaniseTreeNode(n, { token: cfg.token, decimals: cfg.decimals, meta });
|
|
573
|
+
const raw = thresholds[i];
|
|
574
|
+
if (raw === null || raw === undefined)
|
|
575
|
+
return node;
|
|
576
|
+
const tok = n.limits?.[0]?.token;
|
|
577
|
+
const d = (tok ? meta.get(tok)?.decimals : undefined) ?? cfg.decimals;
|
|
578
|
+
let human = String(raw);
|
|
579
|
+
try {
|
|
580
|
+
human = fromBaseUnits(BigInt(human), d);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
/* surface what the contract said */
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
...node,
|
|
587
|
+
approval_threshold: human,
|
|
588
|
+
approval_note: `Payments above ${human} from this node are HELD until the owner approves them.`,
|
|
589
|
+
};
|
|
590
|
+
}));
|
|
428
591
|
});
|
|
429
592
|
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.", {
|
|
430
593
|
nodeId: z.number().int().min(0),
|
|
@@ -436,20 +599,225 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
|
|
|
436
599
|
token: tokenFor(token),
|
|
437
600
|
day: day ?? null,
|
|
438
601
|
}));
|
|
439
|
-
if (!b)
|
|
440
|
-
|
|
441
|
-
|
|
602
|
+
if (!b) {
|
|
603
|
+
// "no limit for that token" ASSERTED THAT THE NODE EXISTS. Asked about
|
|
604
|
+
// node 999 of a two-node vault it said the same thing, which sends a
|
|
605
|
+
// user off to set a limit on a node that is not there. Two different
|
|
606
|
+
// facts had one message.
|
|
607
|
+
const missing = await assertNode(nodeId);
|
|
608
|
+
if (missing)
|
|
609
|
+
return text(noNodeForRead(nodeId, missing.known));
|
|
610
|
+
const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
|
|
611
|
+
const here = tree?.find((n) => n.id === nodeId)?.limits?.map((l) => l.token) ?? [];
|
|
612
|
+
return text(`Node ${nodeId} exists but has no limit for ${tokenFor(token)}. ` +
|
|
613
|
+
(here.length
|
|
614
|
+
? `It has limits for: ${here.join(", ")}.`
|
|
615
|
+
: `It has no limits for any token, so it can pay nothing — set one with vault_plan_set_caps.`));
|
|
616
|
+
}
|
|
617
|
+
// The TOKEN's own decimals, not the configured guess. A vault may hold
|
|
618
|
+
// caps for several tokens and only one of them is NEAR_VAULT_TOKEN.
|
|
619
|
+
const tok = tokenFor(token);
|
|
620
|
+
const meta = (await metaFor([tok])).get(tok) ?? null;
|
|
621
|
+
const d = meta?.decimals ?? cfg.decimals;
|
|
442
622
|
return json({
|
|
443
623
|
node_id: nodeId,
|
|
624
|
+
token: tokenLabel(tok, meta),
|
|
625
|
+
/**
|
|
626
|
+
* `day: 20768` MEANS NOTHING TO A PERSON. It is days since the Unix
|
|
627
|
+
* epoch, which is the right key for a ledger and the wrong thing to
|
|
628
|
+
* show someone asking what they have left. The number stays — it is
|
|
629
|
+
* what the contract indexes by, and a support conversation needs it —
|
|
630
|
+
* but the date leads, and the reset is stated because "daily" does not
|
|
631
|
+
* say WHEN, and a budget that resets at midnight in a timezone you do
|
|
632
|
+
* not live in is a surprise worth pre-empting.
|
|
633
|
+
*/
|
|
634
|
+
date: new Date(Number(b.day) * 86_400_000).toISOString().slice(0, 10),
|
|
444
635
|
day: b.day,
|
|
636
|
+
resets: "00:00 UTC",
|
|
445
637
|
daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
|
|
446
638
|
executed: fromBaseUnits(BigInt(b.executed), d),
|
|
447
639
|
pending: fromBaseUnits(BigInt(b.pending), d),
|
|
448
640
|
available: fromBaseUnits(BigInt(b.available), d),
|
|
449
641
|
});
|
|
450
642
|
});
|
|
451
|
-
|
|
452
|
-
|
|
643
|
+
/**
|
|
644
|
+
* THE ACTIVITY FEED, which nothing exposed.
|
|
645
|
+
*
|
|
646
|
+
* `list_requests` has been on the contract all along. The MCP had only
|
|
647
|
+
* `vault_request(requestId)` — a lookup by an id you can obtain *only* from a
|
|
648
|
+
* `vault_pay` response — so a user asking "what has my agent been spending
|
|
649
|
+
* on?" had nothing but running totals. For the one question people ask most
|
|
650
|
+
* about an autonomous spender, the answer was a number with no history behind
|
|
651
|
+
* it.
|
|
652
|
+
*
|
|
653
|
+
* THE CAVEAT IS LOAD-BEARING AND THE CONTRACT SAYS SO ITSELF: a BLOCKED
|
|
654
|
+
* request is never stored. The hot path returns the refusal as a value and
|
|
655
|
+
* emits an event, deliberately, because storing rejected attempts would let
|
|
656
|
+
* an agent inflate the vault's storage bill at will. So this feed shows
|
|
657
|
+
* in-flight, settled and failed payments — and an agent that was refused
|
|
658
|
+
* every time looks identical to one that never tried.
|
|
659
|
+
*
|
|
660
|
+
* Reporting that silently would be the worst kind of wrong: "no payments" is
|
|
661
|
+
* exactly what a user wants to hear when the real answer is "twelve refusals
|
|
662
|
+
* you cannot see here". So the caveat ships WITH the data, every time, rather
|
|
663
|
+
* than in a doc nobody opens.
|
|
664
|
+
*/
|
|
665
|
+
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.", {
|
|
666
|
+
limit: z
|
|
667
|
+
.number()
|
|
668
|
+
.int()
|
|
669
|
+
.min(1)
|
|
670
|
+
.max(100)
|
|
671
|
+
.optional()
|
|
672
|
+
.describe("how many to return, newest first. Default 20."),
|
|
673
|
+
fromIndex: z
|
|
674
|
+
.number()
|
|
675
|
+
.int()
|
|
676
|
+
.min(0)
|
|
677
|
+
.optional()
|
|
678
|
+
.describe("skip this many of the newest, to page backwards through history."),
|
|
679
|
+
}, async ({ limit, fromIndex }) => {
|
|
680
|
+
const rows = (await viewFunction(cfg.rpcUrl, requireVault(), "list_requests", {
|
|
681
|
+
from_index: fromIndex ?? 0,
|
|
682
|
+
limit: limit ?? 20,
|
|
683
|
+
}));
|
|
684
|
+
if (!Array.isArray(rows))
|
|
685
|
+
return json(rows);
|
|
686
|
+
const meta = await metaFor(rows.map((r) => String(r.token)));
|
|
687
|
+
const payments = rows.map((r) => {
|
|
688
|
+
const m = meta.get(String(r.token)) ?? null;
|
|
689
|
+
const d = m?.decimals ?? cfg.decimals;
|
|
690
|
+
let amount = String(r.amount);
|
|
691
|
+
try {
|
|
692
|
+
amount = fromBaseUnits(BigInt(amount), d);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
/* a figure the contract produced is a fact; surface it unchanged */
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
request_id: r.request_id,
|
|
699
|
+
status: r.status,
|
|
700
|
+
reason: r.reason_code,
|
|
701
|
+
amount,
|
|
702
|
+
token: tokenLabel(String(r.token), m),
|
|
703
|
+
to: r.recipient,
|
|
704
|
+
node_id: r.node_id,
|
|
705
|
+
...(r.memo ? { memo: r.memo } : {}),
|
|
706
|
+
// Nanoseconds are what the contract stores and not what anyone reads.
|
|
707
|
+
when: new Date(Number(BigInt(String(r.created_at_ns)) / 1000000n)).toISOString(),
|
|
708
|
+
charged_on_utc_day: r.reserved_day,
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
return json({
|
|
712
|
+
count: payments.length,
|
|
713
|
+
payments,
|
|
714
|
+
not_shown: "REFUSED payments are not in this list. The contract does not store a blocked " +
|
|
715
|
+
"request — that would let an agent grow the vault's storage bill by spamming " +
|
|
716
|
+
"refusals — so it returns the reason to the caller and emits an event instead. " +
|
|
717
|
+
"An empty list therefore means 'nothing was paid', NOT 'nothing was attempted'.",
|
|
718
|
+
...(payments.length === (limit ?? 20)
|
|
719
|
+
? { more: `Possibly more: pass fromIndex=${(fromIndex ?? 0) + payments.length}.` }
|
|
720
|
+
: {}),
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
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 }) => {
|
|
724
|
+
const r = await viewFunction(cfg.rpcUrl, requireVault(), "get_request", {
|
|
725
|
+
request_id: requestId,
|
|
726
|
+
});
|
|
727
|
+
// A BARE `null` IS AMBIGUOUS and this tool returned one. To a reader it is
|
|
728
|
+
// indistinguishable from a failed call, and to an agent it is a value to
|
|
729
|
+
// guess about rather than a fact to relay. Requests are also PURGEABLE, so
|
|
730
|
+
// "not found" has two very different causes and the honest answer names
|
|
731
|
+
// both instead of implying the payment never happened.
|
|
732
|
+
if (r === null || r === undefined) {
|
|
733
|
+
return text(`No request "${requestId}" on ${cfg.vault}. Either the id is wrong, or the ` +
|
|
734
|
+
`request was settled and later removed by purge_settled — purging is how a ` +
|
|
735
|
+
`vault reclaims storage, so an OLD successful payment can legitimately be gone. ` +
|
|
736
|
+
`vault_tree and vault_budget still reflect what it spent.`);
|
|
737
|
+
}
|
|
738
|
+
return json(r);
|
|
739
|
+
});
|
|
740
|
+
server.tool("vault_config", "Read vault config: owner, paused, strict-vs-overcommit allocation, node count, and clock mode. No key required.", {}, async () => {
|
|
741
|
+
const c = (await viewFunction(cfg.rpcUrl, requireVault(), "get_config", {}));
|
|
742
|
+
if (!c || typeof c !== "object")
|
|
743
|
+
return json(c);
|
|
744
|
+
/**
|
|
745
|
+
* EXPLAIN THE HIERARCHY, because it is the whole product and nothing
|
|
746
|
+
* showed it.
|
|
747
|
+
*
|
|
748
|
+
* `vault_tree` prints `child_cap_sum` and leaves the reader to work out
|
|
749
|
+
* what it implies. The implication is the entire differentiator: budgets
|
|
750
|
+
* are hierarchical, a payment charges every ancestor, and so a SIBLING
|
|
751
|
+
* spending first can exhaust a shared pool while your own cap still reads
|
|
752
|
+
* "25 of 25 available". That is the moment `PARENT_DAILY_BUDGET` arrives
|
|
753
|
+
* and looks like a bug.
|
|
754
|
+
*
|
|
755
|
+
* `strict_allocation` is the switch that decides whether that can happen
|
|
756
|
+
* at all, and as a bare boolean it told nobody anything — a reader cannot
|
|
757
|
+
* tell whether `false` is the safe setting or the dangerous one.
|
|
758
|
+
*
|
|
759
|
+
* Computed from the TREE rather than restated from config, so the numbers
|
|
760
|
+
* are the ones actually in force.
|
|
761
|
+
*/
|
|
762
|
+
const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
|
|
763
|
+
let allocation;
|
|
764
|
+
const root = Array.isArray(tree) ? tree.find((n) => n.id === 0) : null;
|
|
765
|
+
if (root) {
|
|
766
|
+
const lim = root.limits?.[0];
|
|
767
|
+
const sum = root.child_cap_sum?.[0];
|
|
768
|
+
if (lim && sum) {
|
|
769
|
+
const meta = (await metaFor([lim.token])).get(lim.token) ?? null;
|
|
770
|
+
const d = meta?.decimals ?? cfg.decimals;
|
|
771
|
+
const cap = BigInt(lim.daily_cap);
|
|
772
|
+
const allocated = BigInt(sum[1]);
|
|
773
|
+
const strict = c.strict_allocation === true;
|
|
774
|
+
allocation = {
|
|
775
|
+
token: tokenLabel(lim.token, meta),
|
|
776
|
+
root_daily_cap: fromBaseUnits(cap, d),
|
|
777
|
+
allocated_to_children: fromBaseUnits(allocated, d),
|
|
778
|
+
unallocated: fromBaseUnits(cap > allocated ? cap - allocated : 0n, d),
|
|
779
|
+
mode: strict ? "strict" : "overcommit",
|
|
780
|
+
meaning: strict
|
|
781
|
+
? "Children's caps may not sum above the parent's, so each child's cap is " +
|
|
782
|
+
"genuinely its own and a sibling cannot take it."
|
|
783
|
+
: "Children's caps MAY sum above the parent's. A sibling that spends first can " +
|
|
784
|
+
"exhaust the shared pool, and the others are then refused PARENT_DAILY_BUDGET " +
|
|
785
|
+
"while their OWN cap still shows as available. That refusal is the design " +
|
|
786
|
+
"working, not a fault.",
|
|
787
|
+
...(allocated >= cap && cap > 0n
|
|
788
|
+
? {
|
|
789
|
+
note: "Children are allocated the ENTIRE root budget, so any second agent would " +
|
|
790
|
+
"share this pool rather than add to it.",
|
|
791
|
+
}
|
|
792
|
+
: {}),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* AN OWNERSHIP TRANSFER IN FLIGHT WAS INVISIBLE.
|
|
798
|
+
*
|
|
799
|
+
* `get_pending_owner` exists and nothing read it. A two-step transfer is
|
|
800
|
+
* the safe design — the new owner must accept — but a half-finished one
|
|
801
|
+
* is exactly the state worth seeing, and `owner` alone shows the OLD
|
|
802
|
+
* owner with no hint that control is mid-handover.
|
|
803
|
+
*/
|
|
804
|
+
const pendingOwner = await viewFunction(cfg.rpcUrl, requireVault(), "get_pending_owner", {}).catch(() => null);
|
|
805
|
+
return json({
|
|
806
|
+
...c,
|
|
807
|
+
...(pendingOwner
|
|
808
|
+
? {
|
|
809
|
+
pending_owner: pendingOwner,
|
|
810
|
+
ownership_note: `A transfer to ${pendingOwner} is IN FLIGHT. ${c.owner} still controls this ` +
|
|
811
|
+
`vault until ${pendingOwner} accepts.`,
|
|
812
|
+
}
|
|
813
|
+
: {}),
|
|
814
|
+
// A bare `clock_mode: "production"` invites the question it does not
|
|
815
|
+
// answer. The test clock exists so integration tests can roll a UTC day
|
|
816
|
+
// without waiting; seeing it on a real vault would be alarming.
|
|
817
|
+
clock: c.clock_mode === "production" ? "real UTC time" : `TEST CLOCK (${c.clock_mode})`,
|
|
818
|
+
...(allocation ? { allocation } : {}),
|
|
819
|
+
});
|
|
820
|
+
});
|
|
453
821
|
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.", {
|
|
454
822
|
role: z
|
|
455
823
|
.string()
|
|
@@ -784,7 +1152,77 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
|
|
|
784
1152
|
gasTgas: DEFAULT_GAS_TGAS,
|
|
785
1153
|
route,
|
|
786
1154
|
});
|
|
787
|
-
|
|
1155
|
+
/**
|
|
1156
|
+
* HAND THE AGENT THE SENTENCE, do not hope it writes a good one.
|
|
1157
|
+
*
|
|
1158
|
+
* This used to `return json(out)` — just the contract's four fields — and
|
|
1159
|
+
* the instructions said "Report the reason code", so a user who asked to
|
|
1160
|
+
* pay someone got `PARENT_PER_TX_LIMIT` back. Correct, and useless to them.
|
|
1161
|
+
*
|
|
1162
|
+
* A good explanation needs NUMBERS the payment response never carried: the
|
|
1163
|
+
* per-payment cap, what is left today. So read them HERE, on the way out,
|
|
1164
|
+
* and only when they will be used. The budget read is one view call; the
|
|
1165
|
+
* caps come from the tree, fetched only when a limit actually fired.
|
|
1166
|
+
*
|
|
1167
|
+
* The code is NOT discarded — it moves under `technical`. Knowing exactly
|
|
1168
|
+
* which rule fired is a real asset (PARENT_DAILY_BUDGET meaning "a sibling
|
|
1169
|
+
* drained the shared pool" is something a flat policy model cannot say),
|
|
1170
|
+
* it is just not what a person wants first.
|
|
1171
|
+
*/
|
|
1172
|
+
const o = out;
|
|
1173
|
+
const limitFired = /PER_TX_LIMIT|DAILY_BUDGET/.test(o.reason_code ?? "");
|
|
1174
|
+
const settled = o.status === "SETTLED";
|
|
1175
|
+
const tok = tokenFor(token);
|
|
1176
|
+
// The token's own symbol and decimals, cached process-wide, so the sentence
|
|
1177
|
+
// says "USDC" and not a 64-character contract id.
|
|
1178
|
+
const meta = await tokenMeta(cfg.rpcUrl, tok, (rpc, t) => viewFunction(rpc, t, "ft_metadata", {})).catch(() => null);
|
|
1179
|
+
const dec = meta?.decimals ?? cfg.decimals;
|
|
1180
|
+
let perTxCap;
|
|
1181
|
+
let available;
|
|
1182
|
+
let dailyCap;
|
|
1183
|
+
const nodeId = o.node_id;
|
|
1184
|
+
if (nodeId !== undefined && (limitFired || settled)) {
|
|
1185
|
+
// One view call, and it is the figure BOTH a success and a daily-cap
|
|
1186
|
+
// refusal want.
|
|
1187
|
+
const b = await viewFunction(cfg.rpcUrl, vault, "get_node_budget", { node_id: nodeId, token: tok }).catch(() => null);
|
|
1188
|
+
if (b) {
|
|
1189
|
+
available = fromBaseUnits(BigInt(b.available), dec);
|
|
1190
|
+
dailyCap = fromBaseUnits(BigInt(b.daily_cap), dec);
|
|
1191
|
+
}
|
|
1192
|
+
if (limitFired) {
|
|
1193
|
+
const tree = await viewFunction(cfg.rpcUrl, vault, "get_tree", {}).catch(() => null);
|
|
1194
|
+
if (tree) {
|
|
1195
|
+
const byId = new Map(tree.map((n) => [n.id, n]));
|
|
1196
|
+
let smallest;
|
|
1197
|
+
for (let cur = byId.get(nodeId); cur !== undefined; cur = cur.parent === null ? undefined : byId.get(cur.parent)) {
|
|
1198
|
+
const lim = (cur.limits ?? []).find((l) => l.token === tok);
|
|
1199
|
+
if (!lim)
|
|
1200
|
+
continue;
|
|
1201
|
+
const v = BigInt(lim.per_tx_cap);
|
|
1202
|
+
if (smallest === undefined || v < smallest)
|
|
1203
|
+
smallest = v;
|
|
1204
|
+
}
|
|
1205
|
+
if (smallest !== undefined)
|
|
1206
|
+
perTxCap = fromBaseUnits(smallest, dec);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const explained = explainOutcome({
|
|
1211
|
+
status: o.status,
|
|
1212
|
+
reason_code: o.reason_code,
|
|
1213
|
+
amount,
|
|
1214
|
+
recipient,
|
|
1215
|
+
symbol: meta?.symbol ?? "tokens",
|
|
1216
|
+
perTxCap,
|
|
1217
|
+
available,
|
|
1218
|
+
dailyCap,
|
|
1219
|
+
});
|
|
1220
|
+
return json({
|
|
1221
|
+
say: explained.say,
|
|
1222
|
+
next_step: explained.next,
|
|
1223
|
+
money_moved: explained.moved,
|
|
1224
|
+
technical: out,
|
|
1225
|
+
});
|
|
788
1226
|
});
|
|
789
1227
|
// ------------------------------------------------------- plans (unsigned)
|
|
790
1228
|
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.", {
|
|
@@ -794,15 +1232,75 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
|
|
|
794
1232
|
cooldownSec: z.number().int().min(0).default(0),
|
|
795
1233
|
recipients: z.array(z.string()).nullable().default(null),
|
|
796
1234
|
}, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
|
|
797
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* Refuse to build a signable payload for a node that does not exist.
|
|
1237
|
+
*
|
|
1238
|
+
* PLAN TOOLS HANDED OUT PAYLOADS FOR ANY `nodeId`. Measured 2026-11-10:
|
|
1239
|
+
* `vault_plan_set_caps({nodeId: 999})` against a two-node vault returned a
|
|
1240
|
+
* cheerful summary — "Set node 999 caps to … 1/day" — and a complete unsigned
|
|
1241
|
+
* transaction. The user approves it in their wallet, pays the gas, and the
|
|
1242
|
+
* CONTRACT rejects it. The cost is small and the experience is not: the only
|
|
1243
|
+
* signal anything was wrong arrives after a signature, from a layer the user
|
|
1244
|
+
* cannot read.
|
|
1245
|
+
*
|
|
1246
|
+
* A plan tool's entire job is to produce something safe to sign. "Safe to
|
|
1247
|
+
* sign" has to include "addresses something real".
|
|
1248
|
+
*
|
|
1249
|
+
* Costs one view call per plan. That is the right trade against a wasted
|
|
1250
|
+
* signature, and these are human-speed operations — nobody plans in a loop.
|
|
1251
|
+
*
|
|
1252
|
+
* Returns the node ids that DO exist, because "999 does not exist" without
|
|
1253
|
+
* them just prompts another guess.
|
|
1254
|
+
*/
|
|
1255
|
+
async function assertNode(nodeId) {
|
|
1256
|
+
const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
|
|
1257
|
+
// A failed READ must not block a plan: the chain being briefly unreachable
|
|
1258
|
+
// is not evidence the node is missing, and refusing here would turn a blip
|
|
1259
|
+
// into "your node does not exist".
|
|
1260
|
+
if (!Array.isArray(tree))
|
|
1261
|
+
return null;
|
|
1262
|
+
if (tree.some((n) => n.id === nodeId))
|
|
1263
|
+
return null;
|
|
1264
|
+
// RETURNS THE FACTS, NOT A SENTENCE. The first version returned prose and
|
|
1265
|
+
// the read path tried to reuse it by regex-stripping the signing clause,
|
|
1266
|
+
// which left "no node 999, Nodes: ...". Composing at the call site costs two
|
|
1267
|
+
// lines and cannot produce a mangled sentence.
|
|
1268
|
+
return { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") };
|
|
1269
|
+
}
|
|
1270
|
+
/** For a PLAN: the cost of getting this wrong is a wasted signature. */
|
|
1271
|
+
const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so that payload would be refused by the ` +
|
|
1272
|
+
`contract AFTER you signed and paid for it. Nodes: ${known}.`;
|
|
1273
|
+
/** For a READ: nothing is at stake, so just say what exists. */
|
|
1274
|
+
const noNodeForRead = (nodeId, known) => `This vault has no node ${nodeId}. Nodes: ${known}.`;
|
|
1275
|
+
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 }) => {
|
|
1276
|
+
const bad = await assertNode(nodeId);
|
|
1277
|
+
return bad
|
|
1278
|
+
? text(noNodeForPlan(nodeId, bad.known))
|
|
1279
|
+
: renderPlan(planSetNodeCaps(requireVault(), nodeId, limits));
|
|
1280
|
+
});
|
|
798
1281
|
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.", {
|
|
799
1282
|
nodeId: z.number().int().min(0),
|
|
800
1283
|
publicKey: z.string().describe("ed25519:..."),
|
|
801
1284
|
}, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
|
|
802
1285
|
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)));
|
|
803
|
-
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 }) =>
|
|
804
|
-
|
|
805
|
-
|
|
1286
|
+
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 }) => {
|
|
1287
|
+
const bad = await assertNode(nodeId);
|
|
1288
|
+
return bad
|
|
1289
|
+
? text(noNodeForPlan(nodeId, bad.known))
|
|
1290
|
+
: renderPlan(planFreezeNode(requireVault(), nodeId, frozen));
|
|
1291
|
+
});
|
|
1292
|
+
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 () => {
|
|
1293
|
+
const bad = await assertNode(nodeId);
|
|
1294
|
+
return bad
|
|
1295
|
+
? text(noNodeForPlan(nodeId, bad.known))
|
|
1296
|
+
: renderPlan(planSetRecipients(requireVault(), nodeId, recipients));
|
|
1297
|
+
})());
|
|
1298
|
+
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 }) => {
|
|
1299
|
+
const bad = await assertNode(nodeId);
|
|
1300
|
+
return bad
|
|
1301
|
+
? text(noNodeForPlan(nodeId, bad.known))
|
|
1302
|
+
: renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec));
|
|
1303
|
+
});
|
|
806
1304
|
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", {
|
|
807
1305
|
request_id: requestId,
|
|
808
1306
|
})) ?? null));
|
|
@@ -817,11 +1315,74 @@ server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requ
|
|
|
817
1315
|
}, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals)));
|
|
818
1316
|
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)));
|
|
819
1317
|
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)));
|
|
1318
|
+
/**
|
|
1319
|
+
* THE TOOL THAT WAS MISSING, and the gap had teeth.
|
|
1320
|
+
*
|
|
1321
|
+
* `vault_config` reports `strict_allocation` and now explains, at length,
|
|
1322
|
+
* that `overcommit` lets a sibling drain a shared pool and leave another
|
|
1323
|
+
* agent refused `PARENT_DAILY_BUDGET` with its own cap apparently free. The
|
|
1324
|
+
* payload builder `planSetStrictAllocation` has existed all along, and the
|
|
1325
|
+
* DASHBOARD exposes it as an owner action — but no MCP tool did.
|
|
1326
|
+
*
|
|
1327
|
+
* So the server described a mode worth changing and offered no way to change
|
|
1328
|
+
* it, through the one interface an agent can reach. Found by listing all 25
|
|
1329
|
+
* tools against the payload builders rather than by anyone hitting it.
|
|
1330
|
+
*/
|
|
1331
|
+
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.", {
|
|
1332
|
+
strict: z
|
|
1333
|
+
.boolean()
|
|
1334
|
+
.describe("true = children may not be allocated more than their parent holds. false = they may, and siblings then compete for the pool."),
|
|
1335
|
+
}, async ({ strict }) => renderPlan(planSetStrictAllocation(requireVault(), strict)));
|
|
820
1336
|
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)));
|
|
821
|
-
server.tool("vault_plan_purge", "Build an UNSIGNED payload
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1337
|
+
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.", {
|
|
1338
|
+
/**
|
|
1339
|
+
* A DATE, because the raw form asked a human for a Unix day number.
|
|
1340
|
+
*
|
|
1341
|
+
* `beforeDay: 20768` is what the contract indexes by and is unusable as an
|
|
1342
|
+
* input: nobody knows today's number, and an off-by-one silently purges a
|
|
1343
|
+
* day more or less than intended — of records that are then gone. The same
|
|
1344
|
+
* figure was shown in `vault_budget` output and fixed there for the same
|
|
1345
|
+
* reason; this is the input side of it.
|
|
1346
|
+
*
|
|
1347
|
+
* `beforeDay` survives for callers that genuinely have one, and because a
|
|
1348
|
+
* test-clock vault's day is NOT derivable from the wall clock.
|
|
1349
|
+
*/
|
|
1350
|
+
beforeDate: z
|
|
1351
|
+
.string()
|
|
1352
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, "beforeDate must be YYYY-MM-DD")
|
|
1353
|
+
.optional()
|
|
1354
|
+
.describe("purge records from strictly BEFORE this UTC date, e.g. 2026-10-01"),
|
|
1355
|
+
beforeDay: z
|
|
1356
|
+
.number()
|
|
1357
|
+
.int()
|
|
1358
|
+
.min(0)
|
|
1359
|
+
.optional()
|
|
1360
|
+
.describe("the raw Unix day number, if you have it. Overrides beforeDate."),
|
|
1361
|
+
limit: z
|
|
1362
|
+
.number()
|
|
1363
|
+
.int()
|
|
1364
|
+
.min(1)
|
|
1365
|
+
.max(1000)
|
|
1366
|
+
.optional()
|
|
1367
|
+
.describe("how many records at most, default 100. Large purges may exceed the gas limit."),
|
|
1368
|
+
}, async ({ beforeDate, beforeDay, limit }) => {
|
|
1369
|
+
// EXACTLY ONE CUTOFF, and no default. A purge deletes records
|
|
1370
|
+
// irreversibly; guessing "today" for a caller who forgot the argument
|
|
1371
|
+
// would silently purge everything settled.
|
|
1372
|
+
if (beforeDay === undefined && beforeDate === undefined) {
|
|
1373
|
+
return text(`Give a cutoff: beforeDate "YYYY-MM-DD" (records strictly before it are removed), ` +
|
|
1374
|
+
`or beforeDay if you have the raw number. There is deliberately no default — ` +
|
|
1375
|
+
`a purge cannot be undone, and "today" would remove every settled record.`);
|
|
1376
|
+
}
|
|
1377
|
+
let day = beforeDay;
|
|
1378
|
+
if (day === undefined) {
|
|
1379
|
+
const ms = Date.parse(`${beforeDate}T00:00:00Z`);
|
|
1380
|
+
if (Number.isNaN(ms))
|
|
1381
|
+
return text(`"${beforeDate}" is not a real date.`);
|
|
1382
|
+
day = Math.floor(ms / 86_400_000);
|
|
1383
|
+
}
|
|
1384
|
+
return renderPlan(planPurgeSettled(requireVault(), day, limit ?? 100));
|
|
1385
|
+
});
|
|
825
1386
|
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.", {
|
|
826
1387
|
amount: z
|
|
827
1388
|
.string()
|
|
@@ -840,6 +1401,11 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
|
|
|
840
1401
|
* zero a delete needs.
|
|
841
1402
|
*/
|
|
842
1403
|
let base;
|
|
1404
|
+
// "Send 42 out of the vault" reads the same whether 42 is part of the
|
|
1405
|
+
// balance or ALL of it, and those are different decisions: the second
|
|
1406
|
+
// empties the vault, can fail in-flight payments, and is the step before a
|
|
1407
|
+
// teardown. The signer sees a summary, not this code.
|
|
1408
|
+
let emptying = false;
|
|
843
1409
|
if (amount === undefined) {
|
|
844
1410
|
const bal = await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", {
|
|
845
1411
|
account_id: vault,
|
|
@@ -852,13 +1418,23 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
|
|
|
852
1418
|
return text(`${vault} holds no ${tok}. Nothing to withdraw.`);
|
|
853
1419
|
}
|
|
854
1420
|
base = String(bal);
|
|
1421
|
+
emptying = true;
|
|
855
1422
|
}
|
|
856
1423
|
else {
|
|
857
1424
|
// `toBaseUnits` returns a bigint; the payload carries base units as a
|
|
858
1425
|
// STRING, because that is what the contract's U128 arg expects.
|
|
859
1426
|
base = toBaseUnits(amount, cfg.decimals).toString();
|
|
860
1427
|
}
|
|
861
|
-
|
|
1428
|
+
const plan = planWithdraw(vault, tok, base, receiverId, cfg.decimals);
|
|
1429
|
+
if (emptying) {
|
|
1430
|
+
plan.warnings = [
|
|
1431
|
+
`This is the vault's ENTIRE ${tok} balance — it will hold zero afterwards. ` +
|
|
1432
|
+
`You did not name an amount, so the figure above was read from the chain.`,
|
|
1433
|
+
...plan.warnings,
|
|
1434
|
+
];
|
|
1435
|
+
plan.summary = `${plan.summary} (the ENTIRE balance)`;
|
|
1436
|
+
}
|
|
1437
|
+
return renderPlan(plan);
|
|
862
1438
|
});
|
|
863
1439
|
/**
|
|
864
1440
|
* WHY A QUOTE AND NOT A PLAN, which every other owner action here is.
|
|
@@ -1026,9 +1602,51 @@ server.tool("vault_teardown_report", "Read-only. Report exactly what closing thi
|
|
|
1026
1602
|
"0.08322 NEAR as tokens_burnt. Always withdrawFromGasKey first. teardown-vault.mjs " +
|
|
1027
1603
|
"does this and exits rather than continuing if it fails.",
|
|
1028
1604
|
},
|
|
1605
|
+
/**
|
|
1606
|
+
* POINT AT THE USER-FACING CLI, not the repo's internal script.
|
|
1607
|
+
*
|
|
1608
|
+
* This emitted `NETWORK=… VAULT=… OWNER=… TOKEN=… CONFIRM_MAINNET=1
|
|
1609
|
+
* EXPECT_TOKENS=1 node scripts/teardown-vault.mjs` — six environment
|
|
1610
|
+
* variables, a path only someone who cloned the repo has, no dry run,
|
|
1611
|
+
* and a delete at the end. For the one action a user takes when they are
|
|
1612
|
+
* already nervous, that is the worst possible interface, and it is not
|
|
1613
|
+
* what the product ships.
|
|
1614
|
+
*
|
|
1615
|
+
* `float-exit` is the published CLI for exactly this, and it is better
|
|
1616
|
+
* in every way that matters here: two flags, `--dry-run`, numbered steps,
|
|
1617
|
+
* "would" phrasing for everything it has not done yet, and an explicit
|
|
1618
|
+
* "This is irreversible" before the delete. It works via npx, so it needs
|
|
1619
|
+
* no checkout. `--network` defaults to mainnet.
|
|
1620
|
+
*
|
|
1621
|
+
* I pointed at the internal script for a week and then spent a round
|
|
1622
|
+
* fixing ITS ergonomics — adding the missing CONFIRM_MAINNET and
|
|
1623
|
+
* EXPECT_TOKENS — when the friendly path already existed. Fixing the
|
|
1624
|
+
* hard road is not the same as checking whether the easy one is there.
|
|
1625
|
+
*
|
|
1626
|
+
* THE DRY RUN LEADS, deliberately. The destructive form is given second
|
|
1627
|
+
* and separately, so copying the first thing you see cannot delete a
|
|
1628
|
+
* vault.
|
|
1629
|
+
*
|
|
1630
|
+
* THE `-p` IS LOAD-BEARING. float-exit is a BIN of create-float, not a
|
|
1631
|
+
* package of its own, so invoking it by bare scoped name 404s on the
|
|
1632
|
+
* registry. `scripts/check-npx-commands.mjs` enforces this — and it
|
|
1633
|
+
* flagged THIS comment when the wrong form was written out as a
|
|
1634
|
+
* counter-example, which is the check working: it cannot tell a warning
|
|
1635
|
+
* from an instruction, and neither can someone copying a line out of a
|
|
1636
|
+
* file. So the bad form is described rather than spelled.
|
|
1637
|
+
*/
|
|
1029
1638
|
command: signable
|
|
1030
|
-
?
|
|
1031
|
-
`
|
|
1639
|
+
? {
|
|
1640
|
+
first_look: `npx -p @floatonnear/create-float float-exit --owner ${config?.owner ?? "<owner>"} ` +
|
|
1641
|
+
`--vault ${vault}${cfg.network === "mainnet" ? "" : ` --network ${cfg.network}`} --dry-run`,
|
|
1642
|
+
then_for_real: `npx -p @floatonnear/create-float float-exit --owner ${config?.owner ?? "<owner>"} ` +
|
|
1643
|
+
`--vault ${vault}${cfg.network === "mainnet" ? "" : ` --network ${cfg.network}`}`,
|
|
1644
|
+
what_the_dry_run_shows: "Every step it WOULD take, the exact NEAR and tokens coming back, and which " +
|
|
1645
|
+
"account they go to — without touching anything. Run it first.",
|
|
1646
|
+
needs: `A full-access key for ${config?.owner ?? "the owner"} in ~/.near-credentials. ` +
|
|
1647
|
+
`The owner's key is already full-access ON the vault (the factory adds it at ` +
|
|
1648
|
+
`creation), so no separate vault key is needed.`,
|
|
1649
|
+
}
|
|
1032
1650
|
: "(none — nothing can sign as this vault)",
|
|
1033
1651
|
note: "Read-only. This server holds at most a restricted agent key and signed nothing. " +
|
|
1034
1652
|
"Run the command yourself, after the token withdrawal has settled.",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a token is CALLED, and how many decimals it really has.
|
|
3
|
+
*
|
|
4
|
+
* WHY. Caps, balances and errors were all rendered with the raw NEP-141
|
|
5
|
+
* account id, and on mainnet USDC that is
|
|
6
|
+
* `17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1` — 64
|
|
7
|
+
* hex characters appearing in `vault_tree`, `vault_budget`, the teardown
|
|
8
|
+
* report and every "no limit for that token" message. Nobody reading a budget
|
|
9
|
+
* can tell that is USDC, and two different tokens look identical at a glance
|
|
10
|
+
* until you diff the strings.
|
|
11
|
+
*
|
|
12
|
+
* AND IT FIXES A REAL CORRECTNESS LIMIT, not just readability. `vault_tree`
|
|
13
|
+
* could only humanise the ONE token whose decimals the operator had
|
|
14
|
+
* configured; any other kept base units, because scaling by a guessed
|
|
15
|
+
* exponent prints a figure wrong by orders of magnitude while looking tidy.
|
|
16
|
+
* The chain knows the answer — `ft_metadata` returns it — so the guess was
|
|
17
|
+
* never necessary. Every token can now be rendered correctly.
|
|
18
|
+
*
|
|
19
|
+
* CACHED FOR THE PROCESS LIFETIME. Metadata is immutable in practice (a token
|
|
20
|
+
* that changed its decimals would break every holder), and a budget read
|
|
21
|
+
* should not make an extra round trip per node per token. A failed lookup is
|
|
22
|
+
* cached as `null` too: the common cause is an account that is not a token at
|
|
23
|
+
* all, and retrying that on every render turns one bad id into a storm.
|
|
24
|
+
*/
|
|
25
|
+
export interface TokenMeta {
|
|
26
|
+
symbol: string;
|
|
27
|
+
decimals: number;
|
|
28
|
+
}
|
|
29
|
+
type Fetcher = (rpcUrl: string, token: string) => Promise<unknown>;
|
|
30
|
+
/** Visible for tests; a process-lifetime cache is otherwise untestable. */
|
|
31
|
+
export declare function clearTokenMetaCache(): void;
|
|
32
|
+
export declare function tokenMeta(rpcUrl: string, token: string, view: Fetcher): Promise<TokenMeta | null>;
|
|
33
|
+
/**
|
|
34
|
+
* `USDC` where known, the raw id where not — never a symbol alone.
|
|
35
|
+
*
|
|
36
|
+
* The id stays because a symbol is NOT unique: anyone can deploy a token
|
|
37
|
+
* calling itself USDC, and a budget that names only the symbol cannot be
|
|
38
|
+
* checked against the one the user meant. This is money; the identifier has
|
|
39
|
+
* to remain visible.
|
|
40
|
+
*/
|
|
41
|
+
export declare function label(token: string, meta: TokenMeta | null): string;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const cache = new Map();
|
|
2
|
+
/** Visible for tests; a process-lifetime cache is otherwise untestable. */
|
|
3
|
+
export function clearTokenMetaCache() {
|
|
4
|
+
cache.clear();
|
|
5
|
+
}
|
|
6
|
+
export async function tokenMeta(rpcUrl, token, view) {
|
|
7
|
+
if (cache.has(token))
|
|
8
|
+
return cache.get(token) ?? null;
|
|
9
|
+
let meta = null;
|
|
10
|
+
try {
|
|
11
|
+
const raw = (await view(rpcUrl, token));
|
|
12
|
+
// BOTH FIELDS OR NEITHER. A partial answer is worse than none: a symbol
|
|
13
|
+
// without decimals invites rendering with the configured default, which
|
|
14
|
+
// is the wrong-exponent bug this exists to remove.
|
|
15
|
+
if (raw &&
|
|
16
|
+
typeof raw.symbol === "string" &&
|
|
17
|
+
raw.symbol.length > 0 &&
|
|
18
|
+
// REDUNDANT WITH THE NEXT LINE, kept for readability. Mutation-tested:
|
|
19
|
+
// removing this changes nothing because Number.isInteger(undefined) is
|
|
20
|
+
// already false, so `Number.isInteger` is the clause actually carrying
|
|
21
|
+
// the guard. Delete that one and the suite reds; delete this one and it
|
|
22
|
+
// does not.
|
|
23
|
+
typeof raw.decimals === "number" &&
|
|
24
|
+
Number.isInteger(raw.decimals) &&
|
|
25
|
+
raw.decimals >= 0 &&
|
|
26
|
+
raw.decimals <= 24) {
|
|
27
|
+
meta = { symbol: raw.symbol, decimals: raw.decimals };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
meta = null;
|
|
32
|
+
}
|
|
33
|
+
cache.set(token, meta);
|
|
34
|
+
return meta;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `USDC` where known, the raw id where not — never a symbol alone.
|
|
38
|
+
*
|
|
39
|
+
* The id stays because a symbol is NOT unique: anyone can deploy a token
|
|
40
|
+
* calling itself USDC, and a budget that names only the symbol cannot be
|
|
41
|
+
* checked against the one the user meant. This is money; the identifier has
|
|
42
|
+
* to remain visible.
|
|
43
|
+
*/
|
|
44
|
+
export function label(token, meta) {
|
|
45
|
+
return meta ? `${meta.symbol} (${token})` : token;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floatonnear/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server for the hierarchical budget vault. The agent key signs request_payment only; owner methods emit unsigned payloads for wallet approval.",
|
|
6
6
|
"license": "MIT",
|