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