lightnode-sdk 0.1.0 → 0.3.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/README.md +98 -19
- package/dist/analytics.d.ts +6 -0
- package/dist/analytics.js +53 -0
- package/dist/cli.js +25 -8
- package/dist/crypto.d.ts +48 -0
- package/dist/crypto.js +148 -0
- package/dist/gateway.d.ts +97 -0
- package/dist/gateway.js +87 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +16 -3
- package/dist/inference.d.ts +75 -3
- package/dist/inference.js +88 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,10 @@ const rollup = await ln.getNetworkAnalytics(); // overall completion / jobs / in
|
|
|
37
37
|
// Inference cost
|
|
38
38
|
const fee = await ln.estimateFee("llama3-8b"); // whole LCAI per job (on-chain calculateJobFee)
|
|
39
39
|
const id = ln.modelId("llama3-8b"); // keccak256 model id
|
|
40
|
+
|
|
41
|
+
// CSV export (same exporters the LightNode dashboard uses)
|
|
42
|
+
import { workerJobsCsv, modelStatsCsv, workerStatsCsv } from "lightnode-sdk";
|
|
43
|
+
const csv = workerJobsCsv(await ln.getWorkerJobs("0x6781...6e0f", 100));
|
|
40
44
|
```
|
|
41
45
|
|
|
42
46
|
## API
|
|
@@ -57,34 +61,109 @@ const id = ln.modelId("llama3-8b"); // keccak256 model id
|
|
|
57
61
|
| `modelId(tag)` | `0x${string}` |
|
|
58
62
|
|
|
59
63
|
Also exported: `NETWORKS`, `WORKER_REGISTRY`, `REGISTRY_TOPICS`, `aggregateModelStats`,
|
|
60
|
-
`aggregateWorkerStats`, `networkAnalytics`, `
|
|
61
|
-
`consumerGatewayUrl`, `fromWei`, and all
|
|
64
|
+
`aggregateWorkerStats`, `networkAnalytics`, `modelStatsCsv`, `workerStatsCsv`,
|
|
65
|
+
`workerJobsCsv`, `JOB_REGISTRY_CONSUMER_ABI`, `consumerGatewayUrl`, `fromWei`, and all
|
|
66
|
+
the types.
|
|
62
67
|
|
|
63
68
|
## CLI
|
|
64
69
|
|
|
65
70
|
```bash
|
|
66
|
-
npx lightnode network --net testnet
|
|
67
|
-
npx lightnode models
|
|
68
|
-
npx lightnode worker 0x6781…6e0f
|
|
69
|
-
npx lightnode
|
|
70
|
-
npx lightnode
|
|
71
|
-
npx lightnode
|
|
72
|
-
npx lightnode
|
|
71
|
+
npx lightnode network --net testnet # network summary
|
|
72
|
+
npx lightnode models # registered models + fees
|
|
73
|
+
npx lightnode worker 0x6781…6e0f # one worker (on-chain + recent jobs)
|
|
74
|
+
npx lightnode jobs 0x6781…6e0f --csv # one worker's job history (table or CSV)
|
|
75
|
+
npx lightnode registered 0x6781…6e0f # true | false | null
|
|
76
|
+
npx lightnode fee llama3-8b # on-chain job fee
|
|
77
|
+
npx lightnode analytics --csv # per-model performance (CSV)
|
|
78
|
+
npx lightnode reliability --csv # per-worker reliability (CSV)
|
|
73
79
|
```
|
|
74
80
|
|
|
75
|
-
## Submitting inference (
|
|
81
|
+
## Submitting inference (BETA)
|
|
82
|
+
|
|
83
|
+
`v0.3` adds the encrypted inference-submit surface so you can drive a full
|
|
84
|
+
job end to end. It is wire-compatible with the reference client
|
|
85
|
+
[`lcai-chat-v2`](https://github.com/lightchain-protocol/lcai-chat-v2) (same
|
|
86
|
+
ECDH-P256 + AES-256-GCM scheme, same gateway endpoints) and the on-chain calls
|
|
87
|
+
are exercised, but **the full end-to-end flow needs live testing against a funded
|
|
88
|
+
testnet worker before you depend on it in production**. The pieces that talk to
|
|
89
|
+
the chain (createSession / submitJob) are signed by **your** wallet; the SDK only
|
|
90
|
+
prepares the data and the gateway calls.
|
|
91
|
+
|
|
92
|
+
### Auth (your responsibility)
|
|
93
|
+
|
|
94
|
+
The gateway requires a bearer JWT obtained via the consumer-api's SIWE sign-in.
|
|
95
|
+
The SDK does **not** bundle SIWE - hand the SDK either a fixed token or a
|
|
96
|
+
`() => Promise<string>` thunk that refreshes it on demand.
|
|
97
|
+
|
|
98
|
+
### End-to-end (sketch)
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import {
|
|
102
|
+
LightNode,
|
|
103
|
+
prepareSession,
|
|
104
|
+
submitPrompt,
|
|
105
|
+
decryptResponse,
|
|
106
|
+
JOB_REGISTRY_CONSUMER_ABI,
|
|
107
|
+
} from "lightnode-sdk";
|
|
108
|
+
import { createWalletClient, http, parseAbi } from "viem";
|
|
109
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
110
|
+
|
|
111
|
+
const ln = new LightNode("testnet");
|
|
112
|
+
const gateway = ln.gateway({ bearer: () => mySiweJwt() });
|
|
113
|
+
|
|
114
|
+
// 1) Prepare the session: the gateway picks a worker, we wrap a fresh session
|
|
115
|
+
// key for the worker (and the disputer, if returned) and get the dispatcher
|
|
116
|
+
// signature authorising createSession.
|
|
117
|
+
const { sessionKey, createSessionArgs } = await prepareSession(gateway, "llama3-8b");
|
|
118
|
+
|
|
119
|
+
// 2) Call createSession ON-CHAIN with the prepared args. You sign with your
|
|
120
|
+
// wallet; the SDK ships the ABI but never custodies the key.
|
|
121
|
+
const wallet = createWalletClient({ account: privateKeyToAccount("0x..."), transport: http(ln.network.rpc) });
|
|
122
|
+
const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
|
|
123
|
+
const sessionTx = await wallet.writeContract({
|
|
124
|
+
address: ln.network.jobRegistry as `0x${string}`,
|
|
125
|
+
abi,
|
|
126
|
+
functionName: "createSession",
|
|
127
|
+
args: [
|
|
128
|
+
createSessionArgs.modelId,
|
|
129
|
+
createSessionArgs.worker,
|
|
130
|
+
createSessionArgs.encWorkerKey,
|
|
131
|
+
createSessionArgs.encDisputerKey,
|
|
132
|
+
createSessionArgs.dispatcherSignature,
|
|
133
|
+
createSessionArgs.expiry,
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
// Wait for the receipt and pull the sessionId out of the SessionCreated event.
|
|
137
|
+
|
|
138
|
+
// 3) Encrypt + upload your prompt. Returns the EIP-4844 blob hash.
|
|
139
|
+
const blobHash = await submitPrompt(gateway, sessionKey, "write a haiku about LCAI");
|
|
140
|
+
|
|
141
|
+
// 4) Submit the job on-chain, paying the fee:
|
|
142
|
+
const feeLcai = await ln.estimateFee("llama3-8b");
|
|
143
|
+
await wallet.writeContract({
|
|
144
|
+
address: ln.network.jobRegistry as `0x${string}`,
|
|
145
|
+
abi,
|
|
146
|
+
functionName: "submitJob",
|
|
147
|
+
args: [sessionId, blobHash],
|
|
148
|
+
value: BigInt(Math.round(feeLcai * 1e18)),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// 5) Watch JobCompleted (or read the response blob via the relay), then decrypt:
|
|
152
|
+
const answer = await decryptResponse(sessionKey, responseCiphertextFromRelay);
|
|
153
|
+
```
|
|
76
154
|
|
|
77
|
-
|
|
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):
|
|
155
|
+
### What's exported (v0.3)
|
|
81
156
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
157
|
+
- `prepareSession(gateway, modelTag)` - select + wrap + prepare (steps 1+2).
|
|
158
|
+
- `submitPrompt(gateway, sessionKey, prompt)` - encrypt + upload (step 3).
|
|
159
|
+
- `decryptResponse(sessionKey, ciphertext)` - decrypt the worker's reply (step 5).
|
|
160
|
+
- `GatewayClient` + `consumerGatewayUrl(net)` - typed HTTP client.
|
|
161
|
+
- `crypto.*` - the wire-compatible primitives (`encrypt`, `decrypt`,
|
|
162
|
+
`encryptSessionKey`, `decryptSessionKey`, `generateEcdhKeyPair`,
|
|
163
|
+
`generateSessionKey`, hex/base64/utf8 helpers).
|
|
164
|
+
- `JOB_REGISTRY_CONSUMER_ABI` + `estimateJobFee` + `modelId` - on-chain primitives.
|
|
86
165
|
|
|
87
|
-
|
|
166
|
+
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
167
|
|
|
89
168
|
## Why `isRegistered` reads the chain
|
|
90
169
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
71
|
-
|
|
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
|
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/gateway.js
ADDED
|
@@ -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, };
|
package/dist/inference.d.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
40
|
-
"function submitJob(uint256 sessionId, bytes32
|
|
41
|
-
"event SessionCreated(uint256 sessionId, address user, bytes32 indexed
|
|
42
|
-
"event JobSubmitted(uint256 jobId, uint256 sessionId, address worker)",
|
|
43
|
-
"event JobCompleted(uint256 jobId, address worker, bytes32
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|