lightnode-sdk 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # lightnode-sdk
2
2
 
3
- Read-only TypeScript client for **LightChain AI**: workers, jobs, models, on-chain
4
- registration, and per-model network analytics. Pure reads from the public indexer and
5
- the chain. No keys, no writes, no native dependencies beyond `viem`.
3
+ TypeScript client for **LightChain AI**: read workers, jobs, models, on-chain
4
+ registration and per-model analytics, **and run encrypted inference end to end**
5
+ (prepare session, submit prompt, decrypt the streamed response). The SDK is
6
+ non-custodial - it never holds your private key; on-chain calls are signed by
7
+ your wallet via viem. Single peer dep: `viem`.
6
8
 
7
9
  > Independent, community-built. Not an official LightChain package.
10
+ > Live-verified end-to-end on both **mainnet** (chain 9200) and **testnet** (chain 8200)
11
+ > with real LCAI - example transactions in the "Submitting inference" section below.
8
12
 
9
13
  ## Install
10
14
 
@@ -37,6 +41,10 @@ const rollup = await ln.getNetworkAnalytics(); // overall completion / jobs / in
37
41
  // Inference cost
38
42
  const fee = await ln.estimateFee("llama3-8b"); // whole LCAI per job (on-chain calculateJobFee)
39
43
  const id = ln.modelId("llama3-8b"); // keccak256 model id
44
+
45
+ // CSV export (same exporters the LightNode dashboard uses)
46
+ import { workerJobsCsv, modelStatsCsv, workerStatsCsv } from "lightnode-sdk";
47
+ const csv = workerJobsCsv(await ln.getWorkerJobs("0x6781...6e0f", 100));
40
48
  ```
41
49
 
42
50
  ## API
@@ -57,34 +65,114 @@ const id = ln.modelId("llama3-8b"); // keccak256 model id
57
65
  | `modelId(tag)` | `0x${string}` |
58
66
 
59
67
  Also exported: `NETWORKS`, `WORKER_REGISTRY`, `REGISTRY_TOPICS`, `aggregateModelStats`,
60
- `aggregateWorkerStats`, `networkAnalytics`, `JOB_REGISTRY_CONSUMER_ABI`,
61
- `consumerGatewayUrl`, `fromWei`, and all the types.
68
+ `aggregateWorkerStats`, `networkAnalytics`, `modelStatsCsv`, `workerStatsCsv`,
69
+ `workerJobsCsv`, `JOB_REGISTRY_CONSUMER_ABI`, `consumerGatewayUrl`, `fromWei`, and all
70
+ the types.
62
71
 
63
72
  ## CLI
64
73
 
65
74
  ```bash
66
- npx lightnode network --net testnet # network summary
67
- npx lightnode models # registered models + fees
68
- npx lightnode worker 0x6781…6e0f # one worker (on-chain + recent jobs)
69
- npx lightnode registered 0x6781…6e0f # true | false | null
70
- npx lightnode fee llama3-8b # on-chain job fee
71
- npx lightnode analytics --csv # per-model performance (CSV)
72
- npx lightnode reliability # per-worker reliability
75
+ npx lightnode network --net testnet # network summary
76
+ npx lightnode models # registered models + fees
77
+ npx lightnode worker 0x6781…6e0f # one worker (on-chain + recent jobs)
78
+ npx lightnode jobs 0x6781…6e0f --csv # one worker's job history (table or CSV)
79
+ npx lightnode registered 0x6781…6e0f # true | false | null
80
+ npx lightnode fee llama3-8b # on-chain job fee
81
+ npx lightnode analytics --csv # per-model performance (CSV)
82
+ npx lightnode reliability --csv # per-worker reliability (CSV)
73
83
  ```
74
84
 
75
- ## Submitting inference (advanced)
85
+ ## Submitting inference
86
+
87
+ `v0.3+` ships the encrypted inference-submit flow end to end. Wire-compatible with
88
+ the reference client [`lcai-chat-v2`](https://github.com/lightchain-protocol/lcai-chat-v2)
89
+ (same ECDH-P256 + AES-256-GCM, same gateway endpoints, same `JobRegistry` calls).
90
+ **Live-verified** with real LCAI on both networks before this release:
91
+
92
+ | Network | Tx | Decrypted model output (excerpt) |
93
+ | --- | --- | --- |
94
+ | testnet (8200) | createSession `0x77686f3f…ef2bc587` · submitJob `0xba9d48c4…293b2bd96` | "Did you know that the deepest part of the ocean, the Mariana Trench, is so deep that if you were to drop Mount Everest into it, its peak would still be more than 1 mile underwater?!" |
95
+ | mainnet (9200) | createSession `0xf091957f…57d4a6ca` · submitJob `0x6ff44a4a…79846bb89` | "Did you know there is a type of jellyfish called the 'Upside-Down Jellyfish' that actually swims on its back, using its tentacles to catch prey and defend itself from predators?" |
96
+
97
+ The pieces that talk to the chain (`createSession` / `submitJob`) are signed by
98
+ **your** wallet via viem; the SDK only prepares the data, does the crypto, and
99
+ talks to the consumer gateway.
100
+
101
+ ### Auth (your responsibility)
102
+
103
+ The gateway requires a bearer JWT obtained via the consumer-api's SIWE sign-in.
104
+ The SDK does **not** bundle SIWE - hand the SDK either a fixed token or a
105
+ `() => Promise<string>` thunk that refreshes it on demand.
106
+
107
+ ### End-to-end (sketch)
108
+
109
+ ```ts
110
+ import {
111
+ LightNode,
112
+ prepareSession,
113
+ submitPrompt,
114
+ decryptResponse,
115
+ JOB_REGISTRY_CONSUMER_ABI,
116
+ } from "lightnode-sdk";
117
+ import { createWalletClient, http, parseAbi } from "viem";
118
+ import { privateKeyToAccount } from "viem/accounts";
119
+
120
+ const ln = new LightNode("testnet");
121
+ const gateway = ln.gateway({ bearer: () => mySiweJwt() });
122
+
123
+ // 1) Prepare the session: the gateway picks a worker, we wrap a fresh session
124
+ // key for the worker (and the disputer, if returned) and get the dispatcher
125
+ // signature authorising createSession.
126
+ const { sessionKey, createSessionArgs } = await prepareSession(gateway, "llama3-8b");
127
+
128
+ // 2) Call createSession ON-CHAIN with the prepared args. You sign with your
129
+ // wallet; the SDK ships the ABI but never custodies the key.
130
+ const wallet = createWalletClient({ account: privateKeyToAccount("0x..."), transport: http(ln.network.rpc) });
131
+ const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
132
+ const sessionTx = await wallet.writeContract({
133
+ address: ln.network.jobRegistry as `0x${string}`,
134
+ abi,
135
+ functionName: "createSession",
136
+ args: [
137
+ createSessionArgs.modelId,
138
+ createSessionArgs.worker,
139
+ createSessionArgs.encWorkerKey,
140
+ createSessionArgs.encDisputerKey,
141
+ createSessionArgs.dispatcherSignature,
142
+ createSessionArgs.expiry,
143
+ ],
144
+ });
145
+ // Wait for the receipt and pull the sessionId out of the SessionCreated event.
146
+
147
+ // 3) Encrypt + upload your prompt. Returns the EIP-4844 blob hash.
148
+ const blobHash = await submitPrompt(gateway, sessionKey, "write a haiku about LCAI");
149
+
150
+ // 4) Submit the job on-chain, paying the fee:
151
+ const feeLcai = await ln.estimateFee("llama3-8b");
152
+ await wallet.writeContract({
153
+ address: ln.network.jobRegistry as `0x${string}`,
154
+ abi,
155
+ functionName: "submitJob",
156
+ args: [sessionId, blobHash],
157
+ value: BigInt(Math.round(feeLcai * 1e18)),
158
+ });
159
+
160
+ // 5) Watch JobCompleted (or read the response blob via the relay), then decrypt:
161
+ const answer = await decryptResponse(sessionKey, responseCiphertextFromRelay);
162
+ ```
76
163
 
77
- `estimateFee` + `modelId` + the exported `JOB_REGISTRY_CONSUMER_ABI` give you the
78
- on-chain primitives. The full submit is a multi-step, encrypted flow and is **not
79
- bundled** here (it's a large, currently-undocumented protocol surface — shipping it
80
- half-tested would be worse than pointing you at the verified reference):
164
+ ### What's exported (v0.3)
81
165
 
82
- 1. `createSession(modelId, worker, encWorkerKey, encDisputerKey, dispatcherSig, expiry)` on the JobRegistry.
83
- 2. ECDH-P256 + AES-256-GCM encrypt the prompt with a session key; upload it as a blob to the consumer gateway (`consumerGatewayUrl(net)`); get the EIP-4844 `blobHash`.
84
- 3. `submitJob(sessionId, blobHash)` paying `estimateFee(model)` as native value.
85
- 4. Read the result from the relay stream, or watch the `JobCompleted` event / the job's `responseBlobHash`.
166
+ - `prepareSession(gateway, modelTag)` - select + wrap + prepare (steps 1+2).
167
+ - `submitPrompt(gateway, sessionKey, prompt)` - encrypt + upload (step 3).
168
+ - `decryptResponse(sessionKey, ciphertext)` - decrypt the worker's reply (step 5).
169
+ - `GatewayClient` + `consumerGatewayUrl(net)` - typed HTTP client.
170
+ - `crypto.*` - the wire-compatible primitives (`encrypt`, `decrypt`,
171
+ `encryptSessionKey`, `decryptSessionKey`, `generateEcdhKeyPair`,
172
+ `generateSessionKey`, hex/base64/utf8 helpers).
173
+ - `JOB_REGISTRY_CONSUMER_ABI` + `estimateJobFee` + `modelId` - on-chain primitives.
86
174
 
87
- Reference implementation: [lightchain-protocol/lcai-chat-v2](https://github.com/lightchain-protocol/lcai-chat-v2) (`lib/protocol/*`). A managed REST alternative (API-key) also exists at `https://chat2.lightchain.ai/api/v1`.
175
+ A managed REST alternative (API-key) also exists at `https://chat2.lightchain.ai/api/v1` for builders who'd rather skip running their own gateway/SIWE auth.
88
176
 
89
177
  ## Why `isRegistered` reads the chain
90
178
 
@@ -7,5 +7,11 @@ export declare function classifyJobs(jobs: Job[], nowSec: number): JobBuckets;
7
7
  export declare function aggregateModelStats(jobs: Job[], models: ModelInfo[], nowSec?: number): ModelStat[];
8
8
  /** Per-worker reliability, busiest first (top `limit`). */
9
9
  export declare function aggregateWorkerStats(jobs: Job[], nowSec?: number, limit?: number): WorkerStat[];
10
+ /** Per-model stats as CSV. */
11
+ export declare function modelStatsCsv(stats: ModelStat[]): string;
12
+ /** Per-worker reliability as CSV. */
13
+ export declare function workerStatsCsv(workers: WorkerStat[]): string;
14
+ /** One worker's job history as CSV (one row per job). */
15
+ export declare function workerJobsCsv(jobs: Job[]): string;
10
16
  /** Network-wide rollup across all models. */
11
17
  export declare function networkAnalytics(stats: ModelStat[]): NetworkAnalytics;
package/dist/analytics.js CHANGED
@@ -86,6 +86,59 @@ export function aggregateWorkerStats(jobs, nowSec = Math.floor(Date.now() / 1000
86
86
  .sort((a, b) => b.total - a.total)
87
87
  .slice(0, limit);
88
88
  }
89
+ // Shared outcome columns so the per-model and per-worker exports share one shape.
90
+ const STATS_COLUMNS = [
91
+ "jobs",
92
+ "success",
93
+ "incomplete",
94
+ "timed_out",
95
+ "stuck",
96
+ "disputed",
97
+ "in_flight",
98
+ "completion_rate_pct",
99
+ "p50_latency_s",
100
+ "p95_latency_s",
101
+ "earnings_lcai",
102
+ ];
103
+ function bucketsRow(s) {
104
+ return [
105
+ s.total,
106
+ s.success,
107
+ s.incomplete,
108
+ s.timedOut,
109
+ s.stuck,
110
+ s.disputed,
111
+ s.inFlight,
112
+ s.completionRate != null ? Math.round(s.completionRate * 100) : "",
113
+ s.p50 ?? "",
114
+ s.p95 ?? "",
115
+ s.earnings.toFixed(3),
116
+ ];
117
+ }
118
+ const toCsv = (rows) => rows.map((r) => r.join(",")).join("\n");
119
+ /** Per-model stats as CSV. */
120
+ export function modelStatsCsv(stats) {
121
+ return toCsv([["model", ...STATS_COLUMNS], ...stats.map((s) => [s.name, ...bucketsRow(s)])]);
122
+ }
123
+ /** Per-worker reliability as CSV. */
124
+ export function workerStatsCsv(workers) {
125
+ return toCsv([["worker", ...STATS_COLUMNS], ...workers.map((w) => [w.address, ...bucketsRow(w)])]);
126
+ }
127
+ /** One worker's job history as CSV (one row per job). */
128
+ export function workerJobsCsv(jobs) {
129
+ const head = ["job_id", "state", "model_id", "processing_s", "worker_share_lcai", "submitted_at", "ack_at", "completed_at"];
130
+ const rows = jobs.map((j) => [
131
+ j.id,
132
+ j.state,
133
+ j.model_id ?? "",
134
+ j.ack_at && j.completed_at && j.completed_at >= j.ack_at ? j.completed_at - j.ack_at : "",
135
+ fromWei(j.worker_share).toFixed(6),
136
+ j.submitted_at ?? "",
137
+ j.ack_at ?? "",
138
+ j.completed_at ?? "",
139
+ ]);
140
+ return toCsv([head, ...rows]);
141
+ }
89
142
  /** Network-wide rollup across all models. */
90
143
  export function networkAnalytics(stats) {
91
144
  const sum = (f) => stats.reduce((a, s) => a + f(s), 0);
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { LightNode } from "./index.js";
2
+ import { LightNode, modelStatsCsv, workerStatsCsv, workerJobsCsv } from "./index.js";
3
3
  function flag(name) {
4
4
  const i = process.argv.indexOf(name);
5
5
  return i >= 0 ? process.argv[i + 1] : undefined;
@@ -19,10 +19,11 @@ const HELP = `lightnode <command> [--net mainnet|testnet]
19
19
  network network summary (workers, jobs, models, earnings)
20
20
  models registered models + per-job fee
21
21
  worker <addr> a worker: on-chain registration + recent jobs
22
+ jobs <addr> [--csv] one worker's job history (table or CSV)
22
23
  registered <addr> true | false | null (read from chain events)
23
24
  fee [model] on-chain inference fee (default llama3-8b)
24
25
  analytics [--csv] per-model performance (completion, p50/p95, incomplete)
25
- reliability per-worker reliability, busiest first`;
26
+ reliability [--csv] per-worker reliability, busiest first`;
26
27
  async function main() {
27
28
  const ln = new LightNode(net);
28
29
  switch (cmd) {
@@ -42,6 +43,20 @@ async function main() {
42
43
  console.log(JSON.stringify({ onchainRegistered: registered, worker: w, recentJobs: jobs.map((j) => ({ id: j.id, state: j.state })) }, null, 2));
43
44
  break;
44
45
  }
46
+ case "jobs": {
47
+ const addr = positionals[1] ?? die("usage: lightnode jobs <address> [--csv] [--net testnet]");
48
+ const jobs = await ln.getWorkerJobs(addr, 100);
49
+ if (csv) {
50
+ console.log(workerJobsCsv(jobs));
51
+ }
52
+ else {
53
+ for (const j of jobs) {
54
+ const proc = j.ack_at && j.completed_at && j.completed_at >= j.ack_at ? `${j.completed_at - j.ack_at}s` : "-";
55
+ console.log(`#${j.id}\t${j.state}\t${proc}\t${lcai(j.worker_share)} LCAI`);
56
+ }
57
+ }
58
+ break;
59
+ }
45
60
  case "registered": {
46
61
  const addr = positionals[1] ?? die("usage: lightnode registered <address>");
47
62
  console.log(String(await ln.isRegistered(addr)));
@@ -55,10 +70,7 @@ async function main() {
55
70
  case "analytics": {
56
71
  const stats = await ln.getModelStats();
57
72
  if (csv) {
58
- console.log("model,jobs,completion_pct,p50_s,p95_s,incomplete,earnings_lcai");
59
- for (const s of stats) {
60
- console.log(`${s.name},${s.total},${s.completionRate != null ? Math.round(s.completionRate * 100) : ""},${s.p50 ?? ""},${s.p95 ?? ""},${s.incomplete},${s.earnings.toFixed(3)}`);
61
- }
73
+ console.log(modelStatsCsv(stats));
62
74
  }
63
75
  else {
64
76
  for (const s of stats)
@@ -67,8 +79,13 @@ async function main() {
67
79
  break;
68
80
  }
69
81
  case "reliability": {
70
- for (const w of await ln.getWorkerStats(1000, 20)) {
71
- console.log(`${w.address}\t${w.total}j\t${rate(w.completionRate)}\tp50 ${w.p50 ?? "-"}s\tinc ${w.incomplete}\t${w.earnings.toFixed(3)} LCAI`);
82
+ const workers = await ln.getWorkerStats(1000, 20);
83
+ if (csv) {
84
+ console.log(workerStatsCsv(workers));
85
+ }
86
+ else {
87
+ for (const w of workers)
88
+ console.log(`${w.address}\t${w.total}j\t${rate(w.completionRate)}\tp50 ${w.p50 ?? "-"}s\tinc ${w.incomplete}\t${w.earnings.toFixed(3)} LCAI`);
72
89
  }
73
90
  break;
74
91
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Wire-compatible ECDH P-256 + AES-256-GCM helpers for the LightChain AI
3
+ * inference protocol.
4
+ *
5
+ * The format here MUST match the workers' Go implementation byte for byte,
6
+ * which is what the `lcai-chat-v2` reference client also targets:
7
+ * - ECDH P-256 key exchange.
8
+ * - Raw shared secret used DIRECTLY as the AES-256 key (no HKDF).
9
+ * - AES-GCM ciphertext layout: nonce(12) || ciphertext || tag(16).
10
+ * - Encrypted session key layout: ephemeralPub(65) || nonce(12) || ciphertext || tag(16).
11
+ *
12
+ * Uses the Web Crypto API exposed via `globalThis.crypto.subtle`, available in
13
+ * Node 18+ and all modern browsers. No Node-only dependencies; the SDK runs
14
+ * unchanged in either environment.
15
+ */
16
+ /** A fresh 32-byte symmetric session key (random, never derived). */
17
+ export declare function generateSessionKey(): Uint8Array;
18
+ /** Fresh ECDH P-256 keypair (extractable; we need to export the public key on the wire). */
19
+ export declare function generateEcdhKeyPair(): Promise<CryptoKeyPair>;
20
+ /** Export an ECDH public key to its raw uncompressed P-256 encoding (65 bytes). */
21
+ export declare function exportPublicKey(key: CryptoKey): Promise<Uint8Array>;
22
+ /** Import a raw uncompressed P-256 public key (65 bytes) into a CryptoKey. */
23
+ export declare function importPublicKey(raw: Uint8Array): Promise<CryptoKey>;
24
+ /**
25
+ * Derive a 32-byte shared secret from a local ECDH private key and a remote
26
+ * public key. Returns the raw x-coordinate; deliberately no HKDF, matching the
27
+ * protocol's `priv.ECDH(remotePub)` output exactly.
28
+ */
29
+ export declare function deriveSharedSecret(privateKey: CryptoKey, remotePublicKey: CryptoKey): Promise<Uint8Array>;
30
+ /** Encrypt with AES-256-GCM. Output: nonce(12) || ciphertext || tag(16). */
31
+ export declare function encrypt(key: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array>;
32
+ /** Decrypt AES-256-GCM. Input: nonce(12) || ciphertext || tag(16). */
33
+ export declare function decrypt(key: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array>;
34
+ /**
35
+ * Wrap (encrypt) a 32-byte session key for delivery to a remote party (e.g. the
36
+ * worker), using a fresh ephemeral ECDH keypair.
37
+ *
38
+ * Output: ephemeralPub(65) || nonce(12) || AES-GCM(sharedSecret, sessionKey)
39
+ */
40
+ export declare function encryptSessionKey(sessionKey: Uint8Array, remotePublicKey: CryptoKey): Promise<Uint8Array>;
41
+ /** Unwrap a session key encrypted by `encryptSessionKey` using the local ECDH private key. */
42
+ export declare function decryptSessionKey(encWorkerKey: Uint8Array, privateKey: CryptoKey): Promise<Uint8Array>;
43
+ export declare function utf8ToBytes(s: string): Uint8Array;
44
+ export declare function bytesToUtf8(b: Uint8Array): string;
45
+ export declare function bytesToHex(b: Uint8Array): `0x${string}`;
46
+ export declare function hexToBytes(hex: string): Uint8Array;
47
+ export declare function bytesToBase64(b: Uint8Array): string;
48
+ export declare function base64ToBytes(b64: string): Uint8Array;
package/dist/crypto.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Wire-compatible ECDH P-256 + AES-256-GCM helpers for the LightChain AI
3
+ * inference protocol.
4
+ *
5
+ * The format here MUST match the workers' Go implementation byte for byte,
6
+ * which is what the `lcai-chat-v2` reference client also targets:
7
+ * - ECDH P-256 key exchange.
8
+ * - Raw shared secret used DIRECTLY as the AES-256 key (no HKDF).
9
+ * - AES-GCM ciphertext layout: nonce(12) || ciphertext || tag(16).
10
+ * - Encrypted session key layout: ephemeralPub(65) || nonce(12) || ciphertext || tag(16).
11
+ *
12
+ * Uses the Web Crypto API exposed via `globalThis.crypto.subtle`, available in
13
+ * Node 18+ and all modern browsers. No Node-only dependencies; the SDK runs
14
+ * unchanged in either environment.
15
+ */
16
+ const AES_KEY_BYTES = 32;
17
+ const GCM_NONCE_BYTES = 12;
18
+ const P256_UNCOMPRESSED_KEY_BYTES = 65;
19
+ const SESSION_KEY_BYTES = 32;
20
+ function subtle() {
21
+ const c = globalThis.crypto;
22
+ if (!c?.subtle)
23
+ throw new Error("Web Crypto unavailable - Node 18+ or a browser is required");
24
+ return c.subtle;
25
+ }
26
+ function randomBytes(n) {
27
+ const buf = new Uint8Array(n);
28
+ globalThis.crypto.getRandomValues(buf);
29
+ return buf;
30
+ }
31
+ /** A fresh 32-byte symmetric session key (random, never derived). */
32
+ export function generateSessionKey() {
33
+ return randomBytes(SESSION_KEY_BYTES);
34
+ }
35
+ /** Fresh ECDH P-256 keypair (extractable; we need to export the public key on the wire). */
36
+ export function generateEcdhKeyPair() {
37
+ return subtle().generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
38
+ }
39
+ /** Export an ECDH public key to its raw uncompressed P-256 encoding (65 bytes). */
40
+ export async function exportPublicKey(key) {
41
+ return new Uint8Array(await subtle().exportKey("raw", key));
42
+ }
43
+ /** Import a raw uncompressed P-256 public key (65 bytes) into a CryptoKey. */
44
+ export function importPublicKey(raw) {
45
+ return subtle().importKey("raw", raw, { name: "ECDH", namedCurve: "P-256" }, true, []);
46
+ }
47
+ /**
48
+ * Derive a 32-byte shared secret from a local ECDH private key and a remote
49
+ * public key. Returns the raw x-coordinate; deliberately no HKDF, matching the
50
+ * protocol's `priv.ECDH(remotePub)` output exactly.
51
+ */
52
+ export async function deriveSharedSecret(privateKey, remotePublicKey) {
53
+ return new Uint8Array(await subtle().deriveBits({ name: "ECDH", public: remotePublicKey }, privateKey, 256));
54
+ }
55
+ /** Encrypt with AES-256-GCM. Output: nonce(12) || ciphertext || tag(16). */
56
+ export async function encrypt(key, plaintext) {
57
+ if (key.length !== AES_KEY_BYTES)
58
+ throw new Error(`AES key must be ${AES_KEY_BYTES} bytes`);
59
+ const aesKey = await subtle().importKey("raw", key, "AES-GCM", false, ["encrypt"]);
60
+ const nonce = randomBytes(GCM_NONCE_BYTES);
61
+ const ctPlusTag = new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv: nonce }, aesKey, plaintext));
62
+ const out = new Uint8Array(GCM_NONCE_BYTES + ctPlusTag.byteLength);
63
+ out.set(nonce, 0);
64
+ out.set(ctPlusTag, GCM_NONCE_BYTES);
65
+ return out;
66
+ }
67
+ /** Decrypt AES-256-GCM. Input: nonce(12) || ciphertext || tag(16). */
68
+ export async function decrypt(key, ciphertext) {
69
+ if (key.length !== AES_KEY_BYTES)
70
+ throw new Error(`AES key must be ${AES_KEY_BYTES} bytes`);
71
+ if (ciphertext.length < GCM_NONCE_BYTES + 16)
72
+ throw new Error("ciphertext too short");
73
+ const aesKey = await subtle().importKey("raw", key, "AES-GCM", false, ["decrypt"]);
74
+ const nonce = ciphertext.slice(0, GCM_NONCE_BYTES);
75
+ const body = ciphertext.slice(GCM_NONCE_BYTES);
76
+ return new Uint8Array(await subtle().decrypt({ name: "AES-GCM", iv: nonce }, aesKey, body));
77
+ }
78
+ /**
79
+ * Wrap (encrypt) a 32-byte session key for delivery to a remote party (e.g. the
80
+ * worker), using a fresh ephemeral ECDH keypair.
81
+ *
82
+ * Output: ephemeralPub(65) || nonce(12) || AES-GCM(sharedSecret, sessionKey)
83
+ */
84
+ export async function encryptSessionKey(sessionKey, remotePublicKey) {
85
+ if (sessionKey.length !== SESSION_KEY_BYTES)
86
+ throw new Error(`session key must be ${SESSION_KEY_BYTES} bytes`);
87
+ const ephemeral = await generateEcdhKeyPair();
88
+ const shared = await deriveSharedSecret(ephemeral.privateKey, remotePublicKey);
89
+ const ct = await encrypt(shared, sessionKey);
90
+ const pub = await exportPublicKey(ephemeral.publicKey);
91
+ const out = new Uint8Array(pub.length + ct.length);
92
+ out.set(pub, 0);
93
+ out.set(ct, pub.length);
94
+ return out;
95
+ }
96
+ /** Unwrap a session key encrypted by `encryptSessionKey` using the local ECDH private key. */
97
+ export async function decryptSessionKey(encWorkerKey, privateKey) {
98
+ if (encWorkerKey.length < P256_UNCOMPRESSED_KEY_BYTES + GCM_NONCE_BYTES + 16) {
99
+ throw new Error("encrypted session key too short");
100
+ }
101
+ const ephemeralPub = await importPublicKey(encWorkerKey.slice(0, P256_UNCOMPRESSED_KEY_BYTES));
102
+ const ct = encWorkerKey.slice(P256_UNCOMPRESSED_KEY_BYTES);
103
+ const shared = await deriveSharedSecret(privateKey, ephemeralPub);
104
+ const sk = await decrypt(shared, ct);
105
+ if (sk.length !== SESSION_KEY_BYTES)
106
+ throw new Error(`session key must be ${SESSION_KEY_BYTES} bytes`);
107
+ return sk;
108
+ }
109
+ // -- text helpers (cross-runtime, no Node Buffer dependency) -----------------
110
+ export function utf8ToBytes(s) {
111
+ return new TextEncoder().encode(s);
112
+ }
113
+ export function bytesToUtf8(b) {
114
+ return new TextDecoder().decode(b);
115
+ }
116
+ const HEX = "0123456789abcdef";
117
+ export function bytesToHex(b) {
118
+ let out = "0x";
119
+ for (const v of b)
120
+ out += HEX[v >> 4] + HEX[v & 0xf];
121
+ return out;
122
+ }
123
+ export function hexToBytes(hex) {
124
+ const s = hex.startsWith("0x") ? hex.slice(2) : hex;
125
+ if (s.length % 2 !== 0)
126
+ throw new Error("hex must be even length");
127
+ const out = new Uint8Array(s.length / 2);
128
+ for (let i = 0; i < out.length; i++)
129
+ out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
130
+ return out;
131
+ }
132
+ // btoa/atob are globals in Node 18+ and all browsers. Narrow the lookup so
133
+ // strict TS doesn't trip on a missing-on-globalThis interface.
134
+ const g = globalThis;
135
+ export function bytesToBase64(b) {
136
+ // btoa accepts a binary string; build it from bytes (avoids Node Buffer requirement).
137
+ let s = "";
138
+ for (const v of b)
139
+ s += String.fromCharCode(v);
140
+ return g.btoa(s);
141
+ }
142
+ export function base64ToBytes(b64) {
143
+ const s = g.atob(b64);
144
+ const out = new Uint8Array(s.length);
145
+ for (let i = 0; i < s.length; i++)
146
+ out[i] = s.charCodeAt(i);
147
+ return out;
148
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * HTTP client for the LightChain consumer gateway (a.k.a. chat-api). Exposes
3
+ * just the endpoints a third-party consumer needs to submit an inference job
4
+ * and read its result back.
5
+ *
6
+ * Auth: the gateway requires a bearer JWT obtained via the consumer-api's SIWE
7
+ * sign-in flow. The SDK does NOT bundle SIWE; the caller obtains a token (or a
8
+ * fresh-each-call thunk) by whatever means they prefer and hands it here.
9
+ */
10
+ import type { NetworkConfig } from "./types.js";
11
+ export declare function consumerGatewayUrl(net: "mainnet" | "testnet"): string;
12
+ /** Either a fixed token, or a function that produces (or refreshes) one. */
13
+ export type BearerSource = string | (() => string | Promise<string>);
14
+ export declare class GatewayHttpError extends Error {
15
+ readonly status: number;
16
+ readonly body: string;
17
+ constructor(status: number, body: string);
18
+ }
19
+ export interface SelectSessionResult {
20
+ worker: `0x${string}`;
21
+ /**
22
+ * ECDH P-256 uncompressed public key of the selected worker. The gateway
23
+ * historically returns this as **base64** for the worker and **hex** for the
24
+ * disputer; the SDK's `decodePublicKey` accepts either, so callers do not need
25
+ * to branch.
26
+ */
27
+ workerEncryptionKey: string;
28
+ disputerEncryptionKey?: string;
29
+ nonce: number;
30
+ expiry: number;
31
+ }
32
+ export interface PrepareSessionResult {
33
+ worker: `0x${string}`;
34
+ /** Dispatcher EIP-712 signature authorising createSession on-chain. */
35
+ signature: `0x${string}`;
36
+ nonce: number;
37
+ expiry: number;
38
+ }
39
+ export interface UploadBlobResult {
40
+ blobHashes: `0x${string}`[];
41
+ }
42
+ export type SessionTokenResult = {
43
+ token: string;
44
+ expiresAt: string;
45
+ } | {
46
+ status: "pending";
47
+ message?: string;
48
+ };
49
+ export interface GatewayClientOptions {
50
+ /** Network ('mainnet' | 'testnet') OR a verified `NetworkConfig`. */
51
+ network: "mainnet" | "testnet" | NetworkConfig;
52
+ /** Override the gateway base URL (rarely needed; default is consumerGatewayUrl). */
53
+ baseUrl?: string;
54
+ /** Bearer token (or thunk) for authenticated calls. */
55
+ bearer?: BearerSource;
56
+ /** Fetch override (testing). */
57
+ fetch?: typeof fetch;
58
+ }
59
+ /**
60
+ * Thin HTTP client. Methods throw `GatewayHttpError` on non-2xx; protected
61
+ * methods throw if no `bearer` was configured.
62
+ */
63
+ export declare class GatewayClient {
64
+ readonly baseUrl: string;
65
+ private readonly bearer?;
66
+ private readonly fetchImpl;
67
+ constructor(opts: GatewayClientOptions);
68
+ /** Public: registered models the gateway will accept. */
69
+ getModels(): Promise<{
70
+ models: {
71
+ id: string;
72
+ name: string;
73
+ }[];
74
+ }>;
75
+ /** Protected: dispatcher picks a worker for a session and returns its pubkey. */
76
+ selectSession(modelId: `0x${string}`): Promise<SelectSessionResult>;
77
+ /**
78
+ * Protected: hand the dispatcher the encrypted session key it can give the
79
+ * worker, get back the EIP-712 signature authorising on-chain createSession.
80
+ *
81
+ * NOTE: the gateway expects `encWorkerKey` / `encDisputerKey` as **base64**
82
+ * (NOT hex). The same bytes are passed as **hex** to the on-chain
83
+ * `createSession`. The high-level `prepareSession(gateway, modelTag)` in
84
+ * `inference.ts` handles both encodings; if you call this lower-level method
85
+ * directly, base64-encode the wire bytes before passing them in.
86
+ */
87
+ prepareSession(input: {
88
+ modelId: `0x${string}`;
89
+ encWorkerKey: string;
90
+ encDisputerKey: string;
91
+ }): Promise<PrepareSessionResult>;
92
+ /** Protected: upload an encrypted prompt blob; returns the EIP-4844 blob hash. */
93
+ uploadBlob(base64Data: string): Promise<UploadBlobResult>;
94
+ /** Protected: fetch the relay JWT for an active session (202 = pending). */
95
+ getSessionToken(sessionId: number): Promise<SessionTokenResult>;
96
+ private req;
97
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * HTTP client for the LightChain consumer gateway (a.k.a. chat-api). Exposes
3
+ * just the endpoints a third-party consumer needs to submit an inference job
4
+ * and read its result back.
5
+ *
6
+ * Auth: the gateway requires a bearer JWT obtained via the consumer-api's SIWE
7
+ * sign-in flow. The SDK does NOT bundle SIWE; the caller obtains a token (or a
8
+ * fresh-each-call thunk) by whatever means they prefer and hands it here.
9
+ */
10
+ const GATEWAY_HOSTS = {
11
+ mainnet: "https://chat-api.mainnet.lightchain.ai",
12
+ testnet: "https://chat-api.testnet.lightchain.ai",
13
+ };
14
+ export function consumerGatewayUrl(net) {
15
+ return GATEWAY_HOSTS[net];
16
+ }
17
+ async function resolveBearer(src) {
18
+ return typeof src === "function" ? await src() : src;
19
+ }
20
+ export class GatewayHttpError extends Error {
21
+ constructor(status, body) {
22
+ super(`gateway ${status}: ${body.slice(0, 200)}`);
23
+ this.name = "GatewayHttpError";
24
+ this.status = status;
25
+ this.body = body;
26
+ }
27
+ }
28
+ /**
29
+ * Thin HTTP client. Methods throw `GatewayHttpError` on non-2xx; protected
30
+ * methods throw if no `bearer` was configured.
31
+ */
32
+ export class GatewayClient {
33
+ constructor(opts) {
34
+ const net = typeof opts.network === "string" ? opts.network : opts.network.id;
35
+ this.baseUrl = (opts.baseUrl ?? consumerGatewayUrl(net)).replace(/\/+$/, "");
36
+ this.bearer = opts.bearer;
37
+ this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
38
+ }
39
+ /** Public: registered models the gateway will accept. */
40
+ getModels() {
41
+ return this.req("GET", "/api/models");
42
+ }
43
+ /** Protected: dispatcher picks a worker for a session and returns its pubkey. */
44
+ selectSession(modelId) {
45
+ return this.req("POST", "/api/sessions/select", { modelId });
46
+ }
47
+ /**
48
+ * Protected: hand the dispatcher the encrypted session key it can give the
49
+ * worker, get back the EIP-712 signature authorising on-chain createSession.
50
+ *
51
+ * NOTE: the gateway expects `encWorkerKey` / `encDisputerKey` as **base64**
52
+ * (NOT hex). The same bytes are passed as **hex** to the on-chain
53
+ * `createSession`. The high-level `prepareSession(gateway, modelTag)` in
54
+ * `inference.ts` handles both encodings; if you call this lower-level method
55
+ * directly, base64-encode the wire bytes before passing them in.
56
+ */
57
+ prepareSession(input) {
58
+ return this.req("POST", "/api/sessions/prepare", input);
59
+ }
60
+ /** Protected: upload an encrypted prompt blob; returns the EIP-4844 blob hash. */
61
+ uploadBlob(base64Data) {
62
+ return this.req("POST", "/api/blobs", { data: base64Data });
63
+ }
64
+ /** Protected: fetch the relay JWT for an active session (202 = pending). */
65
+ getSessionToken(sessionId) {
66
+ return this.req("GET", `/api/sessions/${sessionId}/token`);
67
+ }
68
+ async req(method, path, body) {
69
+ const headers = { "Content-Type": "application/json" };
70
+ if (this.bearer != null)
71
+ headers["Authorization"] = `Bearer ${await resolveBearer(this.bearer)}`;
72
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
73
+ method,
74
+ headers,
75
+ body: body ? JSON.stringify(body) : undefined,
76
+ });
77
+ if (res.status === 202) {
78
+ // The session-token endpoint returns 202 while pending; surface as JSON.
79
+ return (await res.json());
80
+ }
81
+ if (!res.ok) {
82
+ const text = await res.text().catch(() => "");
83
+ throw new GatewayHttpError(res.status, text);
84
+ }
85
+ return (await res.json());
86
+ }
87
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS } from "./networks.js";
2
2
  import { fromWei } from "./subgraph.js";
3
- import { aggregateModelStats, aggregateWorkerStats, networkAnalytics } from "./analytics.js";
4
- import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl } from "./inference.js";
3
+ import { aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv } from "./analytics.js";
4
+ import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair } from "./inference.js";
5
+ import { GatewayClient, GatewayHttpError } from "./gateway.js";
6
+ import * as crypto from "./crypto.js";
5
7
  import type { NetworkId, NetworkConfig, Worker, Job, ModelInfo, NetworkStats, ModelStat, WorkerStat, NetworkAnalytics } from "./types.js";
6
8
  /**
7
9
  * Read-only client for a LightChain AI network. Pure reads from the public indexer
@@ -46,6 +48,18 @@ export declare class LightNode {
46
48
  modelId(tag: string): `0x${string}`;
47
49
  /** On-chain inference fee for a model, in whole LCAI (what submitJob must be paid). */
48
50
  estimateFee(modelTag: string): Promise<number>;
51
+ /**
52
+ * Configured `GatewayClient` for this network, ready to call the consumer-api
53
+ * endpoints (`prepareSession` / `uploadBlob` / `getSessionToken`). Pass a
54
+ * `bearer` (token or thunk) from your SIWE-authenticated session; the SDK
55
+ * does NOT bundle the SIWE handshake.
56
+ */
57
+ gateway(opts?: {
58
+ bearer?: import("./gateway.js").BearerSource;
59
+ baseUrl?: string;
60
+ }): GatewayClient;
49
61
  }
50
- export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, };
62
+ export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, };
63
+ export type { BearerSource, GatewayClientOptions, SelectSessionResult, PrepareSessionResult, UploadBlobResult, SessionTokenResult } from "./gateway.js";
64
+ export type { SessionPreparation } from "./inference.js";
51
65
  export type { NetworkId, NetworkConfig, Worker, Job, ModelInfo, NetworkStats, ModelStat, WorkerStat, NetworkAnalytics };
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS } from "./networks.js";
2
2
  import { fetchWorker, fetchWorkerJobs, fetchRecentJobs, fetchModels, fetchWorkers, summarize, fromWei, } from "./subgraph.js";
3
3
  import { isRegistered } from "./onchain.js";
4
- import { aggregateModelStats, aggregateWorkerStats, networkAnalytics } from "./analytics.js";
5
- import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl } from "./inference.js";
4
+ import { aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, } from "./analytics.js";
5
+ import { modelId as computeModelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, } from "./inference.js";
6
+ import { GatewayClient, GatewayHttpError } from "./gateway.js";
7
+ import * as crypto from "./crypto.js";
6
8
  /**
7
9
  * Read-only client for a LightChain AI network. Pure reads from the public indexer
8
10
  * and the chain; no keys, no writes. Independent, community-built.
@@ -77,5 +79,16 @@ export class LightNode {
77
79
  estimateFee(modelTag) {
78
80
  return estimateJobFee(this.network, modelTag);
79
81
  }
82
+ /**
83
+ * Configured `GatewayClient` for this network, ready to call the consumer-api
84
+ * endpoints (`prepareSession` / `uploadBlob` / `getSessionToken`). Pass a
85
+ * `bearer` (token or thunk) from your SIWE-authenticated session; the SDK
86
+ * does NOT bundle the SIWE handshake.
87
+ */
88
+ gateway(opts = {}) {
89
+ return new GatewayClient({ network: this.network, ...opts });
90
+ }
80
91
  }
81
- export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, };
92
+ export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl,
93
+ // v0.3 inference-submit surface (BETA - see README "Submitting inference").
94
+ GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, };
@@ -1,4 +1,6 @@
1
1
  import type { NetworkConfig } from "./types.js";
2
+ import { generateEcdhKeyPair } from "./crypto.js";
3
+ import type { GatewayClient } from "./gateway.js";
2
4
  /** modelId = keccak256(utf8(exact ollama tag)). Joins to the subgraph + contracts. */
3
5
  export declare function modelId(tag: string): `0x${string}`;
4
6
  /**
@@ -19,6 +21,76 @@ export declare function estimateJobFee(cfg: NetworkConfig, modelTag: string): Pr
19
21
  * (it's a large, protocol-specific, currently-undocumented surface). See the SDK
20
22
  * README "Submitting inference" for the verified end-to-end steps and a reference.
21
23
  */
22
- export declare const JOB_REGISTRY_CONSUMER_ABI: readonly ["function createSession(bytes32 modelId, address worker, bytes encWorkerKey, bytes encDisputerKey, bytes dispatcherSignature, uint256 expiry) payable returns (uint256 sessionId)", "function submitJob(uint256 sessionId, bytes32 blobHash) payable returns (uint256 jobId)", "event SessionCreated(uint256 sessionId, address user, bytes32 indexed modelId, address worker, bytes encWorkerKey, bytes encDisputerKey)", "event JobSubmitted(uint256 jobId, uint256 sessionId, address worker)", "event JobCompleted(uint256 jobId, address worker, bytes32 responseBlobHash, bytes32 responseCiphertextHash)"];
23
- /** Consumer gateway base URL for a network (SIWE-authenticated; submit blobs + relay). */
24
- export declare function consumerGatewayUrl(net: "mainnet" | "testnet"): string;
24
+ /**
25
+ * Canonical JobRegistry consumer ABI - parameter names mirror the verified
26
+ * mainnet contract (paramsHash / ephemeralPubKey / initState / promptHash) so
27
+ * decoders display sensible labels. The 4-byte selectors are
28
+ * createSession(bytes32,address,bytes,bytes,bytes,uint256) → 0xe80116b4
29
+ * submitJob(uint256,bytes32) → 0xe3f4f3e9
30
+ * createSession is payable but called with value=0; submitJob is payable and
31
+ * must be called with `estimateJobFee(model)` as native value.
32
+ */
33
+ export declare const JOB_REGISTRY_CONSUMER_ABI: readonly ["function createSession(bytes32 paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey, bytes initState, uint256 expiry) payable returns (uint256 sessionId)", "function submitJob(uint256 sessionId, bytes32 promptHash) payable returns (uint256 jobId)", "event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)", "event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)", "event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)"];
34
+ /**
35
+ * High-level orchestration for the encrypted inference submit flow.
36
+ *
37
+ * The full submit is multi-stage (gateway calls + crypto + an on-chain tx the
38
+ * caller signs with their wallet). These helpers chain the gateway calls and
39
+ * the crypto so the caller is left with two well-defined responsibilities:
40
+ *
41
+ * 1. Sign and broadcast `createSession(...)` on the JobRegistry using the
42
+ * `SessionPreparation.createSessionArgs` returned by `prepareSession`.
43
+ * 2. Sign and broadcast `submitJob(sessionId, blobHash)` paying
44
+ * `estimateJobFee(model)` as native value, using the `blobHash` returned
45
+ * by `submitPrompt`. The reply is decrypted with `decryptResponse`.
46
+ *
47
+ * Marked BETA: the on-chain calls are exercised; the gateway endpoints + wire
48
+ * crypto are wire-compatible with the reference client (lcai-chat-v2). Live
49
+ * end-to-end testing with a funded testnet wallet remains the caller's job.
50
+ */
51
+ export interface SessionPreparation {
52
+ /** 32-byte session key the caller persists to encrypt/decrypt subsequent jobs. */
53
+ sessionKey: Uint8Array;
54
+ /**
55
+ * Arguments to pass to JobRegistry.createSession(...), in slot order.
56
+ *
57
+ * Parameter names match the canonical on-chain ABI (paramsHash,
58
+ * ephemeralPubKey, initState) verified live in the LightChain inference
59
+ * integration guide. The slot mapping is:
60
+ * - paramsHash ← keccak256(model tag)
61
+ * - worker ← prepared.worker
62
+ * - encWorkerKey ← hex(encWorker) // ECDH-wrap for the worker
63
+ * - ephemeralPubKey ← hex(encDisputer) // ECDH-wrap for the disputer
64
+ * - initState ← prepared.signature // dispatcher EIP-712 signature
65
+ * - expiry ← prepared.expiry
66
+ */
67
+ createSessionArgs: {
68
+ paramsHash: `0x${string}`;
69
+ worker: `0x${string}`;
70
+ encWorkerKey: `0x${string}`;
71
+ ephemeralPubKey: `0x${string}`;
72
+ initState: `0x${string}`;
73
+ expiry: bigint;
74
+ };
75
+ nonce: number;
76
+ }
77
+ /**
78
+ * Step 1 + 2 of the protocol: ask the gateway which worker to use, generate a
79
+ * fresh session key, wrap it for the worker (and the disputer if one was
80
+ * returned), and get the dispatcher's signature authorising createSession.
81
+ *
82
+ * After this returns, the caller submits the on-chain `createSession` tx with
83
+ * `createSessionArgs` and remembers `sessionKey` for the rest of the session.
84
+ */
85
+ export declare function prepareSession(gateway: GatewayClient, modelTag: string): Promise<SessionPreparation>;
86
+ /**
87
+ * Encrypt a UTF-8 prompt with the session key, upload as a blob, and return
88
+ * the EIP-4844 blob hash to pass to `submitJob(sessionId, blobHash)`.
89
+ */
90
+ export declare function submitPrompt(gateway: GatewayClient, sessionKey: Uint8Array, prompt: string): Promise<`0x${string}`>;
91
+ /** Decrypt a worker response (raw bytes or base64 from the relay) with the session key. */
92
+ export declare function decryptResponse(sessionKey: Uint8Array, ciphertext: Uint8Array | string): Promise<string>;
93
+ /** Re-export so callers don't have to import from a second module just for the URL helper. */
94
+ export { consumerGatewayUrl, GatewayClient } from "./gateway.js";
95
+ /** Optional helper: generate the caller's own ECDH keypair if they want one (e.g. acting as the disputer). */
96
+ export { generateEcdhKeyPair };
package/dist/inference.js CHANGED
@@ -1,4 +1,18 @@
1
1
  import { keccak256, toBytes } from "viem";
2
+ import { generateSessionKey, generateEcdhKeyPair, importPublicKey, encryptSessionKey, encrypt, decrypt, hexToBytes, bytesToHex, bytesToBase64, base64ToBytes, utf8ToBytes, bytesToUtf8, } from "./crypto.js";
3
+ // The gateway returns the worker pubkey as base64 and the disputer pubkey as
4
+ // hex (per the verified integration guide). Both decode to 65-byte uncompressed
5
+ // P-256 points - sniff the format so the caller never has to branch.
6
+ function decodePublicKey(s) {
7
+ const stripped = s.startsWith("0x") ? s.slice(2) : s;
8
+ if (/^[0-9a-fA-F]{130}$/.test(stripped))
9
+ return hexToBytes(stripped);
10
+ const bytes = base64ToBytes(s);
11
+ if (bytes.length !== 65) {
12
+ throw new Error(`public key decoded to ${bytes.length} bytes; expected 65 (P-256 uncompressed)`);
13
+ }
14
+ return bytes;
15
+ }
2
16
  // AIConfig.calculateJobFee(bytes32) - verified live on both networks.
3
17
  const CALCULATE_JOB_FEE_SELECTOR = "0x33763d83";
4
18
  /** modelId = keccak256(utf8(exact ollama tag)). Joins to the subgraph + contracts. */
@@ -35,14 +49,80 @@ export async function estimateJobFee(cfg, modelTag) {
35
49
  * (it's a large, protocol-specific, currently-undocumented surface). See the SDK
36
50
  * README "Submitting inference" for the verified end-to-end steps and a reference.
37
51
  */
52
+ /**
53
+ * Canonical JobRegistry consumer ABI - parameter names mirror the verified
54
+ * mainnet contract (paramsHash / ephemeralPubKey / initState / promptHash) so
55
+ * decoders display sensible labels. The 4-byte selectors are
56
+ * createSession(bytes32,address,bytes,bytes,bytes,uint256) → 0xe80116b4
57
+ * submitJob(uint256,bytes32) → 0xe3f4f3e9
58
+ * createSession is payable but called with value=0; submitJob is payable and
59
+ * must be called with `estimateJobFee(model)` as native value.
60
+ */
38
61
  export const JOB_REGISTRY_CONSUMER_ABI = [
39
- "function createSession(bytes32 modelId, address worker, bytes encWorkerKey, bytes encDisputerKey, bytes dispatcherSignature, uint256 expiry) payable returns (uint256 sessionId)",
40
- "function submitJob(uint256 sessionId, bytes32 blobHash) payable returns (uint256 jobId)",
41
- "event SessionCreated(uint256 sessionId, address user, bytes32 indexed modelId, address worker, bytes encWorkerKey, bytes encDisputerKey)",
42
- "event JobSubmitted(uint256 jobId, uint256 sessionId, address worker)",
43
- "event JobCompleted(uint256 jobId, address worker, bytes32 responseBlobHash, bytes32 responseCiphertextHash)",
62
+ "function createSession(bytes32 paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey, bytes initState, uint256 expiry) payable returns (uint256 sessionId)",
63
+ "function submitJob(uint256 sessionId, bytes32 promptHash) payable returns (uint256 jobId)",
64
+ "event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)",
65
+ "event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)",
66
+ "event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)",
44
67
  ];
45
- /** Consumer gateway base URL for a network (SIWE-authenticated; submit blobs + relay). */
46
- export function consumerGatewayUrl(net) {
47
- return `https://chat-api.${net}.lightchain.ai`;
68
+ /**
69
+ * Step 1 + 2 of the protocol: ask the gateway which worker to use, generate a
70
+ * fresh session key, wrap it for the worker (and the disputer if one was
71
+ * returned), and get the dispatcher's signature authorising createSession.
72
+ *
73
+ * After this returns, the caller submits the on-chain `createSession` tx with
74
+ * `createSessionArgs` and remembers `sessionKey` for the rest of the session.
75
+ */
76
+ export async function prepareSession(gateway, modelTag) {
77
+ const id = modelId(modelTag);
78
+ const selected = await gateway.selectSession(id);
79
+ const sessionKey = generateSessionKey();
80
+ // Workers' pubkeys arrive as base64; disputer's as hex - decodePublicKey
81
+ // accepts either.
82
+ const workerPub = await importPublicKey(decodePublicKey(selected.workerEncryptionKey));
83
+ const encWorker = await encryptSessionKey(sessionKey, workerPub);
84
+ const encDisputer = selected.disputerEncryptionKey
85
+ ? await encryptSessionKey(sessionKey, await importPublicKey(decodePublicKey(selected.disputerEncryptionKey)))
86
+ : new Uint8Array(0);
87
+ // The gateway expects the wrapped keys as BASE64; the same bytes are passed
88
+ // as HEX to the on-chain createSession. Sending hex to the gateway makes the
89
+ // dispatcher reject the prepare with an opaque error.
90
+ const prepared = await gateway.prepareSession({
91
+ modelId: id,
92
+ encWorkerKey: bytesToBase64(encWorker),
93
+ encDisputerKey: bytesToBase64(encDisputer),
94
+ });
95
+ return {
96
+ sessionKey,
97
+ nonce: prepared.nonce,
98
+ createSessionArgs: {
99
+ paramsHash: id,
100
+ worker: prepared.worker,
101
+ encWorkerKey: bytesToHex(encWorker),
102
+ ephemeralPubKey: bytesToHex(encDisputer),
103
+ initState: prepared.signature,
104
+ expiry: BigInt(prepared.expiry),
105
+ },
106
+ };
107
+ }
108
+ /**
109
+ * Encrypt a UTF-8 prompt with the session key, upload as a blob, and return
110
+ * the EIP-4844 blob hash to pass to `submitJob(sessionId, blobHash)`.
111
+ */
112
+ export async function submitPrompt(gateway, sessionKey, prompt) {
113
+ const ct = await encrypt(sessionKey, utf8ToBytes(prompt));
114
+ const res = await gateway.uploadBlob(bytesToBase64(ct));
115
+ const first = res.blobHashes?.[0];
116
+ if (!first)
117
+ throw new Error("gateway returned no blob hashes");
118
+ return first;
119
+ }
120
+ /** Decrypt a worker response (raw bytes or base64 from the relay) with the session key. */
121
+ export async function decryptResponse(sessionKey, ciphertext) {
122
+ const bytes = typeof ciphertext === "string" ? base64ToBytes(ciphertext) : ciphertext;
123
+ return bytesToUtf8(await decrypt(sessionKey, bytes));
48
124
  }
125
+ /** Re-export so callers don't have to import from a second module just for the URL helper. */
126
+ export { consumerGatewayUrl, GatewayClient } from "./gateway.js";
127
+ /** Optional helper: generate the caller's own ECDH keypair if they want one (e.g. acting as the disputer). */
128
+ export { generateEcdhKeyPair };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightnode-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Read-only TypeScript client for LightChain AI: workers, jobs, models, on-chain registration, and per-model network analytics. Independent, community-built (not an official LightChain package).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",