lightnode-sdk 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/add.js ADDED
@@ -0,0 +1,935 @@
1
+ /**
2
+ * `lightnode add inference` - patch an EXISTING project to do encrypted
3
+ * LightChain AI inference. Detects the framework from package.json (Next.js,
4
+ * Hono, or plain Node) and writes the appropriate route/script + a .env.example
5
+ * if one isn't already there. Idempotent: existing files are not overwritten
6
+ * unless --force is passed.
7
+ *
8
+ * No runtime dependencies; templates are inlined as string literals.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ function readPackageJson(cwd) {
13
+ const p = path.join(cwd, "package.json");
14
+ if (!fs.existsSync(p))
15
+ return null;
16
+ try {
17
+ return JSON.parse(fs.readFileSync(p, "utf8"));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function detectTemplate(cwd) {
24
+ const pkg = readPackageJson(cwd);
25
+ if (!pkg)
26
+ return "node";
27
+ const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
28
+ if (all["next"])
29
+ return "nextjs-api";
30
+ if (all["hono"])
31
+ return "hono";
32
+ return "node";
33
+ }
34
+ const ENV_EXAMPLE = (net) => `# Funded private key. Testnet works free (faucet at https://lightfaucet.ai).
35
+ PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000
36
+
37
+ NETWORK=${net}
38
+ MODEL=llama3-8b
39
+ `;
40
+ const NEXTJS_ROUTE = `// app/api/inference/route.ts
41
+ // Generated by 'lightnode add inference'. See https://lightnode.app/build
42
+ import { NextResponse } from "next/server";
43
+ import WS from "ws";
44
+ import { createPublicClient, createWalletClient, http, parseAbi, parseAbiItem, parseEther, type Log } from "viem";
45
+ import { privateKeyToAccount } from "viem/accounts";
46
+ import {
47
+ LightNode, prepareSession, submitPrompt, decryptResponse,
48
+ estimateJobFee, consumerGatewayUrl, JOB_REGISTRY_CONSUMER_ABI,
49
+ GatewayClient, type NetworkId,
50
+ } from "lightnode-sdk";
51
+
52
+ export const runtime = "nodejs";
53
+ export const dynamic = "force-dynamic";
54
+ export const maxDuration = 120;
55
+
56
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
57
+ const MODEL = process.env.MODEL ?? "llama3-8b";
58
+
59
+ export async function POST(req: Request) {
60
+ if (!process.env.PRIVATE_KEY?.startsWith("0x")) {
61
+ return NextResponse.json({ error: "PRIVATE_KEY not configured" }, { status: 500 });
62
+ }
63
+ const body = (await req.json().catch(() => ({}))) as { prompt?: string };
64
+ const prompt = body.prompt?.trim();
65
+ if (!prompt) return NextResponse.json({ error: "prompt is required" }, { status: 400 });
66
+
67
+ const ln = new LightNode(NETWORK);
68
+ const cfg = ln.network;
69
+ const acct = privateKeyToAccount(process.env.PRIVATE_KEY as \`0x\${string}\`);
70
+ const chain = { id: cfg.chainId, name: cfg.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [cfg.rpc] } } };
71
+ const pub = createPublicClient({ transport: http(cfg.rpc), chain });
72
+ const wal = createWalletClient({ account: acct, transport: http(cfg.rpc), chain });
73
+ const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
74
+
75
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
76
+ if (!ch.message) return NextResponse.json({ error: "auth challenge failed" }, { status: 502 });
77
+ const sig = await wal.signMessage({ message: ch.message });
78
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, {
79
+ method: "POST", headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({ message: ch.message, signature: sig }),
81
+ })).json() as { token?: string };
82
+ if (!verify.token) return NextResponse.json({ error: "auth verify failed" }, { status: 502 });
83
+
84
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
85
+ const { sessionKey, createSessionArgs } = await prepareSession(gateway, MODEL);
86
+ const fee = await estimateJobFee(cfg, MODEL);
87
+ const createTx = await wal.writeContract({
88
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "createSession",
89
+ args: [createSessionArgs.paramsHash, createSessionArgs.worker, createSessionArgs.encWorkerKey, createSessionArgs.ephemeralPubKey, createSessionArgs.initState, createSessionArgs.expiry],
90
+ gas: 1_000_000n,
91
+ });
92
+ const createReceipt = await pub.waitForTransactionReceipt({ hash: createTx });
93
+ const sessionCreated = parseAbiItem("event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)");
94
+ const sessionLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: sessionCreated, blockHash: createReceipt.blockHash })).find((l) => l.transactionHash === createTx);
95
+ const sessionId = sessionLog?.args.sessionId;
96
+ if (!sessionId) return NextResponse.json({ error: "SessionCreated missing" }, { status: 500 });
97
+
98
+ let relayToken: string | undefined;
99
+ for (let i = 0; i < 30 && !relayToken; i++) {
100
+ const r = await gateway.getSessionToken(Number(sessionId));
101
+ if ("token" in r && r.token) relayToken = r.token; else await new Promise((res) => setTimeout(res, 1000));
102
+ }
103
+ if (!relayToken) return NextResponse.json({ error: "relay token never became ready" }, { status: 504 });
104
+ const ws = new WS(\`wss://relay.\${NETWORK}.lightchain.ai/ws?token=\${encodeURIComponent(relayToken)}\`);
105
+ const chunks: string[] = [];
106
+ await new Promise<void>((res, rej) => { ws.once("open", () => res()); ws.once("error", rej); });
107
+ ws.on("message", async (data: Buffer) => {
108
+ let f: { type?: string; payload?: string };
109
+ try { f = JSON.parse(data.toString("utf8")); } catch { return; }
110
+ if (!f.payload) return;
111
+ if (f.type === "chunk") { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
112
+ else if (f.type === "complete" && chunks.length === 0) { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
113
+ });
114
+
115
+ const promptHash = await submitPrompt(gateway, sessionKey, prompt);
116
+ const submitTx = await wal.writeContract({
117
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "submitJob",
118
+ args: [sessionId, promptHash], value: parseEther(String(fee)), gas: 500_000n,
119
+ });
120
+ const submitReceipt = await pub.waitForTransactionReceipt({ hash: submitTx });
121
+ const jobSubmitted = parseAbiItem("event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)");
122
+ const jobLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobSubmitted, blockHash: submitReceipt.blockHash })).find((l) => l.transactionHash === submitTx);
123
+ const jobId = jobLog?.args.jobId;
124
+ if (!jobId) return NextResponse.json({ error: "JobSubmitted missing" }, { status: 500 });
125
+
126
+ const jobCompleted = parseAbiItem("event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)");
127
+ const deadline = Date.now() + 90_000;
128
+ let completed: Log | null = null;
129
+ while (!completed && Date.now() < deadline) {
130
+ await new Promise((res) => setTimeout(res, 3000));
131
+ const logs = await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobCompleted, args: { jobId }, fromBlock: submitReceipt.blockNumber });
132
+ if (logs.length) completed = logs[0] as Log;
133
+ }
134
+ await new Promise((res) => setTimeout(res, 4000));
135
+ ws.close();
136
+ if (!completed) return NextResponse.json({ error: "worker stalled", txs: { createSession: createTx, submitJob: submitTx } }, { status: 504 });
137
+
138
+ return NextResponse.json({
139
+ answer: chunks.join(""),
140
+ txs: { createSession: createTx, submitJob: submitTx, jobCompleted: completed.transactionHash },
141
+ sessionId: sessionId.toString(),
142
+ jobId: jobId.toString(),
143
+ worker: createSessionArgs.worker,
144
+ });
145
+ }
146
+ `;
147
+ const HONO_HANDLER = `// lightchain-inference.ts
148
+ // Generated by 'lightnode add inference'. See https://lightnode.app/build
149
+ // Plug the handler below into your Hono router:
150
+ //
151
+ // import { Hono } from "hono";
152
+ // import { inferenceHandler } from "./lightchain-inference.js";
153
+ // const app = new Hono();
154
+ // app.post("/inference", inferenceHandler);
155
+ //
156
+ import WS from "ws";
157
+ import { createPublicClient, createWalletClient, http, parseAbi, parseAbiItem, parseEther, type Log } from "viem";
158
+ import { privateKeyToAccount } from "viem/accounts";
159
+ import type { Context } from "hono";
160
+ import {
161
+ LightNode, prepareSession, submitPrompt, decryptResponse,
162
+ estimateJobFee, consumerGatewayUrl, JOB_REGISTRY_CONSUMER_ABI,
163
+ GatewayClient, type NetworkId,
164
+ } from "lightnode-sdk";
165
+
166
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
167
+ const MODEL = process.env.MODEL ?? "llama3-8b";
168
+
169
+ export async function inferenceHandler(c: Context) {
170
+ if (!process.env.PRIVATE_KEY?.startsWith("0x")) return c.json({ error: "PRIVATE_KEY not set" }, 500);
171
+ const body = await c.req.json().catch(() => ({} as { prompt?: string }));
172
+ const prompt = body.prompt?.trim();
173
+ if (!prompt) return c.json({ error: "prompt is required" }, 400);
174
+
175
+ const ln = new LightNode(NETWORK);
176
+ const cfg = ln.network;
177
+ const acct = privateKeyToAccount(process.env.PRIVATE_KEY as \`0x\${string}\`);
178
+ const chain = { id: cfg.chainId, name: cfg.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [cfg.rpc] } } };
179
+ const pub = createPublicClient({ transport: http(cfg.rpc), chain });
180
+ const wal = createWalletClient({ account: acct, transport: http(cfg.rpc), chain });
181
+ const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
182
+
183
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
184
+ if (!ch.message) return c.json({ error: "auth challenge failed" }, 502);
185
+ const sig = await wal.signMessage({ message: ch.message });
186
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
187
+ if (!verify.token) return c.json({ error: "auth verify failed" }, 502);
188
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
189
+
190
+ const { sessionKey, createSessionArgs } = await prepareSession(gateway, MODEL);
191
+ const fee = await estimateJobFee(cfg, MODEL);
192
+ const createTx = await wal.writeContract({
193
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "createSession",
194
+ args: [createSessionArgs.paramsHash, createSessionArgs.worker, createSessionArgs.encWorkerKey, createSessionArgs.ephemeralPubKey, createSessionArgs.initState, createSessionArgs.expiry],
195
+ gas: 1_000_000n,
196
+ });
197
+ const createReceipt = await pub.waitForTransactionReceipt({ hash: createTx });
198
+ const sessionCreated = parseAbiItem("event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)");
199
+ const sessionLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: sessionCreated, blockHash: createReceipt.blockHash })).find((l) => l.transactionHash === createTx);
200
+ const sessionId = sessionLog?.args.sessionId;
201
+ if (!sessionId) return c.json({ error: "SessionCreated missing" }, 500);
202
+
203
+ let relayToken: string | undefined;
204
+ for (let i = 0; i < 30 && !relayToken; i++) { const r = await gateway.getSessionToken(Number(sessionId)); if ("token" in r && r.token) relayToken = r.token; else await new Promise((res) => setTimeout(res, 1000)); }
205
+ if (!relayToken) return c.json({ error: "relay token never became ready" }, 504);
206
+ const ws = new WS(\`wss://relay.\${NETWORK}.lightchain.ai/ws?token=\${encodeURIComponent(relayToken)}\`);
207
+ const chunks: string[] = [];
208
+ await new Promise<void>((res, rej) => { ws.once("open", () => res()); ws.once("error", rej); });
209
+ ws.on("message", async (data: Buffer) => {
210
+ let f: { type?: string; payload?: string };
211
+ try { f = JSON.parse(data.toString("utf8")); } catch { return; }
212
+ if (!f.payload) return;
213
+ if (f.type === "chunk") { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
214
+ else if (f.type === "complete" && chunks.length === 0) { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
215
+ });
216
+
217
+ const promptHash = await submitPrompt(gateway, sessionKey, prompt);
218
+ const submitTx = await wal.writeContract({
219
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "submitJob",
220
+ args: [sessionId, promptHash], value: parseEther(String(fee)), gas: 500_000n,
221
+ });
222
+ const submitReceipt = await pub.waitForTransactionReceipt({ hash: submitTx });
223
+ const jobSubmitted = parseAbiItem("event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)");
224
+ const jobLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobSubmitted, blockHash: submitReceipt.blockHash })).find((l) => l.transactionHash === submitTx);
225
+ const jobId = jobLog?.args.jobId;
226
+ if (!jobId) return c.json({ error: "JobSubmitted missing" }, 500);
227
+
228
+ const jobCompleted = parseAbiItem("event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)");
229
+ const deadline = Date.now() + 90_000;
230
+ let completed: Log | null = null;
231
+ while (!completed && Date.now() < deadline) {
232
+ await new Promise((res) => setTimeout(res, 3000));
233
+ const logs = await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobCompleted, args: { jobId }, fromBlock: submitReceipt.blockNumber });
234
+ if (logs.length) completed = logs[0] as Log;
235
+ }
236
+ await new Promise((res) => setTimeout(res, 4000));
237
+ ws.close();
238
+ if (!completed) return c.json({ error: "worker stalled", txs: { createSession: createTx, submitJob: submitTx } }, 504);
239
+ return c.json({ answer: chunks.join(""), txs: { createSession: createTx, submitJob: submitTx, jobCompleted: completed.transactionHash }, sessionId: sessionId.toString(), jobId: jobId.toString(), worker: createSessionArgs.worker });
240
+ }
241
+ `;
242
+ const NODE_SCRIPT = `// lightchain-inference.ts
243
+ // Generated by 'lightnode add inference'. Run with: tsx lightchain-inference.ts "your prompt"
244
+ import WS from "ws";
245
+ import { createPublicClient, createWalletClient, http, parseAbi, parseAbiItem, parseEther, type Log } from "viem";
246
+ import { privateKeyToAccount } from "viem/accounts";
247
+ import {
248
+ LightNode, prepareSession, submitPrompt, decryptResponse,
249
+ estimateJobFee, consumerGatewayUrl, JOB_REGISTRY_CONSUMER_ABI,
250
+ GatewayClient, type NetworkId,
251
+ } from "lightnode-sdk";
252
+
253
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
254
+ const MODEL = process.env.MODEL ?? "llama3-8b";
255
+ const PROMPT = process.argv.slice(2).join(" ").trim() || "Reply with a one-sentence fun fact.";
256
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
257
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
258
+
259
+ const ln = new LightNode(NETWORK);
260
+ const cfg = ln.network;
261
+ const acct = privateKeyToAccount(PRIVATE_KEY);
262
+ const chain = { id: cfg.chainId, name: cfg.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [cfg.rpc] } } };
263
+ const pub = createPublicClient({ transport: http(cfg.rpc), chain });
264
+ const wal = createWalletClient({ account: acct, transport: http(cfg.rpc), chain });
265
+ const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
266
+
267
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
268
+ if (!ch.message) throw new Error("auth challenge failed");
269
+ const sig = await wal.signMessage({ message: ch.message });
270
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
271
+ if (!verify.token) throw new Error("auth verify failed");
272
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
273
+
274
+ const { sessionKey, createSessionArgs } = await prepareSession(gateway, MODEL);
275
+ const fee = await estimateJobFee(cfg, MODEL);
276
+ const createTx = await wal.writeContract({
277
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "createSession",
278
+ args: [createSessionArgs.paramsHash, createSessionArgs.worker, createSessionArgs.encWorkerKey, createSessionArgs.ephemeralPubKey, createSessionArgs.initState, createSessionArgs.expiry],
279
+ gas: 1_000_000n,
280
+ });
281
+ const createReceipt = await pub.waitForTransactionReceipt({ hash: createTx });
282
+ const sessionCreated = parseAbiItem("event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)");
283
+ const sessionLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: sessionCreated, blockHash: createReceipt.blockHash })).find((l) => l.transactionHash === createTx);
284
+ if (!sessionLog?.args.sessionId) throw new Error("SessionCreated missing");
285
+ const sessionId = sessionLog.args.sessionId;
286
+
287
+ let relayToken: string | undefined;
288
+ for (let i = 0; i < 30 && !relayToken; i++) {
289
+ const r = await gateway.getSessionToken(Number(sessionId));
290
+ if ("token" in r && r.token) relayToken = r.token; else await new Promise((res) => setTimeout(res, 1000));
291
+ }
292
+ if (!relayToken) throw new Error("relay token never became ready");
293
+ const ws = new WS(\`wss://relay.\${NETWORK}.lightchain.ai/ws?token=\${encodeURIComponent(relayToken)}\`);
294
+ const chunks: string[] = [];
295
+ await new Promise<void>((res, rej) => { ws.once("open", () => res()); ws.once("error", rej); });
296
+ ws.on("message", async (data: Buffer) => {
297
+ let f: { type?: string; payload?: string };
298
+ try { f = JSON.parse(data.toString("utf8")); } catch { return; }
299
+ if (!f.payload) return;
300
+ if (f.type === "chunk") { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
301
+ else if (f.type === "complete" && chunks.length === 0) { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
302
+ });
303
+
304
+ const promptHash = await submitPrompt(gateway, sessionKey, PROMPT);
305
+ const submitTx = await wal.writeContract({
306
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "submitJob",
307
+ args: [sessionId, promptHash], value: parseEther(String(fee)), gas: 500_000n,
308
+ });
309
+ const submitReceipt = await pub.waitForTransactionReceipt({ hash: submitTx });
310
+ const jobSubmitted = parseAbiItem("event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)");
311
+ const jobLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobSubmitted, blockHash: submitReceipt.blockHash })).find((l) => l.transactionHash === submitTx);
312
+ const jobId = jobLog?.args.jobId;
313
+ if (!jobId) throw new Error("JobSubmitted missing");
314
+
315
+ const jobCompleted = parseAbiItem("event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)");
316
+ const deadline = Date.now() + 90_000;
317
+ let completed: Log | null = null;
318
+ while (!completed && Date.now() < deadline) {
319
+ await new Promise((res) => setTimeout(res, 3000));
320
+ const logs = await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobCompleted, args: { jobId }, fromBlock: submitReceipt.blockNumber });
321
+ if (logs.length) completed = logs[0] as Log;
322
+ }
323
+ if (!completed) { console.error("worker stalled - re-run for a different worker"); process.exit(1); }
324
+ await new Promise((res) => setTimeout(res, 4000));
325
+ ws.close();
326
+ console.log("\\n=== ANSWER ===\\n" + chunks.join("") + "\\n");
327
+ console.log("createSession:", createTx, "\\nsubmitJob: ", submitTx, "\\njobCompleted: ", completed.transactionHash);
328
+ process.exit(0);
329
+ `;
330
+ function writeFile(abs, contents, force) {
331
+ const rel = path.relative(process.cwd(), abs) || abs;
332
+ if (fs.existsSync(abs) && !force) {
333
+ return { path: rel, skipped: true, reason: "already exists (use --force to overwrite)" };
334
+ }
335
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
336
+ fs.writeFileSync(abs, contents);
337
+ return { path: rel };
338
+ }
339
+ function depsNeeded(template) {
340
+ if (template === "nextjs-api")
341
+ return ["lightnode-sdk", "viem", "ws"];
342
+ if (template === "hono")
343
+ return ["lightnode-sdk", "viem", "ws"];
344
+ return ["lightnode-sdk", "viem", "ws"];
345
+ }
346
+ /**
347
+ * Implementation called by `lightnode add inference [...]`.
348
+ * Returns the list of files written + the install command the user should run.
349
+ */
350
+ export function addInference(opts = {}) {
351
+ const cwd = opts.cwd ?? process.cwd();
352
+ const network = opts.network ?? "testnet";
353
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
354
+ const force = !!opts.force;
355
+ const written = [];
356
+ if (template === "nextjs-api") {
357
+ written.push(writeFile(path.join(cwd, "app/api/inference/route.ts"), NEXTJS_ROUTE, force));
358
+ }
359
+ else if (template === "hono") {
360
+ written.push(writeFile(path.join(cwd, "lightchain-inference.ts"), HONO_HANDLER, force));
361
+ }
362
+ else {
363
+ written.push(writeFile(path.join(cwd, "lightchain-inference.ts"), NODE_SCRIPT, force));
364
+ }
365
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
366
+ return {
367
+ written,
368
+ install: `npm install ${depsNeeded(template).join(" ")}`,
369
+ template,
370
+ network,
371
+ };
372
+ }
373
+ // ---------------------------------------------------------------------------
374
+ // `lightnode add analytics-dashboard` - drop in a read-only network/worker
375
+ // analytics page that uses the SDK's getNetworkAnalytics + getModelStats +
376
+ // getWorkerStats. All reads, no wallet needed, no fees - so it composes onto
377
+ // any existing dApp.
378
+ // ---------------------------------------------------------------------------
379
+ const NEXTJS_DASHBOARD_PAGE = `// app/lightnode-analytics/page.tsx
380
+ // Generated by 'lightnode add analytics-dashboard'. See https://lightnode.app/build
381
+ import { LightNode, type NetworkId } from "lightnode-sdk";
382
+
383
+ export const revalidate = 30; // cache the SSR render for 30s
384
+
385
+ const NETWORK = (process.env.NEXT_PUBLIC_LIGHTCHAIN_NETWORK ?? "mainnet") as NetworkId;
386
+
387
+ export default async function LightNodeAnalyticsPage() {
388
+ const ln = new LightNode(NETWORK);
389
+ const [network, models, workers] = await Promise.all([
390
+ ln.getNetworkAnalytics(),
391
+ ln.getModelStats(),
392
+ ln.getWorkerStats(1000, 12),
393
+ ]);
394
+
395
+ return (
396
+ <main style={{ maxWidth: 1080, margin: "40px auto", padding: 24, fontFamily: "system-ui, sans-serif", color: "#111" }}>
397
+ <header style={{ marginBottom: 24 }}>
398
+ <h1 style={{ fontSize: 28, fontWeight: 600 }}>LightChain {NETWORK} - network analytics</h1>
399
+ <p style={{ color: "#555", marginTop: 6 }}>
400
+ Live read from the public worker subgraph + on-chain registration. Auto-refreshes every 30 seconds.
401
+ </p>
402
+ </header>
403
+
404
+ <section style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", marginBottom: 28 }}>
405
+ <Stat label="Completion" value={pct(network.completionRate)} />
406
+ <Stat label="Jobs" value={fmt(network.jobs)} />
407
+ <Stat label="Incomplete" value={fmt(network.incomplete)} />
408
+ <Stat label="Earnings (LCAI)" value={network.earnings.toFixed(2)} />
409
+ </section>
410
+
411
+ <section style={{ marginBottom: 32 }}>
412
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 12 }}>Per-model performance</h2>
413
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
414
+ <thead>
415
+ <tr style={{ textAlign: "left", borderBottom: "1px solid #ddd", color: "#666" }}>
416
+ <th style={{ padding: 8 }}>Model</th>
417
+ <th style={{ padding: 8 }}>Jobs</th>
418
+ <th style={{ padding: 8 }}>Completion</th>
419
+ <th style={{ padding: 8 }}>p50</th>
420
+ <th style={{ padding: 8 }}>p95</th>
421
+ <th style={{ padding: 8 }}>Earnings</th>
422
+ </tr>
423
+ </thead>
424
+ <tbody>
425
+ {models.map((m) => (
426
+ <tr key={m.modelId} style={{ borderBottom: "1px solid #f0f0f0" }}>
427
+ <td style={{ padding: 8, fontWeight: 500 }}>{m.name}</td>
428
+ <td style={{ padding: 8 }}>{fmt(m.total)}</td>
429
+ <td style={{ padding: 8 }}>{pct(m.completionRate)}</td>
430
+ <td style={{ padding: 8 }}>{m.p50 ?? "-"}s</td>
431
+ <td style={{ padding: 8 }}>{m.p95 ?? "-"}s</td>
432
+ <td style={{ padding: 8 }}>{m.earnings.toFixed(2)} LCAI</td>
433
+ </tr>
434
+ ))}
435
+ </tbody>
436
+ </table>
437
+ </section>
438
+
439
+ <section>
440
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 12 }}>Busiest workers (top 12)</h2>
441
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
442
+ <thead>
443
+ <tr style={{ textAlign: "left", borderBottom: "1px solid #ddd", color: "#666" }}>
444
+ <th style={{ padding: 8 }}>Worker</th>
445
+ <th style={{ padding: 8 }}>Jobs</th>
446
+ <th style={{ padding: 8 }}>Completion</th>
447
+ <th style={{ padding: 8 }}>Earnings</th>
448
+ </tr>
449
+ </thead>
450
+ <tbody>
451
+ {workers.map((w) => (
452
+ <tr key={w.address} style={{ borderBottom: "1px solid #f0f0f0" }}>
453
+ <td style={{ padding: 8, fontFamily: "monospace" }}>{short(w.address)}</td>
454
+ <td style={{ padding: 8 }}>{fmt(w.total)}</td>
455
+ <td style={{ padding: 8 }}>{pct(w.completionRate)}</td>
456
+ <td style={{ padding: 8 }}>{w.earnings.toFixed(3)} LCAI</td>
457
+ </tr>
458
+ ))}
459
+ </tbody>
460
+ </table>
461
+ </section>
462
+
463
+ <p style={{ marginTop: 28, color: "#888", fontSize: 12 }}>
464
+ Powered by the open-source <a href="https://www.npmjs.com/package/lightnode-sdk">lightnode-sdk</a>.
465
+ Same data the dashboard at lightnode.app uses; you can re-style or filter freely.
466
+ </p>
467
+ </main>
468
+ );
469
+ }
470
+
471
+ function Stat({ label, value }: { label: string; value: string }) {
472
+ return (
473
+ <div style={{ background: "#fafafa", border: "1px solid #eee", borderRadius: 12, padding: 16 }}>
474
+ <div style={{ fontSize: 11, color: "#888", textTransform: "uppercase", letterSpacing: 0.5 }}>{label}</div>
475
+ <div style={{ fontSize: 24, fontWeight: 600, marginTop: 4, fontVariantNumeric: "tabular-nums" }}>{value}</div>
476
+ </div>
477
+ );
478
+ }
479
+
480
+ function pct(r: number | null): string { return r == null ? "-" : \`\${Math.round(r * 100)}%\`; }
481
+ function fmt(n: number): string { return n.toLocaleString(); }
482
+ function short(a: string): string { return \`\${a.slice(0, 6)}…\${a.slice(-4)}\`; }
483
+ `;
484
+ const NODE_DASHBOARD_SCRIPT = `// lightnode-analytics.ts
485
+ // Generated by 'lightnode add analytics-dashboard'. Run with: tsx lightnode-analytics.ts
486
+ import { LightNode, type NetworkId } from "lightnode-sdk";
487
+
488
+ const NETWORK = (process.env.NETWORK ?? "mainnet") as NetworkId;
489
+ const ln = new LightNode(NETWORK);
490
+
491
+ const [network, models, workers] = await Promise.all([
492
+ ln.getNetworkAnalytics(),
493
+ ln.getModelStats(),
494
+ ln.getWorkerStats(1000, 10),
495
+ ]);
496
+
497
+ console.log(\`LightChain \${NETWORK} - network analytics\\n\`);
498
+ console.log(\`Completion : \${pct(network.completionRate)}\`);
499
+ console.log(\`Jobs : \${network.jobs.toLocaleString()}\`);
500
+ console.log(\`Incomplete : \${network.incomplete.toLocaleString()}\`);
501
+ console.log(\`Earnings : \${network.earnings.toFixed(2)} LCAI\\n\`);
502
+
503
+ console.log("Per-model performance:");
504
+ for (const m of models) {
505
+ console.log(\` \${m.name.padEnd(14)} jobs=\${String(m.total).padStart(5)} completion=\${pct(m.completionRate)} p50=\${m.p50 ?? "-"}s earnings=\${m.earnings.toFixed(3)} LCAI\`);
506
+ }
507
+
508
+ console.log("\\nTop 10 workers:");
509
+ for (const w of workers) {
510
+ console.log(\` \${short(w.address)} jobs=\${String(w.total).padStart(4)} completion=\${pct(w.completionRate)} earnings=\${w.earnings.toFixed(3)} LCAI\`);
511
+ }
512
+
513
+ function pct(r: number | null): string { return r == null ? "-" : \`\${Math.round(r * 100)}%\`; }
514
+ function short(a: string): string { return \`\${a.slice(0, 6)}…\${a.slice(-4)}\`; }
515
+ `;
516
+ export function addAnalyticsDashboard(opts = {}) {
517
+ const cwd = opts.cwd ?? process.cwd();
518
+ const network = opts.network ?? "mainnet";
519
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
520
+ const force = !!opts.force;
521
+ const written = [];
522
+ if (template === "nextjs-api") {
523
+ written.push(writeFile(path.join(cwd, "app/lightnode-analytics/page.tsx"), NEXTJS_DASHBOARD_PAGE, force));
524
+ }
525
+ else {
526
+ // Hono and Node both get the CLI-style script; the SDK calls are pure
527
+ // server-side reads anyway and a custom Hono route is trivial to wrap.
528
+ written.push(writeFile(path.join(cwd, "lightnode-analytics.ts"), NODE_DASHBOARD_SCRIPT, force));
529
+ }
530
+ return { written, install: `npm install lightnode-sdk`, template, network };
531
+ }
532
+ // ---------------------------------------------------------------------------
533
+ // `lightnode add nft-mint-with-inference` - drop in a function that uses
534
+ // LightChain AI to generate NFT metadata from a prompt. The caller wires it
535
+ // into their existing mint flow; we don't pick a specific ERC-721 contract.
536
+ // ---------------------------------------------------------------------------
537
+ const NEXTJS_NFT_METADATA_ROUTE = `// app/api/nft-metadata/route.ts
538
+ // Generated by 'lightnode add nft-mint-with-inference'.
539
+ // Calls /api/inference (also added by 'lightnode add inference') to generate
540
+ // an NFT description from a short prompt, returns ERC-721-style metadata.
541
+ import { NextResponse } from "next/server";
542
+
543
+ export const runtime = "nodejs";
544
+ export const dynamic = "force-dynamic";
545
+
546
+ interface MintInput {
547
+ name?: string;
548
+ prompt?: string;
549
+ image?: string;
550
+ attributes?: Array<{ trait_type: string; value: string | number }>;
551
+ }
552
+
553
+ export async function POST(req: Request) {
554
+ const body = (await req.json().catch(() => ({}))) as MintInput;
555
+ const name = body.name?.trim();
556
+ const prompt = body.prompt?.trim();
557
+ if (!name || !prompt) return NextResponse.json({ error: "name and prompt are required" }, { status: 400 });
558
+
559
+ // Reuse the inference route added by 'lightnode add inference'. If you mounted
560
+ // it elsewhere, update this path. If you'd rather call the SDK directly here,
561
+ // copy the contents of app/api/inference/route.ts into this file.
562
+ const origin = new URL(req.url).origin;
563
+ const inference = await fetch(\`\${origin}/api/inference\`, {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json" },
566
+ body: JSON.stringify({ prompt: \`Write a short, evocative 1-2 sentence description of an NFT titled "\${name}" with this concept: \${prompt}\` }),
567
+ }).then((r) => r.json()) as { answer?: string; txs?: Record<string, string>; error?: string };
568
+
569
+ if (!inference?.answer) {
570
+ return NextResponse.json({ error: inference?.error ?? "inference failed" }, { status: 502 });
571
+ }
572
+
573
+ return NextResponse.json({
574
+ name,
575
+ description: inference.answer.trim(),
576
+ image: body.image ?? null,
577
+ attributes: body.attributes ?? [],
578
+ // Provenance: the on-chain LightChain AI transactions that generated this metadata.
579
+ // Pin this whole object to IPFS and use the IPFS hash as your tokenURI.
580
+ lightchain_inference: inference.txs,
581
+ });
582
+ }
583
+ `;
584
+ const NEXTJS_NFT_MINT_CLIENT = `// app/nft-mint/page.tsx
585
+ // Generated by 'lightnode add nft-mint-with-inference'.
586
+ // Minimal client that takes a name + concept, generates AI metadata via the
587
+ // /api/nft-metadata route, and shows the result. Bring your own mint() call.
588
+ "use client";
589
+ import { useState } from "react";
590
+
591
+ interface Metadata { name: string; description: string; image: string | null; attributes: unknown[]; lightchain_inference?: Record<string, string> }
592
+
593
+ export default function NftMint() {
594
+ const [name, setName] = useState("Cosmic Wanderer");
595
+ const [prompt, setPrompt] = useState("an astronaut surfing on the edge of a black hole");
596
+ const [meta, setMeta] = useState<Metadata | null>(null);
597
+ const [busy, setBusy] = useState(false);
598
+
599
+ return (
600
+ <main style={{ maxWidth: 640, margin: "40px auto", padding: 20, fontFamily: "system-ui" }}>
601
+ <h1>Mint an NFT with AI metadata</h1>
602
+ <p style={{ color: "#666", fontSize: 14 }}>
603
+ The description is generated by LightChain AI inference. The transaction hashes are returned in the
604
+ metadata as on-chain provenance you can pin alongside the JSON.
605
+ </p>
606
+ <label style={{ display: "block", marginTop: 16, fontSize: 13 }}>NFT name</label>
607
+ <input value={name} onChange={(e) => setName(e.target.value)} style={{ width: "100%", padding: 10, fontSize: 14 }} />
608
+ <label style={{ display: "block", marginTop: 12, fontSize: 13 }}>Concept</label>
609
+ <textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={2} style={{ width: "100%", padding: 10, fontSize: 14 }} />
610
+ <button
611
+ disabled={busy || !name || !prompt}
612
+ onClick={async () => {
613
+ setBusy(true); setMeta(null);
614
+ try {
615
+ const r = await fetch("/api/nft-metadata", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, prompt }) }).then((r) => r.json());
616
+ setMeta(r);
617
+ } finally { setBusy(false); }
618
+ }}
619
+ style={{ marginTop: 12, padding: "10px 20px", fontSize: 14 }}
620
+ >
621
+ {busy ? "Generating..." : "Generate metadata"}
622
+ </button>
623
+ {meta && (
624
+ <pre style={{ marginTop: 20, padding: 16, background: "#eee", whiteSpace: "pre-wrap", fontSize: 13 }}>
625
+ {JSON.stringify(meta, null, 2)}
626
+ </pre>
627
+ )}
628
+ {/* Wire your own mint(uri) call here. tokenURI = ipfs://<hash of meta> after pinning. */}
629
+ </main>
630
+ );
631
+ }
632
+ `;
633
+ const NODE_NFT_METADATA_SCRIPT = `// nft-metadata.ts
634
+ // Generated by 'lightnode add nft-mint-with-inference'.
635
+ // Use: tsx nft-metadata.ts "Cosmic Wanderer" "an astronaut surfing on the edge of a black hole"
636
+ //
637
+ // Calls LightChain AI inference directly, prints ERC-721-style metadata to stdout.
638
+ // Pipe to a file + pin to IPFS for the tokenURI in your mint contract.
639
+ import WS from "ws";
640
+ import { createPublicClient, createWalletClient, http, parseAbi, parseAbiItem, parseEther, type Log } from "viem";
641
+ import { privateKeyToAccount } from "viem/accounts";
642
+ import {
643
+ LightNode, prepareSession, submitPrompt, decryptResponse,
644
+ estimateJobFee, consumerGatewayUrl, JOB_REGISTRY_CONSUMER_ABI,
645
+ GatewayClient, type NetworkId,
646
+ } from "lightnode-sdk";
647
+
648
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
649
+ const MODEL = process.env.MODEL ?? "llama3-8b";
650
+ const [, , NAME, ...promptArgs] = process.argv;
651
+ const CONCEPT = promptArgs.join(" ").trim();
652
+ if (!NAME || !CONCEPT) { console.error('usage: tsx nft-metadata.ts "NFT Name" "concept"'); process.exit(1); }
653
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
654
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
655
+
656
+ const ln = new LightNode(NETWORK);
657
+ const cfg = ln.network;
658
+ const acct = privateKeyToAccount(PRIVATE_KEY);
659
+ const chain = { id: cfg.chainId, name: cfg.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [cfg.rpc] } } };
660
+ const pub = createPublicClient({ transport: http(cfg.rpc), chain });
661
+ const wal = createWalletClient({ account: acct, transport: http(cfg.rpc), chain });
662
+ const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
663
+
664
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
665
+ if (!ch.message) throw new Error("auth challenge failed");
666
+ const sig = await wal.signMessage({ message: ch.message });
667
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
668
+ if (!verify.token) throw new Error("auth verify failed");
669
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
670
+
671
+ const { sessionKey, createSessionArgs } = await prepareSession(gateway, MODEL);
672
+ const fee = await estimateJobFee(cfg, MODEL);
673
+ const createTx = await wal.writeContract({
674
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "createSession",
675
+ args: [createSessionArgs.paramsHash, createSessionArgs.worker, createSessionArgs.encWorkerKey, createSessionArgs.ephemeralPubKey, createSessionArgs.initState, createSessionArgs.expiry],
676
+ gas: 1_000_000n,
677
+ });
678
+ const createReceipt = await pub.waitForTransactionReceipt({ hash: createTx });
679
+ const sessionCreated = parseAbiItem("event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)");
680
+ const sessionLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: sessionCreated, blockHash: createReceipt.blockHash })).find((l) => l.transactionHash === createTx);
681
+ const sessionId = sessionLog?.args.sessionId;
682
+ if (!sessionId) throw new Error("SessionCreated missing");
683
+
684
+ let relayToken: string | undefined;
685
+ for (let i = 0; i < 30 && !relayToken; i++) {
686
+ const r = await gateway.getSessionToken(Number(sessionId));
687
+ if ("token" in r && r.token) relayToken = r.token; else await new Promise((res) => setTimeout(res, 1000));
688
+ }
689
+ if (!relayToken) throw new Error("relay token never became ready");
690
+ const ws = new WS(\`wss://relay.\${NETWORK}.lightchain.ai/ws?token=\${encodeURIComponent(relayToken)}\`);
691
+ const chunks: string[] = [];
692
+ await new Promise<void>((res, rej) => { ws.once("open", () => res()); ws.once("error", rej); });
693
+ ws.on("message", async (data: Buffer) => {
694
+ let f: { type?: string; payload?: string };
695
+ try { f = JSON.parse(data.toString("utf8")); } catch { return; }
696
+ if (!f.payload) return;
697
+ if (f.type === "chunk") { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
698
+ else if (f.type === "complete" && chunks.length === 0) { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
699
+ });
700
+
701
+ const PROMPT = \`Write a short, evocative 1-2 sentence description of an NFT titled "\${NAME}" with this concept: \${CONCEPT}\`;
702
+ const promptHash = await submitPrompt(gateway, sessionKey, PROMPT);
703
+ const submitTx = await wal.writeContract({
704
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "submitJob",
705
+ args: [sessionId, promptHash], value: parseEther(String(fee)), gas: 500_000n,
706
+ });
707
+ const submitReceipt = await pub.waitForTransactionReceipt({ hash: submitTx });
708
+ const jobSubmitted = parseAbiItem("event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)");
709
+ const jobLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobSubmitted, blockHash: submitReceipt.blockHash })).find((l) => l.transactionHash === submitTx);
710
+ const jobId = jobLog?.args.jobId;
711
+ if (!jobId) throw new Error("JobSubmitted missing");
712
+
713
+ const jobCompleted = parseAbiItem("event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)");
714
+ const deadline = Date.now() + 90_000;
715
+ let completed: Log | null = null;
716
+ while (!completed && Date.now() < deadline) {
717
+ await new Promise((res) => setTimeout(res, 3000));
718
+ const logs = await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobCompleted, args: { jobId }, fromBlock: submitReceipt.blockNumber });
719
+ if (logs.length) completed = logs[0] as Log;
720
+ }
721
+ if (!completed) { console.error("worker stalled - re-run for a different worker"); process.exit(1); }
722
+ await new Promise((res) => setTimeout(res, 4000));
723
+ ws.close();
724
+
725
+ const description = chunks.join("").trim();
726
+ const metadata = {
727
+ name: NAME,
728
+ description,
729
+ image: null,
730
+ attributes: [] as unknown[],
731
+ lightchain_inference: {
732
+ createSession: createTx,
733
+ submitJob: submitTx,
734
+ jobCompleted: completed.transactionHash,
735
+ sessionId: sessionId.toString(),
736
+ jobId: jobId.toString(),
737
+ worker: createSessionArgs.worker,
738
+ },
739
+ };
740
+ console.log(JSON.stringify(metadata, null, 2));
741
+ process.exit(0);
742
+ `;
743
+ // ---------------------------------------------------------------------------
744
+ // `lightnode add chat` - drop in a chat-style UI that uses the SDK's high-
745
+ // level runInference() helper. Keeps conversation history client-side and
746
+ // formats every prior turn into the next prompt so the model has context.
747
+ // ---------------------------------------------------------------------------
748
+ const NEXTJS_CHAT_PAGE = `// app/chat/page.tsx
749
+ // Generated by 'lightnode add chat'.
750
+ "use client";
751
+ import { useState } from "react";
752
+
753
+ type Turn = { role: "user" | "assistant"; text: string; txs?: Record<string, string> };
754
+
755
+ export default function Chat() {
756
+ const [turns, setTurns] = useState<Turn[]>([]);
757
+ const [draft, setDraft] = useState("");
758
+ const [busy, setBusy] = useState(false);
759
+
760
+ async function send() {
761
+ if (!draft.trim()) return;
762
+ const next: Turn[] = [...turns, { role: "user", text: draft.trim() }];
763
+ setTurns(next);
764
+ setDraft("");
765
+ setBusy(true);
766
+ try {
767
+ // Concatenate prior turns so the model has the conversation context.
768
+ const prompt =
769
+ next.map((t) => (t.role === "user" ? \`User: \${t.text}\` : \`Assistant: \${t.text}\`)).join("\\n") +
770
+ "\\nAssistant:";
771
+ const r = await fetch("/api/inference", {
772
+ method: "POST",
773
+ headers: { "Content-Type": "application/json" },
774
+ body: JSON.stringify({ prompt }),
775
+ }).then((r) => r.json()) as { answer?: string; txs?: Record<string, string>; error?: string };
776
+ if (!r.answer) throw new Error(r.error ?? "no answer");
777
+ setTurns([...next, { role: "assistant", text: r.answer, txs: r.txs }]);
778
+ } catch (e) {
779
+ setTurns([...next, { role: "assistant", text: \`(error: \${(e as Error).message})\` }]);
780
+ } finally {
781
+ setBusy(false);
782
+ }
783
+ }
784
+
785
+ return (
786
+ <main style={{ maxWidth: 760, margin: "30px auto", padding: 20, fontFamily: "system-ui, sans-serif" }}>
787
+ <h1>LightChain AI - chat</h1>
788
+ <p style={{ color: "#666", fontSize: 13 }}>
789
+ Each turn pays ~0.02 LCAI on mainnet (free on testnet). Conversation history is sent with each turn so the
790
+ model has context.
791
+ </p>
792
+ <div style={{ marginTop: 20, display: "flex", flexDirection: "column", gap: 10 }}>
793
+ {turns.map((t, i) => (
794
+ <div
795
+ key={i}
796
+ style={{
797
+ padding: 14,
798
+ borderRadius: 12,
799
+ background: t.role === "user" ? "#e8f0ff" : "#f4f4f4",
800
+ alignSelf: t.role === "user" ? "flex-end" : "flex-start",
801
+ maxWidth: "85%",
802
+ whiteSpace: "pre-wrap",
803
+ lineHeight: 1.5,
804
+ }}
805
+ >
806
+ <div style={{ fontSize: 11, color: "#888", marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.5 }}>
807
+ {t.role}
808
+ </div>
809
+ {t.text}
810
+ {t.txs && (
811
+ <div style={{ marginTop: 8, fontSize: 11, color: "#888", fontFamily: "monospace" }}>
812
+ jobCompleted: {t.txs.jobCompleted?.slice(0, 18)}…
813
+ </div>
814
+ )}
815
+ </div>
816
+ ))}
817
+ {busy && (
818
+ <div style={{ padding: 14, color: "#888", fontStyle: "italic" }}>
819
+ running encrypted inference…
820
+ </div>
821
+ )}
822
+ </div>
823
+ <div style={{ marginTop: 20, display: "flex", gap: 8 }}>
824
+ <input
825
+ value={draft}
826
+ onChange={(e) => setDraft(e.target.value)}
827
+ onKeyDown={(e) => e.key === "Enter" && !busy && send()}
828
+ placeholder="Type a message…"
829
+ style={{ flex: 1, padding: 12, fontSize: 14, borderRadius: 8, border: "1px solid #ccc" }}
830
+ />
831
+ <button
832
+ onClick={send}
833
+ disabled={busy || !draft.trim()}
834
+ style={{ padding: "10px 20px", fontSize: 14, borderRadius: 8 }}
835
+ >
836
+ Send
837
+ </button>
838
+ </div>
839
+ <p style={{ marginTop: 16, fontSize: 12, color: "#888" }}>
840
+ Make sure <code>/api/inference</code> is mounted - run <code>npx lightnode add inference</code> if you have
841
+ not. The route signs every call server-side with the PRIVATE_KEY in your .env.
842
+ </p>
843
+ </main>
844
+ );
845
+ }
846
+ `;
847
+ const NODE_CHAT_REPL = `// chat-repl.ts
848
+ // Generated by 'lightnode add chat'. Interactive chat REPL in your terminal.
849
+ // npm install lightnode-sdk viem ws
850
+ // tsx chat-repl.ts
851
+ import * as readline from "node:readline/promises";
852
+ import { stdin as input, stdout as output } from "node:process";
853
+ import WS from "ws";
854
+ import { createPublicClient, createWalletClient, http } from "viem";
855
+ import { privateKeyToAccount } from "viem/accounts";
856
+ import { LightNode, runInference, GatewayClient, consumerGatewayUrl, type NetworkId } from "lightnode-sdk";
857
+
858
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
859
+ const MODEL = process.env.MODEL ?? "llama3-8b";
860
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
861
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
862
+
863
+ const ln = new LightNode(NETWORK);
864
+ const acct = privateKeyToAccount(PRIVATE_KEY);
865
+ const chain = { id: ln.network.chainId, name: ln.network.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [ln.network.rpc] } } };
866
+ const pub = createPublicClient({ transport: http(ln.network.rpc), chain });
867
+ const wal = createWalletClient({ account: acct, transport: http(ln.network.rpc), chain });
868
+
869
+ // One SIWE handshake per process; the JWT is reused across all turns.
870
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
871
+ if (!ch.message) throw new Error("auth challenge failed");
872
+ const sig = await wal.signMessage({ message: ch.message });
873
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
874
+ if (!verify.token) throw new Error("auth verify failed");
875
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
876
+
877
+ const rl = readline.createInterface({ input, output });
878
+ const turns: { role: "user" | "assistant"; text: string }[] = [];
879
+ console.log(\`▶ chat on \${NETWORK}, model=\${MODEL}. Ctrl+C to exit.\\n\`);
880
+
881
+ while (true) {
882
+ const user = (await rl.question("> ")).trim();
883
+ if (!user) continue;
884
+ turns.push({ role: "user", text: user });
885
+ const prompt = turns.map((t) => (t.role === "user" ? \`User: \${t.text}\` : \`Assistant: \${t.text}\`)).join("\\n") + "\\nAssistant:";
886
+ try {
887
+ process.stdout.write(" ");
888
+ const { answer } = await runInference({
889
+ prompt, gateway, wallet: wal, publicClient: pub, network: ln.network,
890
+ model: MODEL, WebSocket: WS,
891
+ onChunk: (chunk) => process.stdout.write(chunk),
892
+ });
893
+ process.stdout.write("\\n\\n");
894
+ turns.push({ role: "assistant", text: answer });
895
+ } catch (e) {
896
+ console.log(\` (error: \${(e as Error).message})\`);
897
+ }
898
+ }
899
+ `;
900
+ export function addChat(opts = {}) {
901
+ const cwd = opts.cwd ?? process.cwd();
902
+ const network = opts.network ?? "testnet";
903
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
904
+ const force = !!opts.force;
905
+ const written = [];
906
+ if (template === "nextjs-api") {
907
+ written.push(writeFile(path.join(cwd, "app/chat/page.tsx"), NEXTJS_CHAT_PAGE, force));
908
+ }
909
+ else {
910
+ written.push(writeFile(path.join(cwd, "chat-repl.ts"), NODE_CHAT_REPL, force));
911
+ }
912
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
913
+ return { written, install: `npm install ${depsNeeded(template).join(" ")}`, template, network };
914
+ }
915
+ export function addNftMint(opts = {}) {
916
+ const cwd = opts.cwd ?? process.cwd();
917
+ const network = opts.network ?? "testnet";
918
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
919
+ const force = !!opts.force;
920
+ const written = [];
921
+ if (template === "nextjs-api") {
922
+ written.push(writeFile(path.join(cwd, "app/api/nft-metadata/route.ts"), NEXTJS_NFT_METADATA_ROUTE, force));
923
+ written.push(writeFile(path.join(cwd, "app/nft-mint/page.tsx"), NEXTJS_NFT_MINT_CLIENT, force));
924
+ }
925
+ else {
926
+ written.push(writeFile(path.join(cwd, "nft-metadata.ts"), NODE_NFT_METADATA_SCRIPT, force));
927
+ }
928
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
929
+ return {
930
+ written,
931
+ install: `npm install ${depsNeeded(template).join(" ")}`,
932
+ template,
933
+ network,
934
+ };
935
+ }