@floatonnear/float-adapter 0.0.0-stage → 0.1.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/server.js ADDED
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Serves the existing Float dashboard's /v1 API from NEAR vault views.
4
+ *
5
+ * The point of this process: the Float dashboard runs UNCHANGED. Same
6
+ * components, same logo, same styles, same screens — only
7
+ * NEXT_PUBLIC_FLOAT_API_URL points here instead of at the Solana backend.
8
+ *
9
+ * node:http with no framework, because the whole job is four JSON endpoints.
10
+ *
11
+ * NEAR_VAULT_CONTRACT required
12
+ * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
13
+ * NEAR_VAULT_TOKEN required (the NEP-141 the vault pays in)
14
+ * NEAR_VAULT_TOKEN_DECIMALS default 6
15
+ * NEAR_VAULT_AGENT_ID only needed for POST /v1/pay
16
+ * NEAR_VAULT_AGENT_KEY only needed for POST /v1/pay (the restricted key)
17
+ * PORT default 8787 (Float's dashboard default)
18
+ */
19
+ import { createServer } from "node:http";
20
+ import { resolveHost, corsHeaders, authorize, classifyError, fromCaller } from "./gate.js";
21
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
22
+ import { dirname } from "node:path";
23
+ import { toBaseUnits, fromBaseUnits } from "@floatonnear/mcp/amounts";
24
+ import { newRequestId, viewAccessKeys, viewFunction } from "@floatonnear/mcp";
25
+ import { buildRoute, sendRequestPayment } from "@floatonnear/mcp/sign";
26
+ import { getQuote, signBinding, toBase64 } from "@floatonnear/mcp/quote";
27
+ import { planFreezeNode, planSetCooldown, planSetNodeCaps, planSetRecipients, toCliCommand, } from "@floatonnear/mcp/payloads";
28
+ import { getAgentKeys, getDashboard, getSpendHistory, getVaultInfo, paymentFromOutcome, } from "./handlers.js";
29
+ import { AgentSelectionError, loadKeyring, selectAgent } from "./keyring.js";
30
+ import { BlockedFollower, createRpc, emptyStore } from "./blocked-scan.js";
31
+ import { parseEnvFile, applyEnvFile } from "./env-file.js";
32
+ // Load FLOAT_ENV_FILE before anything reads process.env.
33
+ //
34
+ // This replaces `set -a && . .float/adapter.env && set +a` in every deploy
35
+ // path. That idiom makes the SHELL execute a file holding the agent key, the
36
+ // quote signer and the API token, and it has already failed for real on a single
37
+ // unquoted value containing a space (see env-file.ts). It also does not exist in
38
+ // PowerShell, which is why starting the adapter on Windows needed a bespoke
39
+ // Get-Content incantation recorded in this project's memory.
40
+ //
41
+ // Real environment variables win over the file, so one service can be pointed
42
+ // at a different port or contract without editing shared secrets.
43
+ if (process.env.FLOAT_ENV_FILE) {
44
+ const envPath = process.env.FLOAT_ENV_FILE;
45
+ if (!existsSync(envPath)) {
46
+ // Fail loudly. A missing env file that is silently ignored starts an adapter
47
+ // with no API token, which disables every mutating route and looks like a
48
+ // code bug rather than a deploy mistake.
49
+ console.error(`FATAL: FLOAT_ENV_FILE=${envPath} does not exist`);
50
+ process.exit(1);
51
+ }
52
+ const applied = applyEnvFile(parseEnvFile(readFileSync(envPath, "utf8")));
53
+ // NAMES only, never values: this line goes to pm2's log on every restart.
54
+ console.log(`env file: ${envPath} — set ${applied.length} (${applied.join(", ") || "none"})`);
55
+ }
56
+ const env = {
57
+ 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",
61
+ token: process.env.NEAR_VAULT_TOKEN ?? "",
62
+ // Loopback unless explicitly overridden — exposure is opted into.
63
+ host: resolveHost(process.env.HOST),
64
+ // Required for mutating routes. Absent => those routes are disabled, not open.
65
+ apiToken: process.env.FLOAT_API_TOKEN,
66
+ // Exact-match CORS origin; the dashboard's dev server by default.
67
+ allowedOrigin: process.env.FLOAT_ALLOWED_ORIGIN ?? "http://localhost:3000",
68
+ 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,
71
+ port: Number(process.env.PORT ?? "8787"),
72
+ // Where the relayer records Base deliveries. Read fresh on each request so a
73
+ // delivery that lands while this server is running appears without a
74
+ // restart — the file is small and this is a dashboard, not a hot path.
75
+ relayLedger: process.env.RELAY_LEDGER ?? ".demo/relay-ledger.json",
76
+ // Refusals recovered from chain by scripts/scan-blocked.mjs.
77
+ blockedLog: process.env.BLOCKED_LOG ?? ".demo/blocked-log.json",
78
+ // Several agents, so the payment path can exercise the hierarchy the
79
+ // dashboard draws. This is the file create-float writes.
80
+ agentKeysPath: process.env.NEAR_VAULT_AGENT_KEYS ?? ".float/agent-keys.json",
81
+ // Follow the chain for refusals. On by default: a refusal is never stored
82
+ // in contract state, so without this the only ones the dashboard can show
83
+ // are those this process happened to witness itself.
84
+ followBlocked: process.env.BLOCKED_FOLLOW !== "0",
85
+ followIntervalMs: Number(process.env.BLOCKED_FOLLOW_INTERVAL_MS ?? "10000"),
86
+ // Binds a payee to a one-time deposit address for the cross-chain rail.
87
+ // Absent by default: whoever holds it can redirect any Intents payment the
88
+ // budget allows, so it is opt-in and belongs to the operator.
89
+ quoteSignerKey: process.env.FLOAT_QUOTE_SIGNER_KEY,
90
+ };
91
+ const keyring = loadKeyring(env);
92
+ /**
93
+ * JSON-RPC for the follower's block and chunk calls, with pacing and backoff.
94
+ *
95
+ * Deliberately not a bare fetch. Public endpoints rate-limit, and without
96
+ * retries every transient 429 became a PERMANENT gap in the coverage map,
97
+ * because a block that could not be read is recorded as unread. One load test
98
+ * left 33 fragmented holes of three to twenty blocks each — every one
99
+ * technically honest, and collectively useless, since nobody is going to
100
+ * backfill a five-block gap. A hole should mean "genuinely unavailable", not
101
+ * "the endpoint was briefly busy".
102
+ */
103
+ const rpc = createRpc(env.rpcUrl, {
104
+ onThrottle: (ms) => console.log(`[blocked-follow] rate-limited, waiting ${ms}ms`),
105
+ });
106
+ function loadBlockedStore() {
107
+ try {
108
+ if (!existsSync(env.blockedLog))
109
+ return emptyStore(env.vault);
110
+ 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
+ };
118
+ }
119
+ catch {
120
+ // A corrupt file must not stop the server, and must not be mistaken for
121
+ // "no refusals" — a null cursor makes the follower restart from the head
122
+ // rather than claim coverage it does not have.
123
+ return emptyStore(env.vault);
124
+ }
125
+ }
126
+ function saveBlockedStore(s) {
127
+ mkdirSync(dirname(env.blockedLog), { recursive: true });
128
+ writeFileSync(env.blockedLog, JSON.stringify(s, null, 2) + "\n");
129
+ }
130
+ if (!env.vault || !env.token) {
131
+ console.error("NEAR_VAULT_CONTRACT and NEAR_VAULT_TOKEN are required");
132
+ process.exit(1);
133
+ }
134
+ const cfg = {
135
+ token: env.token,
136
+ decimals: env.decimals,
137
+ blockedRing: [],
138
+ maxBlocked: 50,
139
+ };
140
+ const view = (method, args = {}) => viewFunction(env.rpcUrl, env.vault, method, args);
141
+ const server = createServer(async (req, res) => {
142
+ const url = new URL(req.url ?? "/", `http://localhost:${env.port}`);
143
+ // CORS echoes ONLY the configured origin. The previous wildcard plus an
144
+ // approving preflight meant any page the operator had open could POST JSON
145
+ // here, and /v1/pay signs with the agent key.
146
+ for (const [k, v] of Object.entries(corsHeaders(env.allowedOrigin, req.headers.origin))) {
147
+ res.setHeader(k, v);
148
+ }
149
+ if (req.method === "OPTIONS")
150
+ return res.writeHead(204).end();
151
+ // 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
153
+ // bearer token; GETs stay open behind a loopback bind.
154
+ const verdict = authorize({
155
+ method: req.method ?? "GET",
156
+ authHeader: req.headers.authorization,
157
+ token: env.apiToken,
158
+ });
159
+ if (!verdict.ok) {
160
+ res.writeHead(verdict.status, { "content-type": "application/json" });
161
+ return res.end(JSON.stringify({ error: verdict.error }));
162
+ }
163
+ const send = (code, body) => {
164
+ res.writeHead(code, { "content-type": "application/json" });
165
+ res.end(JSON.stringify(body));
166
+ };
167
+ // A missing or malformed ledger yields NO deliveries, never a crash and
168
+ // never an invented one: the worst it can do is under-report delivery, which
169
+ // is the safe direction for a claim the chain cannot back.
170
+ // Chain-recovered refusals, re-read per request so a scan that runs while
171
+ // the server is up appears without a restart. A missing or malformed file
172
+ // yields none rather than crashing — the failure direction is always to
173
+ // show fewer refusals than happened, never to invent one.
174
+ {
175
+ const parsed = (() => {
176
+ try {
177
+ if (!existsSync(env.blockedLog))
178
+ return null;
179
+ return JSON.parse(readFileSync(env.blockedLog, "utf8"));
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ })();
185
+ cfg.chainBlocked = parsed?.blocked ?? {};
186
+ cfg.blockedScan =
187
+ parsed && typeof parsed.scannedFrom === "number" && typeof parsed.scannedTo === "number"
188
+ ? {
189
+ from: parsed.scannedFrom,
190
+ to: parsed.scannedTo,
191
+ // Without these the range is a claim about blocks nobody read.
192
+ holes: Array.isArray(parsed.holes) ? parsed.holes : [],
193
+ }
194
+ : null;
195
+ }
196
+ cfg.deliveries = (() => {
197
+ try {
198
+ if (!existsSync(env.relayLedger))
199
+ return {};
200
+ return JSON.parse(readFileSync(env.relayLedger, "utf8"));
201
+ }
202
+ catch {
203
+ return {};
204
+ }
205
+ })();
206
+ try {
207
+ if (url.pathname === "/v1/dashboard") {
208
+ return send(200, await getDashboard(view, cfg));
209
+ }
210
+ if (url.pathname === "/v1/spend-history") {
211
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? "25"), 50);
212
+ const offset = Number(url.searchParams.get("offset") ?? "0");
213
+ return send(200, await getSpendHistory(view, cfg, limit, offset));
214
+ }
215
+ // Where this vault lives. The UI reads the chain from here rather than
216
+ // hardcoding it, so it cannot link a testnet vault to a mainnet explorer.
217
+ if (url.pathname === "/v1/vault-info") {
218
+ return send(200, await getVaultInfo(view, cfg, (contractId, method, args) => viewFunction(env.rpcUrl, contractId, method, args ?? {})));
219
+ }
220
+ // Agent accounts WITH their access keys read from the chain. The key
221
+ // restriction is the product's central claim, so the page that states it
222
+ // reads the real permission instead of a constant.
223
+ if (url.pathname === "/v1/agent-keys") {
224
+ return send(200, await getAgentKeys(view, (id) => viewAccessKeys(env.rpcUrl, id), cfg));
225
+ }
226
+ /**
227
+ * An UNSIGNED owner payload for a policy change, plus the exact CLI command.
228
+ *
229
+ * Deliberately not a write endpoint. Owner methods change who can spend the
230
+ * treasury, so this server never signs them — it only ever holds an agent
231
+ * key that can call `request_payment`. The dashboard therefore shows the
232
+ * owner what to run rather than claiming to have saved anything, which is
233
+ * also why the old Solana-era "Save policy" button could never have worked
234
+ * here: it POSTed to /v1/agents/:id/policy, which does not exist.
235
+ */
236
+ if (url.pathname === "/v1/owner-plan" && req.method === "POST") {
237
+ const body = await new Promise((ok) => {
238
+ let s = "";
239
+ req.on("data", (d) => (s += d));
240
+ req.on("end", () => ok(s));
241
+ });
242
+ const b = JSON.parse(body || "{}");
243
+ const net = await view("get_config", {});
244
+ 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)}` });
270
+ }
271
+ return send(200, { payload, cli: toCliCommand(payload, net.network) });
272
+ }
273
+ if (url.pathname === "/v1/settings") {
274
+ const c = await view("get_config", {});
275
+ return send(200, { ownerWallet: c.owner, notificationEmail: null });
276
+ }
277
+ if (url.pathname === "/v1/pay" && req.method === "POST") {
278
+ const body = await new Promise((ok) => {
279
+ let b = "";
280
+ req.on("data", (d) => (b += d));
281
+ req.on("end", () => ok(b));
282
+ });
283
+ const { amount, recipient, memo = "", agent, nodeId, rail = "near", destinationAsset, } = JSON.parse(body || "{}");
284
+ // WHICH agent signs decides WHICH budget is charged — the contract
285
+ // resolves the node from the signer, not from the request body. So a
286
+ // bad selection is a 400, never a quietly-redirected payment.
287
+ let creds;
288
+ try {
289
+ creds = selectAgent(keyring, { agent, nodeId });
290
+ }
291
+ catch (e) {
292
+ if (e instanceof AgentSelectionError)
293
+ return send(400, { error: e.message });
294
+ throw e;
295
+ }
296
+ // ---- Cross-chain, if asked for.
297
+ //
298
+ // `amount` means what the PAYEE receives. For the intents rail what
299
+ // actually leaves the vault is the quote's amountIn — fees included —
300
+ // and that is the figure the budget tree is charged, so the hierarchy
301
+ // governs real outflow rather than a nominal number.
302
+ const requestId = newRequestId();
303
+ let route = "near_direct";
304
+ let amountIn = fromCaller(() => toBaseUnits(amount, env.decimals));
305
+ if (rail === "intents") {
306
+ if (!env.quoteSignerKey) {
307
+ // Deliberately a 400 with the reason, not a silent fallback to the
308
+ // NEAR rail. Quietly paying on the wrong chain would look like
309
+ // success and leave the payee unpaid.
310
+ 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.",
314
+ });
315
+ }
316
+ try {
317
+ const deadline = new Date(Date.now() + 20 * 60 * 1000);
318
+ const q = await getQuote({
319
+ amountOut: fromCaller(() => toBaseUnits(amount, env.decimals)),
320
+ payee: recipient,
321
+ refundTo: env.vault,
322
+ destinationAsset,
323
+ deadline,
324
+ });
325
+ amountIn = q.amountIn;
326
+ const sk = Uint8Array.from(Buffer.from(env.quoteSignerKey.replace(/^0x/, ""), "hex"));
327
+ route = buildRoute("intents", {
328
+ depositAddress: q.depositAddress,
329
+ quoteDeadlineNs: q.deadlineNs.toString(),
330
+ signature: toBase64(signBinding({
331
+ vault: env.vault,
332
+ requestId,
333
+ token: env.token,
334
+ amount: q.amountIn,
335
+ payee: recipient,
336
+ depositAddress: q.depositAddress,
337
+ deadlineNs: q.deadlineNs,
338
+ }, sk)),
339
+ });
340
+ }
341
+ catch (e) {
342
+ return send(502, { error: `quote failed: ${e.message}` });
343
+ }
344
+ }
345
+ const outcome = (await sendRequestPayment({
346
+ rpcUrl: env.rpcUrl,
347
+ vault: env.vault,
348
+ agentId: creds.agentId,
349
+ agentKey: creds.secret,
350
+ requestId,
351
+ token: env.token,
352
+ amount: amountIn,
353
+ recipient,
354
+ memo,
355
+ gasTgas: 100,
356
+ route,
357
+ }));
358
+ // A BLOCKED outcome is never stored on-chain, so if the dashboard is to
359
+ // show it as a row, this process has to remember it. See recordBlocked.
360
+ const tree = await view("get_tree", {});
361
+ const labels = {};
362
+ for (const n of tree)
363
+ labels[n.id] = n.label;
364
+ const blocked = outcome.status
365
+ ? paymentFromOutcome(cfg, {
366
+ request_id: outcome.request_id ?? "",
367
+ node_id: outcome.node_id ?? 0,
368
+ status: outcome.status,
369
+ reason_code: outcome.reason_code ?? "",
370
+ }, labels, amount, recipient)
371
+ : null;
372
+ return send(200, { outcome, blocked });
373
+ }
374
+ if (url.pathname === "/healthz") {
375
+ const c = await view("get_config", {});
376
+ return send(200, { ok: true, vault: c.vault_id, clock_mode: c.clock_mode });
377
+ }
378
+ send(404, { error: `no such endpoint: ${url.pathname}` });
379
+ }
380
+ catch (e) {
381
+ // Classified, not assumed. A caller's malformed input is THEIR error and
382
+ // must not read as "the server broke, retry" — an agent that retries a
383
+ // bad amount retries it forever.
384
+ const { status, body } = classifyError(e);
385
+ send(status, body);
386
+ }
387
+ });
388
+ // ---- Follow the chain for refusals.
389
+ //
390
+ // Refusals live only in NEP-297 events, so an adapter that does not watch the
391
+ // chain can only ever show the ones it personally caused. Started before
392
+ // listening so the first dashboard request already has a cursor.
393
+ if (env.followBlocked) {
394
+ const follower = new BlockedFollower({
395
+ rpc,
396
+ vault: env.vault,
397
+ load: loadBlockedStore,
398
+ save: saveBlockedStore,
399
+ intervalMs: env.followIntervalMs,
400
+ onTick: ({ from, to, added, holes }) => {
401
+ // Silent when there is nothing to say; a per-tick line every 10s would
402
+ // bury anything that matters.
403
+ if (added || holes) {
404
+ console.log(`[blocked-follow] blocks ${from}..${to} +${added} refusal(s)` +
405
+ (holes ? ` ${holes} unreadable block(s)` : ""));
406
+ }
407
+ },
408
+ onSkip: ({ from, to }) => {
409
+ console.warn(`[blocked-follow] BACKLOG TOO LARGE — blocks ${from}..${to} ` +
410
+ `(${to - from + 1}) were NOT scanned and are recorded as a gap. ` +
411
+ `Refusals in that span are unknown, not absent. ` +
412
+ `Fill it with: node scripts/scan-blocked.mjs --from ${from}`);
413
+ },
414
+ });
415
+ follower.start();
416
+ const shutdown = () => follower.stop();
417
+ process.once("SIGINT", shutdown);
418
+ process.once("SIGTERM", shutdown);
419
+ }
420
+ server.listen(env.port, env.host, () => {
421
+ console.log(`float-adapter -> ${env.vault} on ${env.rpcUrl}`);
422
+ // The address the socket is ACTUALLY on. The old line hardcoded 127.0.0.1
423
+ // while listen() had bound 0.0.0.0 — a claim that stopped anyone checking.
424
+ const addr = server.address();
425
+ const shown = typeof addr === "object" && addr ? `${addr.address}:${addr.port}` : `${env.host}:${env.port}`;
426
+ console.log(`listening on http://${shown}`);
427
+ if (env.host !== "127.0.0.1" && env.host !== "::1" && env.host !== "localhost") {
428
+ console.log(` WARNING: bound to ${env.host} — reachable beyond this machine.`);
429
+ }
430
+ console.log(` mutating routes: ${env.apiToken ? "require a bearer token" : "DISABLED until FLOAT_API_TOKEN is set"}`);
431
+ console.log(` cors origin : ${env.allowedOrigin}`);
432
+ console.log(`point the dashboard at it: NEXT_PUBLIC_FLOAT_API_URL=http://127.0.0.1:${env.port}`);
433
+ console.log(`token ${env.token} (${env.decimals} decimals), 1 unit = ${fromBaseUnits(10n ** BigInt(env.decimals), env.decimals)}`);
434
+ });
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@floatonnear/float-adapter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Serves the existing Float dashboard's /v1 API from NEAR vault views, so the Float UI runs unchanged against the NEAR hierarchical vault.",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "float-adapter": "dist/server.js"
9
+ },
10
+ "files": [
11
+ "dist/"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "prepack": "npm run build",
16
+ "start": "node dist/server.js",
17
+ "test": "vitest run",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@floatonnear/mcp": "^0.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.9.0",
25
+ "vitest": "^5.0.5",
26
+ "@types/node": "^24.0.0"
27
+ }
28
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.