@floatonnear/float-adapter 0.2.1 → 0.3.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/blocked-scan.d.ts +78 -0
- package/dist/blocked-scan.js +162 -8
- package/dist/env-file.d.ts +16 -0
- package/dist/env-file.js +16 -0
- package/dist/gate.d.ts +9 -5
- package/dist/gate.js +32 -5
- package/dist/handlers.d.ts +176 -3
- package/dist/handlers.js +262 -29
- package/dist/keyring.d.ts +81 -2
- package/dist/keyring.js +80 -10
- package/dist/map.d.ts +56 -11
- package/dist/map.js +43 -21
- package/dist/owner-plan.d.ts +112 -0
- package/dist/owner-plan.js +112 -0
- package/dist/server.d.ts +10 -1
- package/dist/server.js +461 -53
- package/dist/unavailable.d.ts +31 -0
- package/dist/unavailable.js +90 -0
- package/package.json +2 -2
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 {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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;
|