@floatonnear/mcp 0.2.0 → 0.4.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/payloads.js CHANGED
@@ -11,12 +11,27 @@
11
11
  * too, but failing locally costs no gas and no wallet round-trip, and the
12
12
  * message can explain *why* a bound exists.
13
13
  */
14
- import { toBaseUnits, fromBaseUnits } from "./amounts.js";
14
+ import { toBaseUnits } from "./amounts.js";
15
+ /** A node id must be a non-negative integer; there is no node -1. */
16
+ function assertNodeId(nodeId) {
17
+ if (!Number.isInteger(nodeId) || nodeId < 0) {
18
+ throw new Error(`node id must be a non-negative integer, got ${nodeId}`);
19
+ }
20
+ }
21
+ /**
22
+ * Shape-check a public key before it reaches a wallet.
23
+ *
24
+ * Deliberately not a full base58 decode — the contract does that and rejects
25
+ * a malformed key. This catches the empty string and the obvious typo locally,
26
+ * where it costs nothing instead of a wallet round-trip.
27
+ */
28
+ function assertPublicKey(publicKey) {
29
+ if (!/^(ed25519|secp256k1):[1-9A-HJ-NP-Za-km-z]{32,}$/.test(publicKey)) {
30
+ throw new Error(`not a public key: ${JSON.stringify(publicKey)}. ` +
31
+ `Expected "ed25519:<base58>" or "secp256k1:<base58>".`);
32
+ }
33
+ }
15
34
  export const ONE_YOCTO = "1";
16
- /** Mirrors `AGENT_ACCOUNT_STORAGE` in the contract: 0.05 NEAR. */
17
- export const AGENT_ACCOUNT_STORAGE_YOCTO = (5n * 10n ** 22n).toString();
18
- /** Mirrors `MIN_AGENT_KEY_ALLOWANCE` in the contract: 0.5 NEAR. */
19
- export const MIN_AGENT_KEY_ALLOWANCE_YOCTO = (5n * 10n ** 23n).toString();
20
35
  /**
21
36
  * Gas attached to a payment, for every route.
22
37
  *
@@ -33,7 +48,6 @@ export const DEFAULT_GAS_TGAS = 100;
33
48
  export const MAX_TOKENS = 4;
34
49
  export const MAX_RECIPIENTS = 32;
35
50
  export const MAX_LABEL_LEN = 48;
36
- export const MAX_SUB_LABEL_LEN = 32;
37
51
  export const MAX_COOLDOWN_SEC = 7 * 24 * 60 * 60;
38
52
  export const MAX_PURGE_LIMIT = 100;
39
53
  const NEAR_DECIMALS = 24;
@@ -70,7 +84,6 @@ function describeLimits(limits) {
70
84
  .map((l) => `${l.token}: ${l.dailyCap}/day, max ${l.perTxCap}/payment`)
71
85
  .join("; ");
72
86
  }
73
- const SUB_LABEL = new RegExp(`^[a-z0-9_-]{1,${MAX_SUB_LABEL_LEN}}$`);
74
87
  function assertRecipients(recipients) {
75
88
  if (recipients === null)
76
89
  return;
@@ -138,47 +151,118 @@ export function planBindAgent(vault, nodeId, accountId) {
138
151
  summary: `Let ${accountId} spend from node ${nodeId}`,
139
152
  warnings: [
140
153
  `${accountId} is an EXISTING account whose keys you do not control. ` +
141
- `Prefer create_agent_account, which issues a key restricted to request_payment.`,
154
+ `It can call request_payment with the full authority of node ${nodeId}.`,
155
+ `Prefer a VIRTUAL agent (bind_virtual_agent): it binds one key you generate ` +
156
+ `rather than a whole account, costs ~168 bytes, and strands nothing.`,
142
157
  ],
143
158
  };
144
159
  }
145
- export function planCreateAgentAccount(vault, nodeId, subLabel, publicKey, keyAllowanceNear) {
146
- if (!SUB_LABEL.test(subLabel)) {
147
- throw new Error(`invalid sub_label ${JSON.stringify(subLabel)}: 1..=32 chars of [a-z0-9_-]. ` +
148
- `A dot in particular is refused — the account id is DERIVED as ` +
149
- `<label>.${vault} so the new account cannot be aimed outside this vault's subtree.`);
150
- }
151
- if (!publicKey.startsWith("ed25519:") && !publicKey.startsWith("secp256k1:")) {
152
- throw new Error("publicKey must be in NEAR form, e.g. ed25519:...");
160
+ /**
161
+ * A whole departmental agent in ONE owner signature: node, binding, gas key.
162
+ *
163
+ * WHY THIS IS NOT `add_node` + `bind_virtual_agent` + an AddKey.
164
+ *
165
+ * Installing a gas key is an `AddKey` action, and NEAR requires
166
+ * `signer_id == receiver_id` for one. The owner signing a transaction TO the
167
+ * vault cannot carry it — only the vault can key itself. A browser wallet
168
+ * signs as the connected account and cannot sign as the vault, so composing
169
+ * this client-side would make fleet expansion CLI-only.
170
+ *
171
+ * And the node id cannot be threaded: `add_node` returns it, but no client
172
+ * can read one action's return value inside the same transaction, and
173
+ * predicting it from `next_node_id` races a concurrent `add_node`.
174
+ *
175
+ * So the contract does all three. This is one plain FunctionCall.
176
+ *
177
+ * THE DEPOSIT IS THE GAS KEY'S ENTIRE BALANCE, and that balance is its
178
+ * spending cap — a gas key's `allowance` is null by design. It is not a fee:
179
+ * the owner can reclaim it, because `WithdrawFromGasKey` is account-signed
180
+ * and the vault carries the owner's full-access key.
181
+ */
182
+ export function planAddRole(vault, label, limits, cooldownSec, recipients, publicKey, fundingYocto) {
183
+ if (!label || label.length > MAX_LABEL_LEN) {
184
+ throw new Error(`label must be 1..=${MAX_LABEL_LEN} characters (MAX_LABEL_LEN)`);
153
185
  }
154
- const allowance = toBaseUnits(keyAllowanceNear, NEAR_DECIMALS);
155
- if (allowance < BigInt(MIN_AGENT_KEY_ALLOWANCE_YOCTO)) {
156
- throw new Error(`key allowance ${keyAllowanceNear} NEAR is below the usable floor of ` +
157
- `${fromBaseUnits(BigInt(MIN_AGENT_KEY_ALLOWANCE_YOCTO), NEAR_DECIMALS)} NEAR. ` +
158
- `NEAR reserves the FULL prepaid gas of each call against the key up front and ` +
159
- `refunds the remainder, so an allowance sized to expected burn produces a key ` +
160
- `that cannot transact at all.`);
186
+ assertCooldown(cooldownSec);
187
+ assertRecipients(recipients);
188
+ assertPublicKey(publicKey);
189
+ // Mirrors MIN_AGENT_GAS_FUNDING in the vault. Below this the key is
190
+ // installed and cannot complete a single call: NEAR reserves
191
+ // `prepaid_gas * 1e9` and the tooling sends 60 TGas, which reserves 0.06.
192
+ // The contract refuses too; refusing here means the owner is not sent to a
193
+ // wallet to sign something that will revert.
194
+ if (BigInt(fundingYocto) < 6n * 10n ** 22n) {
195
+ throw new Error(`the agent's gas key needs at least 0.06 NEAR to make one 60 TGas call — ` +
196
+ `NEAR reserves prepaid gas up front, so a key funded to the expected ` +
197
+ `burn (~0.0007) cannot transact at all.`);
161
198
  }
162
- const deposit = allowance + BigInt(AGENT_ACCOUNT_STORAGE_YOCTO);
163
- const derived = `${subLabel}.${vault}`;
164
199
  return {
165
200
  receiverId: vault,
166
- methodName: "create_agent_account",
201
+ methodName: "add_role",
167
202
  args: {
168
- node_id: nodeId,
169
- sub_label: subLabel,
203
+ label,
204
+ limits: limitsToArgs(limits),
205
+ cooldown_sec: cooldownSec,
206
+ recipients,
170
207
  public_key: publicKey,
171
- key_allowance: allowance.toString(),
172
208
  },
173
209
  gasTgas: DEFAULT_GAS_TGAS,
174
- depositYocto: deposit.toString(),
175
- summary: `Create agent account ${derived}, bind it to node ${nodeId}, and give it ONE key ` +
176
- `that can call only request_payment on ${vault} — it cannot call other methods, ` +
177
- `cannot call other contracts, and cannot attach a deposit. ` +
178
- `Funding ${fromBaseUnits(deposit, NEAR_DECIMALS)} NEAR ` +
179
- `(${keyAllowanceNear} gas allowance + ` +
180
- `${fromBaseUnits(BigInt(AGENT_ACCOUNT_STORAGE_YOCTO), NEAR_DECIMALS)} account storage).`,
181
- warnings: [],
210
+ depositYocto: fundingYocto,
211
+ summary: `Add the "${label}" agent with its own budget, under root`,
212
+ warnings: [
213
+ `The ${(Number(fundingYocto) / 1e24).toFixed(3)} NEAR deposit funds the agent's ` +
214
+ `gas key. It is recoverable, not a fee.`,
215
+ `The new node is a CHILD OF ROOT, so root's caps still bound the whole ` +
216
+ `fleet: this divides what the vault can spend in a day, it does not raise it.`,
217
+ `Check the public key matches the one your agent reported before signing.`,
218
+ ],
219
+ };
220
+ }
221
+ export function planBindVirtualAgent(vault, nodeId, publicKey) {
222
+ assertNodeId(nodeId);
223
+ assertPublicKey(publicKey);
224
+ return {
225
+ receiverId: vault,
226
+ methodName: "bind_virtual_agent",
227
+ args: { node_id: nodeId, public_key: publicKey },
228
+ gasTgas: DEFAULT_GAS_TGAS,
229
+ // ONE YOCTO, not a funding amount. `bind_virtual_agent` calls
230
+ // `assert_owner()` -> `assert_one_yocto()`. Nothing is funded here, which
231
+ // is the whole point of moving off sub-accounts.
232
+ depositYocto: ONE_YOCTO,
233
+ summary: `Let the key ${publicKey.slice(0, 22)}… spend from node ${nodeId}`,
234
+ warnings: [
235
+ `A node may hold more than one key — binding another does not replace this one.`,
236
+ `This grants spending authority only. The key still needs gas: install it as ` +
237
+ `a NEP-611 gas key on the vault and fund it. That balance is recoverable.`,
238
+ ],
239
+ };
240
+ }
241
+ /**
242
+ * Revoke a virtual agent's authority.
243
+ *
244
+ * Frees the 82-byte `pubkey_to_node` entry. The 86-byte `pubkey_to_nonce`
245
+ * entry is RETAINED deliberately and never reclaimed — confirmed live, where
246
+ * next_nonce stayed at 2 instead of resetting to 1. Dropping it would let
247
+ * unbind-then-rebind reset the nonce and reopen the replay window, so about
248
+ * half the binding's storage is intentionally unrecoverable.
249
+ */
250
+ export function planUnbindVirtualAgent(vault, publicKey) {
251
+ assertPublicKey(publicKey);
252
+ return {
253
+ receiverId: vault,
254
+ methodName: "unbind_virtual_agent",
255
+ args: { public_key: publicKey },
256
+ gasTgas: DEFAULT_GAS_TGAS,
257
+ depositYocto: ONE_YOCTO,
258
+ summary: `Revoke the key ${publicKey.slice(0, 22)}…`,
259
+ warnings: [
260
+ `The replay nonce for this key is kept forever, on purpose: resetting it ` +
261
+ `would let an unbind/rebind replay old signed payloads. About half the ` +
262
+ `binding's storage is therefore not reclaimed.`,
263
+ `Gas still sitting in this key's NEP-611 gas key is NOT returned by ` +
264
+ `unbinding. Withdraw from the gas key first, or it stays there.`,
265
+ ],
182
266
  };
183
267
  }
184
268
  export function planSetRecipients(vault, nodeId, recipients) {
@@ -270,18 +354,78 @@ export function planPurgeSettled(vault, beforeDay, limit) {
270
354
  ],
271
355
  };
272
356
  }
357
+ /**
358
+ * Move tokens out of the vault, to anywhere the owner says.
359
+ *
360
+ * THE STEP THAT DECIDES WHETHER MONEY SURVIVES A TEARDOWN. Deleting a NEAR
361
+ * account does NOT return its fungible tokens — the token ledger keeps a row
362
+ * keyed to an account nobody can ever sign for again. So this is not hygiene
363
+ * before closing a vault, it is the difference between recovering a balance
364
+ * and burning it, and `scripts/teardown-vault.mjs` runs it first for exactly
365
+ * that reason.
366
+ *
367
+ * BASE UNITS, NOT HUMAN ONES. `withdraw` passes the amount straight to
368
+ * `ft_transfer`, so this takes what the token uses. The caller scales, and
369
+ * the summary says the human figure so the two cannot silently disagree in
370
+ * the wallet.
371
+ *
372
+ * The contract refuses a zero amount (`amount must be positive`), so this
373
+ * does too — refusing here means the owner is not sent to a wallet to sign
374
+ * something that will revert and cost gas to learn.
375
+ */
376
+ export function planWithdraw(vault, token, amountBase, receiverId, decimals) {
377
+ let amt;
378
+ try {
379
+ amt = BigInt(amountBase);
380
+ }
381
+ catch {
382
+ throw new Error(`amount must be an integer in base units, got "${amountBase}"`);
383
+ }
384
+ if (amt <= 0n) {
385
+ throw new Error(`amount must be positive — the contract rejects a zero withdrawal outright.`);
386
+ }
387
+ if (!receiverId)
388
+ throw new Error("a receiver account is required");
389
+ // Human figure for the summary only. Done with string surgery rather than
390
+ // Number(), because 2^53 is about 9 billion and a 6-decimal token hits that
391
+ // at 9,007 tokens — a figure a real vault can hold, and silently wrong is
392
+ // the one thing a wallet confirmation must never be.
393
+ const neg = amt < 0n;
394
+ const v = (neg ? -amt : amt).toString().padStart(decimals + 1, "0");
395
+ const whole = v.slice(0, v.length - decimals);
396
+ const frac = decimals ? v.slice(v.length - decimals).replace(/0+$/, "") : "";
397
+ const human = frac ? `${whole}.${frac}` : whole;
398
+ return {
399
+ receiverId: vault,
400
+ methodName: "withdraw",
401
+ args: { token, amount: amountBase, receiver_id: receiverId },
402
+ gasTgas: DEFAULT_GAS_TGAS,
403
+ // `withdraw` is #[payable] and calls assert_owner() -> assert_one_yocto().
404
+ depositYocto: ONE_YOCTO,
405
+ summary: `Send ${human} out of the vault to ${receiverId}`,
406
+ warnings: [
407
+ `This moves real tokens. ${receiverId} keeps them — the vault cannot pull them back.`,
408
+ `In-flight payments reserved against this balance may now fail at the token ` +
409
+ `contract. Their reservations are released by the existing sweep machinery, ` +
410
+ `so no accounting breaks, but a payment simply does not go through.`,
411
+ ],
412
+ };
413
+ }
273
414
  /** Registry used by the MCP server, and asserted against the contract's method list. */
274
415
  export const OWNER_PLANNERS = {
275
416
  add_node: { methodName: "add_node", fn: planAddNode },
276
417
  set_node_caps: { methodName: "set_node_caps", fn: planSetNodeCaps },
277
418
  bind_agent: { methodName: "bind_agent", fn: planBindAgent },
278
- create_agent_account: { methodName: "create_agent_account", fn: planCreateAgentAccount },
419
+ add_role: { methodName: "add_role", fn: planAddRole },
420
+ bind_virtual_agent: { methodName: "bind_virtual_agent", fn: planBindVirtualAgent },
421
+ unbind_virtual_agent: { methodName: "unbind_virtual_agent", fn: planUnbindVirtualAgent },
279
422
  set_recipients: { methodName: "set_recipients", fn: planSetRecipients },
280
423
  set_cooldown: { methodName: "set_cooldown", fn: planSetCooldown },
281
424
  freeze_node: { methodName: "freeze_node", fn: planFreezeNode },
282
425
  set_paused: { methodName: "set_paused", fn: planSetPaused },
283
426
  set_strict_allocation: { methodName: "set_strict_allocation", fn: planSetStrictAllocation },
284
427
  purge_settled: { methodName: "purge_settled", fn: planPurgeSettled },
428
+ withdraw: { methodName: "withdraw", fn: planWithdraw },
285
429
  };
286
430
  /** A ready-to-run command, so approving does not require trusting this server. */
287
431
  export function toCliCommand(p, network = "testnet") {
@@ -296,18 +440,47 @@ export function toCliCommand(p, network = "testnet") {
296
440
  * A threshold is a gate INSIDE the caps: it can only make the vault more
297
441
  * conservative, never less, so there is no value of it that moves more money
298
442
  * than the budget tree already allowed. `null` removes it.
443
+ *
444
+ * ---- `amount` IS HUMAN UNITS, and it used to be base units.
445
+ *
446
+ * Breaking change, 2026-10-31, made the same day the tool was published and
447
+ * before anyone used it. The parameter was `amountBase` and took raw base
448
+ * units, which made it the ONLY tool in this module on that convention:
449
+ *
450
+ * vault_pay.amount human "12.50"
451
+ * planSetNodeCaps dailyCap/perTxCap human (the zod schema says so)
452
+ * planSetApprovalThreshold BASE <- the odd one out
453
+ *
454
+ * And it is the one that configures a safety gate, so it FAILED OPEN. An owner
455
+ * thinking in human units who passed a figure at or above the per-tx cap in
456
+ * base units got a threshold ABOVE every cap in the tree: the gate never fires,
457
+ * nothing is ever held, and the transaction succeeds — so the owner believes the
458
+ * control is on. Measured against the live mainnet vault (per-tx cap 510000
459
+ * base = 0.51 USDC): "1000000" meaning "a million" yields a 1.0 USDC threshold,
460
+ * above every cap.
461
+ *
462
+ * The other direction fails closed and is merely annoying ("1" meaning 1 USDC
463
+ * became 0.000001, so everything got held), which is why only one direction is
464
+ * dangerous and why this is worth a breaking change rather than a doc fix.
465
+ *
466
+ * `toBaseUnits` throws rather than truncating excess precision — correct here:
467
+ * silently rounding 0.1234567 would set a threshold the owner did not choose.
299
468
  */
300
- export function planSetApprovalThreshold(vault, nodeId, amountBase) {
469
+ export function planSetApprovalThreshold(vault, nodeId, amount, decimals) {
470
+ const base = amount === null ? null : toBaseUnits(amount, decimals).toString();
301
471
  return {
302
472
  receiverId: vault,
303
473
  methodName: "set_approval_threshold",
304
- args: { node_id: nodeId, amount: amountBase },
474
+ args: { node_id: nodeId, amount: base },
305
475
  gasTgas: DEFAULT_GAS_TGAS,
306
476
  depositYocto: ONE_YOCTO,
307
- summary: amountBase
308
- ? `Payments above ${amountBase} (base units) from node ${nodeId} will need your approval`
477
+ // Human units in the summary too. The old line read "Payments above
478
+ // 1000000 (base units)", which is unreadable in precisely the moment the
479
+ // owner is checking the plan before signing it.
480
+ summary: amount
481
+ ? `Payments above ${amount} from node ${nodeId} will need your approval`
309
482
  : `Remove the approval threshold on node ${nodeId} — payments up to the caps run unattended`,
310
- warnings: amountBase
483
+ warnings: amount
311
484
  ? []
312
485
  : ["After this, nothing at this node waits for a human: only the caps apply."],
313
486
  };
@@ -0,0 +1,18 @@
1
+ export interface RoleCredential {
2
+ agentId: string;
3
+ agentKey: string;
4
+ role: string;
5
+ nodeId?: number;
6
+ }
7
+ export declare class RoleSelectionError extends Error {
8
+ }
9
+ export declare function selectRole(cfg: {
10
+ agentKeysPath?: string;
11
+ }, role: string): {
12
+ agentId: string;
13
+ agentKey: string;
14
+ };
15
+ /** Every role that could actually sign, for listing back to a caller. */
16
+ export declare function listRoles(cfg: {
17
+ agentKeysPath?: string;
18
+ }): RoleCredential[];
package/dist/roles.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Picking which agent in a fleet signs a payment.
3
+ *
4
+ * WHICH KEY SIGNS *IS* WHICH BUDGET IS CHARGED — the contract resolves the
5
+ * spending node from the signer (`pubkey_to_node`), never from the request
6
+ * body. So a wrong choice here does not fail; it succeeds against another
7
+ * agent's budget and shows up later as a balance nobody can explain.
8
+ *
9
+ * This reads the SAME file the adapter reads, and accepts the same entry
10
+ * shape, because two readers of one keyring that disagree about what counts
11
+ * as a credential is how an agent ends up "configured" and unreachable.
12
+ */
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ /** Entries that `loadKeyring` would accept, nothing else. */
15
+ function loadable(path) {
16
+ if (!existsSync(path))
17
+ return [];
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(readFileSync(path, "utf8"));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ if (!Array.isArray(parsed))
26
+ return [];
27
+ // THE SAME PREDICATE THE ADAPTER USES. An entry failing it is skipped there
28
+ // in total silence, so listing it here would advertise an agent that can
29
+ // never be selected.
30
+ return parsed.filter((e) => e && typeof e.agentId === "string" && typeof e.secret === "string");
31
+ }
32
+ export class RoleSelectionError extends Error {
33
+ }
34
+ export function selectRole(cfg, role) {
35
+ const path = cfg.agentKeysPath ?? ".float/agent-keys.json";
36
+ const entries = loadable(path);
37
+ const known = entries
38
+ .map((e) => (typeof e.role === "string" ? e.role : null))
39
+ .filter(Boolean);
40
+ const hit = entries.find((e) => e.role === role);
41
+ if (!hit) {
42
+ throw new RoleSelectionError(`no agent with role '${role}' in ${path}. ` +
43
+ `Known roles: ${known.length ? known.join(", ") : "none"}. ` +
44
+ (entries.length && !known.length
45
+ ? `${entries.length} credential(s) are loaded but none carries a role — ` +
46
+ `they predate roles, so select them by nodeId or add a "role" field.`
47
+ : `Add one with vault_add_role.`));
48
+ }
49
+ return { agentId: hit.agentId, agentKey: hit.secret };
50
+ }
51
+ /** Every role that could actually sign, for listing back to a caller. */
52
+ export function listRoles(cfg) {
53
+ return loadable(cfg.agentKeysPath ?? ".float/agent-keys.json")
54
+ .filter((e) => typeof e.role === "string")
55
+ .map((e) => ({
56
+ agentId: e.agentId,
57
+ agentKey: e.secret,
58
+ role: e.role,
59
+ nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
60
+ }));
61
+ }
package/dist/rpc.d.ts CHANGED
@@ -32,6 +32,19 @@ interface RawViewResponse {
32
32
  */
33
33
  export declare function decodeViewResult<T = unknown>(res: RawViewResponse): T;
34
34
  export declare function viewFunction<T = unknown>(rpcUrl: string, contractId: string, methodName: string, args?: unknown): Promise<T>;
35
+ /**
36
+ * Does this account exist on chain?
37
+ *
38
+ * Used before offering to CREATE a vault at a derived address. Getting this
39
+ * wrong is not cosmetic: the create batch's first action is `CreateAccount`,
40
+ * so offering an address that is already taken sends the user to a wallet,
41
+ * takes their signature, and fails only after they have committed.
42
+ *
43
+ * Distinguishes "does not exist" from "could not tell". An RPC timeout that
44
+ * returned `false` would read as a free address, so ONLY the specific
45
+ * UNKNOWN_ACCOUNT answer counts as absent and anything else throws.
46
+ */
47
+ export declare function accountExists(rpcUrl: string, accountId: string): Promise<boolean>;
35
48
  /** One access key as NEAR reports it. */
36
49
  export interface AccessKeyInfo {
37
50
  public_key: string;
@@ -43,6 +56,14 @@ export interface AccessKeyInfo {
43
56
  receiver_id: string;
44
57
  method_names: string[];
45
58
  };
59
+ } | {
60
+ GasKeyFunctionCall: {
61
+ allowance: string | null;
62
+ balance: string;
63
+ receiver_id: string;
64
+ method_names: string[];
65
+ num_nonces?: number;
66
+ };
46
67
  };
47
68
  };
48
69
  }
@@ -56,6 +77,20 @@ export interface AccessKeyInfo {
56
77
  * be true.
57
78
  */
58
79
  export declare function viewAccessKeys(rpcUrl: string, accountId: string): Promise<AccessKeyInfo[]>;
80
+ /** An account's balance and storage, as the chain reports them. */
81
+ export interface AccountView {
82
+ amount: string;
83
+ storage_usage: number;
84
+ }
85
+ /**
86
+ * Read an account's balance and storage.
87
+ *
88
+ * Both as the chain reports them: `amount` is the TOTAL, and the storage
89
+ * stake (`storage_usage * 1e19`) is locked inside it rather than held
90
+ * separately. Subtracting is the caller's job, and getting that backwards is
91
+ * how a vault with 0.02 spendable looks like it has 0.12.
92
+ */
93
+ export declare function viewAccount(rpcUrl: string, accountId: string): Promise<AccountView>;
59
94
  export interface AgentCreds {
60
95
  agentId: string;
61
96
  agentKey: string;
@@ -68,6 +103,22 @@ export interface AgentCreds {
68
103
  export declare function requireAgentKey(env: {
69
104
  agentId?: string;
70
105
  agentKey?: string;
106
+ /**
107
+ * Why `NEAR_VAULT_AGENT_KEY_FILE` produced no key, when one was named.
108
+ *
109
+ * SEPARATE FROM `agentKey` BEING ABSENT, and it must stay that way. "The
110
+ * variable is not set" and "the file you pointed it at is broken" are
111
+ * different problems with different fixes; reporting the second as the first
112
+ * sends the operator to set something they have already set — and the
113
+ * quickest way out of that dead end is pasting the key inline, which undoes
114
+ * the reason the file option exists.
115
+ */
116
+ agentKeyError?: string;
117
+ /**
118
+ * `account_id` from a `~/.near-credentials`-shaped key file, when the key
119
+ * came from one. Absent for an inline key or a bare key file.
120
+ */
121
+ agentKeyAccountId?: string;
71
122
  }): AgentCreds;
72
123
  /**
73
124
  * A request id the contract will accept.
package/dist/rpc.js CHANGED
@@ -71,6 +71,39 @@ export async function viewFunction(rpcUrl, contractId, methodName, args = {}) {
71
71
  return decodeViewResult({ error: json.error });
72
72
  return decodeViewResult(json.result ?? {});
73
73
  }
74
+ /**
75
+ * Does this account exist on chain?
76
+ *
77
+ * Used before offering to CREATE a vault at a derived address. Getting this
78
+ * wrong is not cosmetic: the create batch's first action is `CreateAccount`,
79
+ * so offering an address that is already taken sends the user to a wallet,
80
+ * takes their signature, and fails only after they have committed.
81
+ *
82
+ * Distinguishes "does not exist" from "could not tell". An RPC timeout that
83
+ * returned `false` would read as a free address, so ONLY the specific
84
+ * UNKNOWN_ACCOUNT answer counts as absent and anything else throws.
85
+ */
86
+ export async function accountExists(rpcUrl, accountId) {
87
+ const r = await fetch(rpcUrl, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({
91
+ jsonrpc: "2.0",
92
+ id: "near-vault-mcp",
93
+ method: "query",
94
+ params: { request_type: "view_account", finality: "final", account_id: accountId },
95
+ }),
96
+ });
97
+ if (!r.ok)
98
+ throw new Error(`RPC ${r.status} checking whether ${accountId} exists`);
99
+ const j = (await r.json());
100
+ if (j.result)
101
+ return true;
102
+ const name = j.error?.cause?.name;
103
+ if (name === "UNKNOWN_ACCOUNT")
104
+ return false;
105
+ throw new Error(`could not determine whether ${accountId} exists: ${name ?? j.error?.message ?? "unknown error"}`);
106
+ }
74
107
  /**
75
108
  * Every access key on an account, read from the chain.
76
109
  *
@@ -98,28 +131,91 @@ export async function viewAccessKeys(rpcUrl, accountId) {
98
131
  throw new Error(`access key list failed: ${json.error.message ?? "unknown"}`);
99
132
  return json.result?.keys ?? [];
100
133
  }
134
+ /**
135
+ * Read an account's balance and storage.
136
+ *
137
+ * Both as the chain reports them: `amount` is the TOTAL, and the storage
138
+ * stake (`storage_usage * 1e19`) is locked inside it rather than held
139
+ * separately. Subtracting is the caller's job, and getting that backwards is
140
+ * how a vault with 0.02 spendable looks like it has 0.12.
141
+ */
142
+ export async function viewAccount(rpcUrl, accountId) {
143
+ const r = await fetch(rpcUrl, {
144
+ method: "POST",
145
+ headers: { "content-type": "application/json" },
146
+ body: JSON.stringify({
147
+ jsonrpc: "2.0",
148
+ id: "near-vault-mcp",
149
+ method: "query",
150
+ params: { request_type: "view_account", finality: "final", account_id: accountId },
151
+ }),
152
+ });
153
+ if (!r.ok)
154
+ throw new Error(`RPC ${rpcUrl} returned HTTP ${r.status}`);
155
+ const json = (await r.json());
156
+ if (json.error)
157
+ throw new Error(`view_account failed: ${json.error.message ?? "unknown"}`);
158
+ if (!json.result)
159
+ throw new Error(`view_account returned no result for ${accountId}`);
160
+ return json.result;
161
+ }
101
162
  /**
102
163
  * Both halves or neither. Failing here with the variable NAMES beats throwing a
103
164
  * TypeError three frames deeper, and beats silently signing with the wrong
104
165
  * account.
105
166
  */
106
167
  export function requireAgentKey(env) {
168
+ // THE FILE'S OWN FAILURE OUTRANKS EVERY "not set" MESSAGE BELOW. It is the
169
+ // only one of them that names a cause, and once a path is involved the
170
+ // missing-variable advice is actively misleading.
171
+ if (!env.agentKey && env.agentKeyError) {
172
+ throw new Error(`Paying needs the agent key, and the key file could not be read. ` +
173
+ `${env.agentKeyError} Fix the path or the file — or unset the _FILE variable ` +
174
+ `to fall back to an inline NEAR_VAULT_AGENT_KEY.`);
175
+ }
107
176
  // Name EVERY missing variable, not just the first. A message that reports
108
177
  // half the problem buys a second failed attempt.
109
178
  if (!env.agentId && !env.agentKey) {
110
- throw new Error("Paying requires NEAR_VAULT_AGENT_ID and NEAR_VAULT_AGENT_KEY, and neither is set. " +
179
+ throw new Error("Paying requires NEAR_VAULT_AGENT_ID and a key, and neither is set. " +
180
+ "The key can be NEAR_VAULT_AGENT_KEY (inline) or NEAR_VAULT_AGENT_KEY_FILE " +
181
+ "(a path to a ~/.near-credentials-shaped JSON file, or a bare key file) — " +
182
+ "prefer the file, since an inline key is printed by any read of the config. " +
111
183
  "Reads and payload planning work without them; only paying needs them. " +
112
- "The key must be the restricted one issued by create_agent_account — never a " +
184
+ "The key must be a RESTRICTED one, from a VIRTUAL AGENT — never a " +
113
185
  "full-access key.");
114
186
  }
115
187
  if (!env.agentId) {
116
188
  throw new Error("NEAR_VAULT_AGENT_ID is not set, so there is no agent account to pay from.");
117
189
  }
118
190
  if (!env.agentKey) {
119
- throw new Error("NEAR_VAULT_AGENT_KEY is not set. This must be the restricted key issued by " +
120
- "create_agent_account — it can call only request_payment on the vault, cannot " +
191
+ throw new Error("No agent key is set: use NEAR_VAULT_AGENT_KEY_FILE (a path — preferred, since " +
192
+ "an inline key is printed by any read of the config) or NEAR_VAULT_AGENT_KEY. " +
193
+ "This must be a VIRTUAL AGENT's key, bound " +
194
+ "with bind_virtual_agent and installed as a NEP-611 gas key on the vault — it can call only request_payment on the vault, cannot " +
121
195
  "call other contracts, and cannot attach a deposit. Never put a full-access key here.");
122
196
  }
197
+ /**
198
+ * THE KEY FILE AND NEAR_VAULT_AGENT_ID MUST NAME THE SAME ACCOUNT.
199
+ *
200
+ * Which key signs *is* which budget is charged — the contract resolves the
201
+ * spending node from the signer, never from the request body. So a key used
202
+ * under the wrong account id does not get rejected locally; it goes to chain
203
+ * and fails with NOT_VIRTUAL_AGENT, which reads as a binding bug and sends
204
+ * the reader to the contract rather than to their config.
205
+ *
206
+ * No mismatch is legitimate. A virtual agent's key is a NEP-611 gas key ON
207
+ * the vault, so it signs AS the vault, and a credentials file for it names
208
+ * the vault. A file naming some other account holds some other account's key
209
+ * — most often the owner's full-access one, which must never be used here.
210
+ */
211
+ if (env.agentKeyAccountId && env.agentKeyAccountId !== env.agentId) {
212
+ throw new Error(`the key file belongs to ${env.agentKeyAccountId}, but NEAR_VAULT_AGENT_ID is ` +
213
+ `${env.agentId}. A key signs as the account it was issued for, and the vault ` +
214
+ `resolves the spending budget from the signer — so this pairing cannot pay. ` +
215
+ `Point NEAR_VAULT_AGENT_ID at ${env.agentKeyAccountId}, or use the key file ` +
216
+ `for ${env.agentId}. A virtual agent's key is a gas key ON the vault, so for ` +
217
+ `one it is the vault's own id.`);
218
+ }
123
219
  return { agentId: env.agentId, agentKey: env.agentKey };
124
220
  }
125
221
  /**