@floatonnear/float-adapter 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -12,8 +12,17 @@
12
12
  * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
13
13
  * NEAR_VAULT_TOKEN required (the NEP-141 the vault pays in)
14
14
  * NEAR_VAULT_TOKEN_DECIMALS default 6
15
- * NEAR_VAULT_AGENT_ID only needed for POST /v1/pay
15
+ * NEAR_VAULT_AGENT_ID only needed for POST /v1/pay. Taken from the key
16
+ * file's `account_id` when one is given.
16
17
  * NEAR_VAULT_AGENT_KEY only needed for POST /v1/pay (the restricted key)
18
+ * NEAR_VAULT_AGENT_KEY_FILE PREFERRED over the inline value above: a PATH to
19
+ * a ~/.near-credentials-shaped JSON file or a bare
20
+ * key file. See key-file.ts — a path is not a
21
+ * secret, so reading this config to check which
22
+ * vault is wired up stops printing a live private
23
+ * key. If both are set the FILE wins.
24
+ * FLOAT_QUOTE_SIGNER_KEY 32-byte hex, for rail=intents
25
+ * FLOAT_QUOTE_SIGNER_KEY_FILE the same, as a path
17
26
  * PORT default 8787 (Float's dashboard default)
18
27
  */
19
28
  import { createServer } from "node:http";
@@ -21,14 +30,17 @@ import { resolveHost, corsHeaders, authorize, classifyError, fromCaller } from "
21
30
  import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
22
31
  import { dirname } from "node:path";
23
32
  import { toBaseUnits, fromBaseUnits } from "@floatonnear/mcp/amounts";
24
- import { newRequestId, viewAccessKeys, viewFunction } from "@floatonnear/mcp";
33
+ import { accountExists, newRequestId, rpcForNetwork, RPC_BY_NETWORK, viewAccessKeys, viewFunction, } from "@floatonnear/mcp";
34
+ import { isUnavailable } from "./unavailable.js";
25
35
  import { buildRoute, sendRequestPayment } from "@floatonnear/mcp/sign";
26
36
  import { getQuote, signBinding, toBase64 } from "@floatonnear/mcp/quote";
27
- import { planFreezeNode, planSetCooldown, planSetNodeCaps, planSetRecipients, toCliCommand, } from "@floatonnear/mcp/payloads";
37
+ import { toCliCommand } from "@floatonnear/mcp/payloads";
38
+ import { planOwnerAction } from "./owner-plan.js";
28
39
  import { getAgentKeys, getDashboard, getSpendHistory, getVaultInfo, paymentFromOutcome, } from "./handlers.js";
29
40
  import { AgentSelectionError, loadKeyring, selectAgent } from "./keyring.js";
30
- import { BlockedFollower, createRpc, emptyStore } from "./blocked-scan.js";
41
+ import { adoptBlockedStore, BlockedFollower, createRpc, emptyStore, } from "./blocked-scan.js";
31
42
  import { parseEnvFile, applyEnvFile } from "./env-file.js";
43
+ import { resolveKeySource } from "@floatonnear/mcp/key-file";
32
44
  // Load FLOAT_ENV_FILE before anything reads process.env.
33
45
  //
34
46
  // This replaces `set -a && . .float/adapter.env && set +a` in every deploy
@@ -53,11 +65,107 @@ if (process.env.FLOAT_ENV_FILE) {
53
65
  // NAMES only, never values: this line goes to pm2's log on every restart.
54
66
  console.log(`env file: ${envPath} — set ${applied.length} (${applied.join(", ") || "none"})`);
55
67
  }
68
+ /**
69
+ * Secrets, from a FILE where one is named. See key-file.ts for why.
70
+ *
71
+ * AFTER the FLOAT_ENV_FILE block above, deliberately: the env file may be where
72
+ * NEAR_VAULT_AGENT_KEY_FILE itself is set, so resolving a path before that file
73
+ * has been applied would miss it.
74
+ */
75
+ const agentKeySource = resolveKeySource({
76
+ inline: process.env.NEAR_VAULT_AGENT_KEY,
77
+ file: process.env.NEAR_VAULT_AGENT_KEY_FILE,
78
+ inlineVar: "NEAR_VAULT_AGENT_KEY",
79
+ fileVar: "NEAR_VAULT_AGENT_KEY_FILE",
80
+ });
81
+ const quoteSignerSource = resolveKeySource({
82
+ inline: process.env.FLOAT_QUOTE_SIGNER_KEY,
83
+ file: process.env.FLOAT_QUOTE_SIGNER_KEY_FILE,
84
+ inlineVar: "FLOAT_QUOTE_SIGNER_KEY",
85
+ fileVar: "FLOAT_QUOTE_SIGNER_KEY_FILE",
86
+ });
87
+ // NAMES only, never values: this goes to pm2's log on every restart.
88
+ for (const note of [...agentKeySource.warnings, ...quoteSignerSource.warnings]) {
89
+ console.log(`key source: ${note}`);
90
+ }
91
+ /**
92
+ * LOUD, BUT NOT FATAL — and the choice is not obvious, so here is the argument.
93
+ *
94
+ * The missing-FLOAT_ENV_FILE case above exits, and this looks like the same
95
+ * thing. It is not: without that file NOTHING is configured, so the process
96
+ * cannot do its job at all. A missing agent key leaves every READ working, and
97
+ * reads are most of what the dashboard asks for. Exiting would turn a mistyped
98
+ * path into a pm2 crash-loop that takes the whole dashboard down.
99
+ *
100
+ * The decisive argument is consistency with what this adapter already does: an
101
+ * ABSENT NEAR_VAULT_AGENT_KEY has always started fine and failed at /v1/pay
102
+ * with a message from `selectAgent`. A key file that cannot be read is the same
103
+ * category of problem, so it travels the same route — carried into the keyring
104
+ * as `agentKeyError`, which reports it there INSTEAD of the generic "set
105
+ * NEAR_VAULT_AGENT_ID and NEAR_VAULT_AGENT_KEY". That generic message is the
106
+ * one thing this must never say here, because it reads as advice to paste the
107
+ * secret back inline.
108
+ */
109
+ for (const err of [agentKeySource.error, quoteSignerSource.error]) {
110
+ if (err)
111
+ console.error(`key source: ${err} — reads are unaffected; paying will fail.`);
112
+ }
113
+ /**
114
+ * The network, resolved once and BEFORE `env` so `rpcUrl` can follow it.
115
+ *
116
+ * MAINNET IS THE DEFAULT, matching @floatonnear/mcp and the five CLIs. It was
117
+ * testnet here while every CLI defaulted to mainnet, so an operator who created
118
+ * a mainnet vault and started this adapter served a dashboard that queried
119
+ * testnet and reported the vault missing.
120
+ *
121
+ * An unrecognised value FAILS CLOSED for the same reason it does in the MCP:
122
+ * `rpcForNetwork` resolves anything it does not recognise to a testnet
123
+ * endpoint, so `NEAR_VAULT_NETWORK=Mainnet` would answer mainnet questions
124
+ * about the wrong chain rather than complaining.
125
+ */
126
+ const NETWORK = process.env.NEAR_VAULT_NETWORK ?? "mainnet";
127
+ if (!Object.hasOwn(RPC_BY_NETWORK, NETWORK)) {
128
+ console.error(`float-adapter: NEAR_VAULT_NETWORK is "${NETWORK}", which is not a known network ` +
129
+ `(${Object.keys(RPC_BY_NETWORK).join(", ")}). Refusing to start rather than resolving ` +
130
+ `to a testnet endpoint and answering questions about the wrong chain.`);
131
+ process.exit(1);
132
+ }
56
133
  const env = {
57
134
  vault: process.env.NEAR_VAULT_CONTRACT ?? "",
58
- // See DEFAULT_RPC_URL in @floatonnear/mcp: the old public testnet endpoint
59
- // is deprecated and now returns -429 for every call.
60
- rpcUrl: process.env.NEAR_VAULT_RPC_URL ?? "https://test.rpc.fastnear.com",
135
+ /**
136
+ * The factory, needed only to answer "which vault is MINE?" for a visitor.
137
+ *
138
+ * Optional: a local single-vault setup never asks, and the resolve route
139
+ * reports it is unconfigured rather than guessing a factory — guessing one
140
+ * would hand a visitor an address that is not theirs, under a factory
141
+ * nobody chose.
142
+ */
143
+ factory: process.env.NEAR_VAULT_FACTORY ?? "",
144
+ /**
145
+ * THE RPC FOLLOWS THE NETWORK. It did not, and the same bug was fixed in
146
+ * @floatonnear/mcp earlier today without anyone checking here.
147
+ *
148
+ * This was `NEAR_VAULT_RPC_URL ?? "https://test.rpc.fastnear.com"` — the
149
+ * TESTNET endpoint, regardless of NEAR_VAULT_NETWORK. So an operator running
150
+ * `NEAR_VAULT_NETWORK=mainnet` and nothing else queried testnet about a
151
+ * mainnet vault and got:
152
+ *
153
+ * account usefloat.near does not exist while viewing
154
+ *
155
+ * which names the account, not the chain, and reads as "your factory is
156
+ * gone". Measured 2026-11-11 by starting this adapter exactly as a new user
157
+ * would.
158
+ *
159
+ * THE DEPLOYED COPY WORKED ONLY BY ACCIDENT: deploy-skill-host.sh sets
160
+ * NEAR_VAULT_RPC_URL explicitly, so the box masked the bug while every
161
+ * hand-run instance hit it. A default that is only ever correct because
162
+ * something else overrides it is not a default, it is a trap with one
163
+ * authorised path around it.
164
+ */
165
+ rpcUrl: rpcForNetwork(NETWORK, {
166
+ configuredNetwork: NETWORK,
167
+ override: process.env.NEAR_VAULT_RPC_URL,
168
+ }),
61
169
  token: process.env.NEAR_VAULT_TOKEN ?? "",
62
170
  // Loopback unless explicitly overridden — exposure is opted into.
63
171
  host: resolveHost(process.env.HOST),
@@ -66,15 +174,31 @@ const env = {
66
174
  // Exact-match CORS origin; the dashboard's dev server by default.
67
175
  allowedOrigin: process.env.FLOAT_ALLOWED_ORIGIN ?? "http://localhost:3000",
68
176
  decimals: Number(process.env.NEAR_VAULT_TOKEN_DECIMALS ?? "6"),
69
- agentId: process.env.NEAR_VAULT_AGENT_ID,
70
- agentKey: process.env.NEAR_VAULT_AGENT_KEY,
177
+ // The credentials file carries the account as well as the key, so one path
178
+ // supplies both halves. An explicit NEAR_VAULT_AGENT_ID still wins.
179
+ agentId: process.env.NEAR_VAULT_AGENT_ID ?? agentKeySource.accountId,
180
+ agentKey: agentKeySource.secret,
181
+ // WHY THIS IS THREADED THROUGH rather than handled here: an empty keyring has
182
+ // three possible causes and they need different fixes. `loadKeyring` already
183
+ // distinguishes two of them (`skippedOtherVault`); this is the third.
184
+ agentKeyError: agentKeySource.error,
71
185
  port: Number(process.env.PORT ?? "8787"),
72
186
  // Where the relayer records Base deliveries. Read fresh on each request so a
73
187
  // delivery that lands while this server is running appears without a
74
188
  // restart — the file is small and this is a dashboard, not a hot path.
75
189
  relayLedger: process.env.RELAY_LEDGER ?? ".demo/relay-ledger.json",
76
190
  // Refusals recovered from chain by scripts/scan-blocked.mjs.
77
- blockedLog: process.env.BLOCKED_LOG ?? ".demo/blocked-log.json",
191
+ //
192
+ // PER-VAULT PATH, and it MUST match the scanner's default or the two write
193
+ // and read different files while both look healthy — the scan reports
194
+ // refusals recorded, the dashboard shows none, and nothing says why.
195
+ //
196
+ // The old shared `.demo/blocked-log.json` is what let one vault's refusals
197
+ // render on another's dashboard: the scanner defaulted to a different vault
198
+ // entirely, wrote there, and the adapter read it. Putting the vault id in
199
+ // the filename makes that impossible rather than merely detectable.
200
+ blockedLog: process.env.BLOCKED_LOG ??
201
+ `.demo/blocked-log.${(process.env.NEAR_VAULT_CONTRACT ?? "unset").replace(/[^a-z0-9._-]/gi, "_")}.json`,
78
202
  // Several agents, so the payment path can exercise the hierarchy the
79
203
  // dashboard draws. This is the file create-float writes.
80
204
  agentKeysPath: process.env.NEAR_VAULT_AGENT_KEYS ?? ".float/agent-keys.json",
@@ -86,7 +210,8 @@ const env = {
86
210
  // Binds a payee to a one-time deposit address for the cross-chain rail.
87
211
  // Absent by default: whoever holds it can redirect any Intents payment the
88
212
  // budget allows, so it is opt-in and belongs to the operator.
89
- quoteSignerKey: process.env.FLOAT_QUOTE_SIGNER_KEY,
213
+ quoteSignerKey: quoteSignerSource.secret,
214
+ quoteSignerError: quoteSignerSource.error,
90
215
  };
91
216
  const keyring = loadKeyring(env);
92
217
  /**
@@ -108,13 +233,16 @@ function loadBlockedStore() {
108
233
  if (!existsSync(env.blockedLog))
109
234
  return emptyStore(env.vault);
110
235
  const parsed = JSON.parse(readFileSync(env.blockedLog, "utf8"));
111
- return {
112
- vault: parsed.vault ?? env.vault,
113
- scannedFrom: parsed.scannedFrom ?? null,
114
- scannedTo: parsed.scannedTo ?? null,
115
- holes: Array.isArray(parsed.holes) ? parsed.holes : [],
116
- blocked: parsed.blocked ?? {},
117
- };
236
+ // VAULT-SCOPED. `parsed.vault ?? env.vault` read the field and never
237
+ // compared it, so a log written for another vault loaded wholesale —
238
+ // observed live, six refusals from v3.floatvault-12329.testnet rendered on
239
+ // a one-node vault's activity page. See `adoptBlockedStore`.
240
+ const store = adoptBlockedStore(parsed, env.vault);
241
+ if (parsed.vault && parsed.vault !== env.vault) {
242
+ console.log(`[blocked] ignoring ${env.blockedLog}: it belongs to ${parsed.vault}, not ${env.vault}. ` +
243
+ `Refusals for this vault are UNKNOWN, not absent — rescan with scripts/scan-blocked.mjs.`);
244
+ }
245
+ return store;
118
246
  }
119
247
  catch {
120
248
  // A corrupt file must not stop the server, and must not be mistaken for
@@ -134,10 +262,107 @@ if (!env.vault || !env.token) {
134
262
  const cfg = {
135
263
  token: env.token,
136
264
  decimals: env.decimals,
265
+ // VIRTUAL AGENTS LIVE HERE. Their keys are NEP-611 gas keys on the vault
266
+ // itself rather than on a sub-account, so enumerating them needs the vault
267
+ // id. Without it `getAgentKeys` degrades to account-bound agents only —
268
+ // which, now that sub-accounts are retired, is an empty list.
269
+ vault: env.vault,
137
270
  blockedRing: [],
138
271
  maxBlocked: 50,
139
272
  };
140
273
  const view = (method, args = {}) => viewFunction(env.rpcUrl, env.vault, method, args);
274
+ /**
275
+ * A NEAR account id, loosely — enough to keep junk out of an RPC call.
276
+ *
277
+ * Not a full grammar check: the chain is the authority and answers an unknown
278
+ * account with an error we surface anyway. This exists so a query string
279
+ * cannot push arbitrary text into a view call, and so the 64-character cap is
280
+ * refused here rather than producing a confusing RPC failure.
281
+ */
282
+ const LOOKS_LIKE_ACCOUNT = /^[a-z0-9._-]{2,64}$/;
283
+ /**
284
+ * WHICH VAULT THIS REQUEST IS ABOUT.
285
+ *
286
+ * The adapter used to serve exactly one vault — `NEAR_VAULT_CONTRACT` — and
287
+ * ignore the `?owner=` the dashboard had been sending all along. Hosted, that
288
+ * means every visitor sees the operator's vault, and the figures look like
289
+ * theirs. Nothing leaks (a vault's state is public on chain) but a person acts
290
+ * on numbers that are not theirs, which is worse than being told nothing.
291
+ *
292
+ * So reads honour `?vault=`. The configured vault remains the default, which
293
+ * keeps every local setup working unchanged.
294
+ *
295
+ * `/v1/pay` MUST NOT honour it, and does not: that path uses `env.vault`
296
+ * directly, because the agent key it signs with is bound to one vault and
297
+ * pointing it elsewhere is meaningless at best.
298
+ */
299
+ function vaultFor(url) {
300
+ const asked = url.searchParams.get("vault")?.trim();
301
+ if (!asked)
302
+ return { vault: env.vault, isDefault: true };
303
+ if (!LOOKS_LIKE_ACCOUNT.test(asked)) {
304
+ return { vault: env.vault, isDefault: true, error: `"${asked}" is not a NEAR account id` };
305
+ }
306
+ return { vault: asked, isDefault: asked === env.vault };
307
+ }
308
+ /**
309
+ * The per-request config and view function.
310
+ *
311
+ * `blockedRing` and `chainBlocked` are EMPTIED for any vault that is not the
312
+ * configured one, and that is the subtle part. Both are populated by watching
313
+ * `env.vault` — the follower and the scan store. Sharing them would attribute
314
+ * the operator's refusals to a visitor's vault: a payment they never made,
315
+ * refused for a reason that was never about them. Empty is correct, and the
316
+ * coverage banner already says "outside the scanned range they are UNKNOWN,
317
+ * not absent", which is exactly the truth for a vault nobody has scanned.
318
+ */
319
+ function perRequest(vault, isDefault) {
320
+ const reqCfg = isDefault
321
+ ? cfg
322
+ : { ...cfg, vault, blockedRing: [], chainBlocked: {} };
323
+ const reqView = (method, args = {}) => viewFunction(env.rpcUrl, vault, method, args);
324
+ return { reqCfg, reqView };
325
+ }
326
+ /**
327
+ * The network this vault lives on, read ONCE.
328
+ *
329
+ * `get_config.network` is immutable for a given vault — a contract cannot
330
+ * migrate chains — so re-reading it per request buys nothing and costs a
331
+ * round trip on a path that is otherwise pure local computation.
332
+ *
333
+ * The env var is a FALLBACK, not the primary. The chain is authoritative: an
334
+ * operator who mistypes it would otherwise get CLI commands carrying the wrong
335
+ * `network-config`, which fail after the owner has already approved them.
336
+ * Asking the vault first means the env var can only help when the chain is
337
+ * unreachable, never override it.
338
+ *
339
+ * ONE CONFIG NAME. This read `NEAR_NETWORK`, which appeared nowhere else in the
340
+ * product — every other surface uses `NEAR_VAULT_NETWORK` (33 occurrences
341
+ * against this one). So an operator who configured the adapter the documented
342
+ * way, and then lost RPC, fell through to an env var they had never been told
343
+ * to set, and the adapter answered with whatever `networkName()` threw instead.
344
+ * A second spelling of the same setting is a parallel flow, and it only shows
345
+ * up in the one case nobody tests: the chain being down.
346
+ *
347
+ * `NEAR_NETWORK` is still honoured so an existing deployment does not break,
348
+ * but it is second and it is not documented.
349
+ */
350
+ let cachedNetwork;
351
+ async function networkName() {
352
+ if (cachedNetwork)
353
+ return cachedNetwork;
354
+ try {
355
+ const c = await viewFunction(env.rpcUrl, env.vault, "get_config", {});
356
+ cachedNetwork = c.network;
357
+ return cachedNetwork;
358
+ }
359
+ catch (e) {
360
+ const fallback = process.env.NEAR_VAULT_NETWORK?.trim() || process.env.NEAR_NETWORK?.trim();
361
+ if (fallback)
362
+ return fallback; // NOT cached: the chain still wins once reachable
363
+ throw e;
364
+ }
365
+ }
141
366
  const server = createServer(async (req, res) => {
142
367
  const url = new URL(req.url ?? "/", `http://localhost:${env.port}`);
143
368
  // CORS echoes ONLY the configured origin. The previous wildcard plus an
@@ -149,10 +374,18 @@ const server = createServer(async (req, res) => {
149
374
  if (req.method === "OPTIONS")
150
375
  return res.writeHead(204).end();
151
376
  // AUTH BEFORE ANY HANDLER. Previously a POST reached body validation and
152
- // answered 400 — there was nothing to fail at. Mutating routes now require a
377
+ // answered 400 — there was nothing to fail at. Mutating routes require a
153
378
  // bearer token; GETs stay open behind a loopback bind.
379
+ //
380
+ // THE ROUTE DECIDES, NOT THE METHOD. Gating every non-GET meant a hosted
381
+ // dashboard could offer no owner actions at all, because the token cannot
382
+ // exist in public: the dashboard proxy attaches it without authenticating
383
+ // the caller. `/v1/owner-plan` returns an UNSIGNED payload the owner
384
+ // approves in their own wallet, so it needs no token; `/v1/pay` signs with
385
+ // the agent key and always does. Unknown routes stay shut — see gate.ts.
154
386
  const verdict = authorize({
155
387
  method: req.method ?? "GET",
388
+ path: url.pathname,
156
389
  authHeader: req.headers.authorization,
157
390
  token: env.apiToken,
158
391
  });
@@ -172,11 +405,20 @@ const server = createServer(async (req, res) => {
172
405
  // yields none rather than crashing — the failure direction is always to
173
406
  // show fewer refusals than happened, never to invent one.
174
407
  {
408
+ // VAULT-SCOPED HERE TOO. `loadBlockedStore` above was fixed first and this
409
+ // reader was missed, so the leak survived the fix completely: six refusals
410
+ // from v3.floatvault-12329.testnet still rendered on a one-node vault's
411
+ // activity page, and the adapter log showed no complaint because the
412
+ // complaint lives in the other function. Two readers of one file, one
413
+ // guard. Caught only by checking the live endpoint after the fix.
175
414
  const parsed = (() => {
176
415
  try {
177
416
  if (!existsSync(env.blockedLog))
178
417
  return null;
179
- return JSON.parse(readFileSync(env.blockedLog, "utf8"));
418
+ const raw = JSON.parse(readFileSync(env.blockedLog, "utf8"));
419
+ if (raw?.vault && raw.vault !== env.vault)
420
+ return null;
421
+ return raw;
180
422
  }
181
423
  catch {
182
424
  return null;
@@ -205,23 +447,155 @@ const server = createServer(async (req, res) => {
205
447
  })();
206
448
  try {
207
449
  if (url.pathname === "/v1/dashboard") {
208
- return send(200, await getDashboard(view, cfg));
450
+ {
451
+ const { vault, isDefault, error } = vaultFor(url);
452
+ if (error)
453
+ return send(400, { error });
454
+ const { reqCfg, reqView } = perRequest(vault, isDefault);
455
+ return send(200, await getDashboard(reqView, reqCfg));
456
+ }
209
457
  }
210
458
  if (url.pathname === "/v1/spend-history") {
211
459
  const limit = Math.min(Number(url.searchParams.get("limit") ?? "25"), 50);
212
460
  const offset = Number(url.searchParams.get("offset") ?? "0");
213
- return send(200, await getSpendHistory(view, cfg, limit, offset));
461
+ {
462
+ const { vault, isDefault, error } = vaultFor(url);
463
+ if (error)
464
+ return send(400, { error });
465
+ const { reqCfg, reqView } = perRequest(vault, isDefault);
466
+ return send(200, await getSpendHistory(reqView, reqCfg, limit, offset));
467
+ }
214
468
  }
215
469
  // Where this vault lives. The UI reads the chain from here rather than
216
470
  // hardcoding it, so it cannot link a testnet vault to a mainnet explorer.
471
+ /**
472
+ * WHICH VAULT BELONGS TO THIS OWNER?
473
+ *
474
+ * A hosted dashboard has to answer this before it can read anything: the
475
+ * visitor connects a wallet and the page needs an address to ask about.
476
+ *
477
+ * ASKED OF THE FACTORY, never derived here. The factory exposes
478
+ * `vault_id_for` precisely so the derivation has ONE implementation —
479
+ * `create.ts` says so in capitals — and a copy in this file would be a
480
+ * second place for it to drift. The derivation is also not injective
481
+ * (`a.b.near` and `a-b.near` collide), so a local guess could name a
482
+ * vault owned by somebody else.
483
+ *
484
+ * `vault: null` is a real answer, not an error: it means this owner has
485
+ * no legal vault id, which is the case for an implicit account. The page
486
+ * should offer to create one rather than showing a failure.
487
+ */
488
+ if (url.pathname === "/v1/resolve") {
489
+ const owner = url.searchParams.get("owner")?.trim();
490
+ if (!owner)
491
+ return send(400, { error: "owner is required" });
492
+ if (!LOOKS_LIKE_ACCOUNT.test(owner)) {
493
+ return send(400, { error: `"${owner}" is not a NEAR account id` });
494
+ }
495
+ if (!env.factory) {
496
+ return send(200, {
497
+ owner,
498
+ vault: null,
499
+ exists: false,
500
+ reason: "NEAR_VAULT_FACTORY is not set on this adapter, so it cannot derive a vault id.",
501
+ });
502
+ }
503
+ /**
504
+ * COULD NOT ASK != THE ANSWER IS NO.
505
+ *
506
+ * This was `.catch(() => null)`, so a transient RPC failure was
507
+ * reported as "an implicit account cannot have a vault" with HTTP 200 —
508
+ * a confident false claim about the caller's account, which the
509
+ * dashboard proxy then cached for the life of the process. Measured
510
+ * 2026-11-09: six identical calls gave four answers and two nulls,
511
+ * because the background refusal-follower saturates the RPC budget.
512
+ *
513
+ * 503 is the honest status. It is also what stops the proxy caching it.
514
+ */
515
+ let derived;
516
+ try {
517
+ derived = await viewFunction(env.rpcUrl, env.factory, "vault_id_for", { owner });
518
+ }
519
+ catch (e) {
520
+ if (isUnavailable(e)) {
521
+ return send(503, {
522
+ error: "UPSTREAM_UNAVAILABLE",
523
+ owner,
524
+ detail: "Could not reach the chain to derive this account's vault id, so " +
525
+ "whether it has one is UNKNOWN — not no. Retry shortly.",
526
+ });
527
+ }
528
+ throw e;
529
+ }
530
+ if (!derived) {
531
+ return send(200, {
532
+ owner,
533
+ vault: null,
534
+ exists: false,
535
+ reason: "No legal vault id for that account under this factory. An implicit " +
536
+ "account (64 hex characters) cannot have one; use a named account.",
537
+ });
538
+ }
539
+ // Whether it has been CREATED is a separate question from whether it
540
+ // could exist, and the page needs both: one says "here is your vault",
541
+ // the other says "you do not have one yet".
542
+ //
543
+ // A view call that throws is the account not existing; it is not an
544
+ // adapter failure, so it must not surface as one.
545
+ //
546
+ // SAME RULE FOR `exists`, and this one was the demo-killer: a 429 made
547
+ // it `false`, so an owner with a populated vault was shown the
548
+ // create-one empty state and invited to create a vault that already
549
+ // exists. `exists` flickered false->true across one measured run.
550
+ let exists;
551
+ try {
552
+ exists = await accountExists(env.rpcUrl, derived);
553
+ }
554
+ catch (e) {
555
+ if (isUnavailable(e)) {
556
+ return send(503, {
557
+ error: "UPSTREAM_UNAVAILABLE",
558
+ owner,
559
+ vault: derived,
560
+ detail: `Derived ${derived}, but could not reach the chain to check whether ` +
561
+ "it exists. Whether you have a vault is UNKNOWN — not no. Retry shortly.",
562
+ });
563
+ }
564
+ /**
565
+ * NEVER `exists = false` HERE. `accountExists` returns false WITHOUT
566
+ * throwing for a genuine absence (UNKNOWN_ACCOUNT); it throws only
567
+ * when it cannot tell. So every throw is uncertainty, and turning
568
+ * uncertainty into "you have no vault" is the exact bug this route
569
+ * was already fixed for once.
570
+ */
571
+ return send(503, {
572
+ error: "UPSTREAM_UNAVAILABLE",
573
+ owner,
574
+ vault: derived,
575
+ detail: `Derived ${derived}, but could not determine whether it exists: ` +
576
+ `${e instanceof Error ? e.message : String(e)}. UNKNOWN, not no.`,
577
+ });
578
+ }
579
+ return send(200, { owner, vault: derived, exists, factory: env.factory });
580
+ }
217
581
  if (url.pathname === "/v1/vault-info") {
218
- return send(200, await getVaultInfo(view, cfg, (contractId, method, args) => viewFunction(env.rpcUrl, contractId, method, args ?? {})));
582
+ const { vault, isDefault, error } = vaultFor(url);
583
+ if (error)
584
+ return send(400, { error });
585
+ const { reqCfg, reqView } = perRequest(vault, isDefault);
586
+ return send(200, await getVaultInfo(reqView, reqCfg, (contractId, method, args) => viewFunction(env.rpcUrl, contractId, method, args ?? {})));
219
587
  }
220
588
  // Agent accounts WITH their access keys read from the chain. The key
221
589
  // restriction is the product's central claim, so the page that states it
222
590
  // reads the real permission instead of a constant.
223
591
  if (url.pathname === "/v1/agent-keys") {
224
- return send(200, await getAgentKeys(view, (id) => viewAccessKeys(env.rpcUrl, id), cfg));
592
+ {
593
+ const { vault, isDefault, error } = vaultFor(url);
594
+ if (error)
595
+ return send(400, { error });
596
+ const { reqCfg, reqView } = perRequest(vault, isDefault);
597
+ return send(200, await getAgentKeys(reqView, (id) => viewAccessKeys(env.rpcUrl, id), reqCfg));
598
+ }
225
599
  }
226
600
  /**
227
601
  * An UNSIGNED owner payload for a policy change, plus the exact CLI command.
@@ -240,33 +614,60 @@ const server = createServer(async (req, res) => {
240
614
  req.on("end", () => ok(s));
241
615
  });
242
616
  const b = JSON.parse(body || "{}");
243
- const net = await view("get_config", {});
617
+ /**
618
+ * WHICH VAULT THIS PLAN IS FOR. This route built against `env.vault`
619
+ * unconditionally, so on a hosted box every visitor asking to freeze
620
+ * their own node received a command naming the OPERATOR's vault. It
621
+ * reverts — `assert_owner` sees the wrong signer — but the dashboard
622
+ * would have shown them a confident command for somebody else's vault,
623
+ * which is the same defect the read path had and the reason `?owner=`
624
+ * was being ignored.
625
+ *
626
+ * The plan is still only a DESCRIPTION: no key is used here and the
627
+ * owner signs it in their own wallet, so naming a vault you do not own
628
+ * buys nothing the contract will honour.
629
+ */
630
+ const { vault: planVault, error: planVaultError } = vaultFor(url);
631
+ if (planVaultError)
632
+ return send(400, { error: planVaultError });
633
+ // The NETWORK, not the vault — it is a property of the chain, so the
634
+ // configured vault answers for it even when the plan targets another.
635
+ //
636
+ // CACHED, because it CANNOT CHANGE and this was an RPC round trip on
637
+ // every single call. The deploy's own assertion caught the cost: the
638
+ // refusal-follower's cold-start catch-up briefly saturates the RPC, and
639
+ // during that window this read threw and the route answered 500 instead
640
+ // of the 400 it owed the caller. A static value should not be able to
641
+ // make a request fail.
642
+ let net;
643
+ try {
644
+ net = { network: await networkName() };
645
+ }
646
+ catch (e) {
647
+ if (isUnavailable(e)) {
648
+ return send(503, {
649
+ error: "UPSTREAM_UNAVAILABLE",
650
+ detail: "Could not reach the chain to determine the network, so the CLI " +
651
+ "command cannot be written correctly. Retry shortly.",
652
+ });
653
+ }
654
+ throw e;
655
+ }
244
656
  let payload;
245
- switch (b.action) {
246
- case "caps":
247
- // HUMAN units, with decimals — `planSetNodeCaps` scales them itself
248
- // via limitsToArgs. Calling toBaseUnits here too would scale twice
249
- // and turn a 100 USDC cap into 100 million USDC.
250
- payload = planSetNodeCaps(env.vault, b.nodeId, [
251
- {
252
- token: env.token,
253
- dailyCap: b.dailyCap ?? "0",
254
- perTxCap: b.perTxCap ?? "0",
255
- decimals: env.decimals,
256
- },
257
- ]);
258
- break;
259
- case "cooldown":
260
- payload = planSetCooldown(env.vault, b.nodeId, b.cooldownSec ?? 0);
261
- break;
262
- case "recipients":
263
- payload = planSetRecipients(env.vault, b.nodeId, b.recipients ?? null);
264
- break;
265
- case "freeze":
266
- payload = planFreezeNode(env.vault, b.nodeId, b.frozen ?? false);
267
- break;
268
- default:
269
- return send(400, { error: `unknown action: ${String(b.action)}` });
657
+ try {
658
+ payload = planOwnerAction(b, { ...env, vault: planVault });
659
+ }
660
+ catch (e) {
661
+ // REPORT WHAT ACTUALLY WENT WRONG.
662
+ //
663
+ // This used to swallow the error and say `unknown action` for
664
+ // everything, so an underfunded gas key, a mistyped public key and
665
+ // an over-long label all produced the one message that cannot lead
666
+ // the caller to a fix. The validators in planAddRole write for a
667
+ // human; this hands that text through unchanged.
668
+ return send(400, {
669
+ error: e instanceof Error ? e.message : String(e),
670
+ });
270
671
  }
271
672
  return send(200, { payload, cli: toCliCommand(payload, net.network) });
272
673
  }
@@ -308,9 +709,16 @@ const server = createServer(async (req, res) => {
308
709
  // NEAR rail. Quietly paying on the wrong chain would look like
309
710
  // success and leave the payee unpaid.
310
711
  return send(400, {
311
- error: "rail=intents requires FLOAT_QUOTE_SIGNER_KEY (32-byte hex). That key " +
312
- "binds a payee to a one-time deposit address, so it belongs to the vault " +
313
- "operator. Without it the cross-chain rail is closed.",
712
+ // A named-but-broken FILE is a cause; "the variable is unset" is a
713
+ // description of the result. Saying the second when the first
714
+ // applies sends the operator to set something already set.
715
+ error: env.quoteSignerError
716
+ ? `rail=intents needs the quote signer, and the key file could not be ` +
717
+ `read. ${env.quoteSignerError}`
718
+ : "rail=intents requires FLOAT_QUOTE_SIGNER_KEY (32-byte hex), or " +
719
+ "FLOAT_QUOTE_SIGNER_KEY_FILE pointing at a file that holds it. That key " +
720
+ "binds a payee to a one-time deposit address, so it belongs to the vault " +
721
+ "operator. Without it the cross-chain rail is closed.",
314
722
  });
315
723
  }
316
724
  try {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Could we not ASK the chain, or did the chain ANSWER?
3
+ *
4
+ * These are different facts and they must never share a code path. `/v1/resolve`
5
+ * ended both of its reads with `.catch(() => null)` / `.catch(() => false)`,
6
+ * so a transient RPC failure was reported as a statement about the user's
7
+ * account:
8
+ *
9
+ * vault_id_for threw -> "No legal vault id for that account. An implicit
10
+ * account (64 hex characters) cannot have one."
11
+ * accountExists threw -> "you do not have a vault yet"
12
+ *
13
+ * Both with HTTP 200, so the dashboard proxy — which deliberately does not
14
+ * cache a failure — cached them as answers for the life of the process.
15
+ *
16
+ * MEASURED, NOT IMAGINED. Six identical calls against a running adapter gave
17
+ * four correct answers and two nulls, because the adapter's own background
18
+ * refusal-follower saturates its RPC budget and competes with request
19
+ * handling. The user-visible result is the worst available one: somebody who
20
+ * owns a populated vault is shown the create-one empty state and invited to
21
+ * create a vault that already exists.
22
+ *
23
+ * MESSAGE MATCHING, AND WHY THAT IS ACCEPTABLE HERE. `viewFunction` throws a
24
+ * plain `Error` with a constructed string, and `fetch` throws `TypeError` with
25
+ * a platform string, so there is no status code or error class to branch on
26
+ * without changing both. The strings are pinned by tests, and the failure
27
+ * direction is chosen deliberately: an unrecognised error is treated as an
28
+ * ANSWER, so a new transport failure shows up as a visible wrong answer rather
29
+ * than as a silent infinite retry.
30
+ */
31
+ export declare function isUnavailable(e: unknown): boolean;