lightnode-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KykyRykyPaloma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # lightnode-sdk
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`.
6
+
7
+ > Independent, community-built. Not an official LightChain package.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install lightnode-sdk viem
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { LightNode } from "lightnode-sdk";
19
+
20
+ const ln = new LightNode("mainnet"); // or "testnet"
21
+
22
+ // One worker
23
+ const worker = await ln.getWorker("0x6781...6e0f");
24
+ const jobs = await ln.getWorkerJobs("0x6781...6e0f", 20);
25
+ const earnings = await ln.getEarningsLcai("0x6781...6e0f"); // whole LCAI
26
+
27
+ // On-chain truth (independent of the indexer, which can lag a re-register)
28
+ const registered = await ln.isRegistered("0x6781...6e0f"); // true | false | null
29
+
30
+ // Network-wide
31
+ const stats = await ln.getNetworkStats(); // { total, active, jobsCompleted, totalEarnedLcai, models }
32
+ const models = await ln.getModels(); // [{ name, fee, max_output_tokens, ... }]
33
+ const perModel = await ln.getModelStats(); // completion rate, p50/p95 latency, incomplete, earnings
34
+ const perWorker = await ln.getWorkerStats(); // per-worker reliability, busiest first
35
+ const rollup = await ln.getNetworkAnalytics(); // overall completion / jobs / incomplete / earnings
36
+
37
+ // Inference cost
38
+ const fee = await ln.estimateFee("llama3-8b"); // whole LCAI per job (on-chain calculateJobFee)
39
+ const id = ln.modelId("llama3-8b"); // keccak256 model id
40
+ ```
41
+
42
+ ## API
43
+
44
+ | Method | Returns |
45
+ | --- | --- |
46
+ | `getWorker(address)` | `Worker \| null` |
47
+ | `getWorkerJobs(address, first?)` | `Job[]` |
48
+ | `getEarningsLcai(address)` | `number` |
49
+ | `isRegistered(address)` | `boolean \| null` (read from chain events) |
50
+ | `getModels()` | `ModelInfo[]` |
51
+ | `getWorkers(first?)` | `Worker[]` |
52
+ | `getNetworkStats()` | `NetworkStats` |
53
+ | `getModelStats(sample?)` | `ModelStat[]` |
54
+ | `getWorkerStats(sample?, limit?)` | `WorkerStat[]` (reliability) |
55
+ | `getNetworkAnalytics(sample?)` | `NetworkAnalytics` |
56
+ | `estimateFee(modelTag)` | `number` (LCAI per job) |
57
+ | `modelId(tag)` | `0x${string}` |
58
+
59
+ Also exported: `NETWORKS`, `WORKER_REGISTRY`, `REGISTRY_TOPICS`, `aggregateModelStats`,
60
+ `aggregateWorkerStats`, `networkAnalytics`, `JOB_REGISTRY_CONSUMER_ABI`,
61
+ `consumerGatewayUrl`, `fromWei`, and all the types.
62
+
63
+ ## CLI
64
+
65
+ ```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
73
+ ```
74
+
75
+ ## Submitting inference (advanced)
76
+
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):
81
+
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`.
86
+
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`.
88
+
89
+ ## Why `isRegistered` reads the chain
90
+
91
+ The public indexer can report a registered worker as `deregistered` after a
92
+ deregister -> re-register cycle. `isRegistered` instead reads the WorkerRegistry's
93
+ join/exit events directly and returns the latest, so it is correct for any worker.
94
+
95
+ ## Networks
96
+
97
+ | | mainnet | testnet |
98
+ | --- | --- | --- |
99
+ | chainId | 9200 | 8200 |
100
+ | min stake | 50,000 LCAI | 5,000 LCAI |
101
+ | WorkerRegistry | `0x…1002` (genesis predeploy) | same |
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,11 @@
1
+ import type { Job, ModelInfo, ModelStat, WorkerStat, JobBuckets, NetworkAnalytics } from "./types.js";
2
+ /** Nearest-rank percentile of an ascending-sorted array (null if empty). */
3
+ export declare function percentile(sortedAsc: number[], p: number): number | null;
4
+ /** Bucket a set of jobs into outcomes + latency percentiles + earnings. */
5
+ export declare function classifyJobs(jobs: Job[], nowSec: number): JobBuckets;
6
+ /** Per-model performance, busiest first. */
7
+ export declare function aggregateModelStats(jobs: Job[], models: ModelInfo[], nowSec?: number): ModelStat[];
8
+ /** Per-worker reliability, busiest first (top `limit`). */
9
+ export declare function aggregateWorkerStats(jobs: Job[], nowSec?: number, limit?: number): WorkerStat[];
10
+ /** Network-wide rollup across all models. */
11
+ export declare function networkAnalytics(stats: ModelStat[]): NetworkAnalytics;
@@ -0,0 +1,106 @@
1
+ import { fromWei } from "./subgraph.js";
2
+ const STUCK_SEC = 600;
3
+ const isSuccess = (s) => /complet|releas|resolv/i.test(s);
4
+ const isTimedOut = (s) => /timed?[ _-]*out|timeout/i.test(s);
5
+ const isDisputed = (s) => /disput/i.test(s);
6
+ const isAcked = (s) => /acknowled|ack/i.test(s);
7
+ const isSubmitted = (s) => /submit/i.test(s);
8
+ /** Nearest-rank percentile of an ascending-sorted array (null if empty). */
9
+ export function percentile(sortedAsc, p) {
10
+ if (sortedAsc.length === 0)
11
+ return null;
12
+ const rank = Math.ceil((p / 100) * sortedAsc.length);
13
+ return sortedAsc[Math.min(sortedAsc.length - 1, Math.max(0, rank - 1))];
14
+ }
15
+ /** Bucket a set of jobs into outcomes + latency percentiles + earnings. */
16
+ export function classifyJobs(jobs, nowSec) {
17
+ let success = 0;
18
+ let timedOut = 0;
19
+ let stuck = 0;
20
+ let disputed = 0;
21
+ let inFlight = 0;
22
+ let earnings = 0;
23
+ const latencies = [];
24
+ for (const j of jobs) {
25
+ const s = j.state || "";
26
+ if (isSuccess(s))
27
+ success++;
28
+ else if (isTimedOut(s))
29
+ timedOut++;
30
+ else if (isDisputed(s))
31
+ disputed++;
32
+ else if (isAcked(s)) {
33
+ if (j.ack_at && nowSec - j.ack_at > STUCK_SEC)
34
+ stuck++;
35
+ else
36
+ inFlight++;
37
+ }
38
+ else if (isSubmitted(s))
39
+ inFlight++;
40
+ earnings += fromWei(j.worker_share);
41
+ if (j.ack_at && j.completed_at && j.completed_at >= j.ack_at)
42
+ latencies.push(j.completed_at - j.ack_at);
43
+ }
44
+ const incomplete = timedOut + stuck;
45
+ const resolved = success + incomplete + disputed;
46
+ latencies.sort((a, b) => a - b);
47
+ return {
48
+ total: jobs.length,
49
+ success,
50
+ timedOut,
51
+ stuck,
52
+ disputed,
53
+ inFlight,
54
+ incomplete,
55
+ completionRate: resolved > 0 ? success / resolved : null,
56
+ p50: percentile(latencies, 50),
57
+ p95: percentile(latencies, 95),
58
+ earnings,
59
+ };
60
+ }
61
+ function groupBy(jobs, key) {
62
+ const m = new Map();
63
+ for (const j of jobs) {
64
+ const k = key(j);
65
+ if (!k)
66
+ continue;
67
+ const arr = m.get(k);
68
+ if (arr)
69
+ arr.push(j);
70
+ else
71
+ m.set(k, [j]);
72
+ }
73
+ return m;
74
+ }
75
+ /** Per-model performance, busiest first. */
76
+ export function aggregateModelStats(jobs, models, nowSec = Math.floor(Date.now() / 1000)) {
77
+ const nameById = new Map(models.map((m) => [m.id.toLowerCase(), m.name]));
78
+ return [...groupBy(jobs, (j) => j.model_id?.toLowerCase()).entries()]
79
+ .map(([id, js]) => ({ modelId: id, name: nameById.get(id) ?? `${id.slice(0, 10)}…`, ...classifyJobs(js, nowSec) }))
80
+ .sort((a, b) => b.total - a.total);
81
+ }
82
+ /** Per-worker reliability, busiest first (top `limit`). */
83
+ export function aggregateWorkerStats(jobs, nowSec = Math.floor(Date.now() / 1000), limit = 25) {
84
+ return [...groupBy(jobs, (j) => j.worker).entries()]
85
+ .map(([address, js]) => ({ address, ...classifyJobs(js, nowSec) }))
86
+ .sort((a, b) => b.total - a.total)
87
+ .slice(0, limit);
88
+ }
89
+ /** Network-wide rollup across all models. */
90
+ export function networkAnalytics(stats) {
91
+ const sum = (f) => stats.reduce((a, s) => a + f(s), 0);
92
+ const success = sum((s) => s.success);
93
+ const incomplete = sum((s) => s.incomplete);
94
+ const disputed = sum((s) => s.disputed);
95
+ const resolved = success + incomplete + disputed;
96
+ return {
97
+ models: stats.length,
98
+ jobs: sum((s) => s.total),
99
+ success,
100
+ incomplete,
101
+ disputed,
102
+ inFlight: sum((s) => s.inFlight),
103
+ completionRate: resolved > 0 ? success / resolved : null,
104
+ earnings: sum((s) => s.earnings),
105
+ };
106
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { LightNode } from "./index.js";
3
+ function flag(name) {
4
+ const i = process.argv.indexOf(name);
5
+ return i >= 0 ? process.argv[i + 1] : undefined;
6
+ }
7
+ const positionals = process.argv.slice(2).filter((a) => !a.startsWith("--"));
8
+ const cmd = positionals[0];
9
+ const net = flag("--net") || "mainnet";
10
+ const csv = process.argv.includes("--csv");
11
+ function die(msg) {
12
+ console.error(msg);
13
+ process.exit(1);
14
+ }
15
+ const lcai = (wei) => (wei ? Number(BigInt(wei)) / 1e18 : 0);
16
+ const rate = (r) => (r == null ? "-" : `${Math.round(r * 100)}%`);
17
+ const HELP = `lightnode <command> [--net mainnet|testnet]
18
+
19
+ network network summary (workers, jobs, models, earnings)
20
+ models registered models + per-job fee
21
+ worker <addr> a worker: on-chain registration + recent jobs
22
+ registered <addr> true | false | null (read from chain events)
23
+ fee [model] on-chain inference fee (default llama3-8b)
24
+ analytics [--csv] per-model performance (completion, p50/p95, incomplete)
25
+ reliability per-worker reliability, busiest first`;
26
+ async function main() {
27
+ const ln = new LightNode(net);
28
+ switch (cmd) {
29
+ case "network": {
30
+ console.log(JSON.stringify(await ln.getNetworkAnalytics(), null, 2));
31
+ break;
32
+ }
33
+ case "models": {
34
+ for (const m of await ln.getModels()) {
35
+ console.log(`${m.name}\t${lcai(m.fee)} LCAI\t${m.max_output_tokens} tok\t${m.is_whitelisted && m.is_enabled ? "live" : "off"}`);
36
+ }
37
+ break;
38
+ }
39
+ case "worker": {
40
+ const addr = positionals[1] ?? die("usage: lightnode worker <address> [--net testnet]");
41
+ const [w, registered, jobs] = await Promise.all([ln.getWorker(addr), ln.isRegistered(addr), ln.getWorkerJobs(addr, 5)]);
42
+ console.log(JSON.stringify({ onchainRegistered: registered, worker: w, recentJobs: jobs.map((j) => ({ id: j.id, state: j.state })) }, null, 2));
43
+ break;
44
+ }
45
+ case "registered": {
46
+ const addr = positionals[1] ?? die("usage: lightnode registered <address>");
47
+ console.log(String(await ln.isRegistered(addr)));
48
+ break;
49
+ }
50
+ case "fee": {
51
+ const model = positionals[1] ?? "llama3-8b";
52
+ console.log(`${await ln.estimateFee(model)} LCAI per job (${model})`);
53
+ break;
54
+ }
55
+ case "analytics": {
56
+ const stats = await ln.getModelStats();
57
+ 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
+ }
62
+ }
63
+ else {
64
+ for (const s of stats)
65
+ console.log(`${s.name}\t${s.total}j\t${rate(s.completionRate)}\tp50 ${s.p50 ?? "-"}s\tp95 ${s.p95 ?? "-"}s\tinc ${s.incomplete}\t${s.earnings.toFixed(3)} LCAI`);
66
+ }
67
+ break;
68
+ }
69
+ 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`);
72
+ }
73
+ break;
74
+ }
75
+ default:
76
+ console.log(HELP);
77
+ }
78
+ }
79
+ main().catch((e) => die(String(e?.message ?? e)));
@@ -0,0 +1,51 @@
1
+ import { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS } from "./networks.js";
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";
5
+ import type { NetworkId, NetworkConfig, Worker, Job, ModelInfo, NetworkStats, ModelStat, WorkerStat, NetworkAnalytics } from "./types.js";
6
+ /**
7
+ * Read-only client for a LightChain AI network. Pure reads from the public indexer
8
+ * and the chain; no keys, no writes. Independent, community-built.
9
+ *
10
+ * ```ts
11
+ * import { LightNode } from "lightnode-sdk";
12
+ * const ln = new LightNode("mainnet");
13
+ * const worker = await ln.getWorker("0x...");
14
+ * const registered = await ln.isRegistered("0x..."); // on-chain truth
15
+ * const perModel = await ln.getModelStats();
16
+ * ```
17
+ */
18
+ export declare class LightNode {
19
+ readonly network: NetworkConfig;
20
+ constructor(network?: NetworkId | NetworkConfig);
21
+ /** The full record for one worker (null if the indexer has never seen it). */
22
+ getWorker(address: string): Promise<Worker | null>;
23
+ /** Recent jobs for one worker, newest first. */
24
+ getWorkerJobs(address: string, first?: number): Promise<Job[]>;
25
+ /** The network's registered models (name, fee, output limit, whitelist flags). */
26
+ getModels(): Promise<ModelInfo[]>;
27
+ /** Registered workers (default top 200). */
28
+ getWorkers(first?: number): Promise<Worker[]>;
29
+ /** A one-shot summary: totals, active count, jobs completed, earnings, model count. */
30
+ getNetworkStats(): Promise<NetworkStats>;
31
+ /** Per-model performance over the last `sample` jobs (completion, p50/p95, incomplete, disputes, earnings). */
32
+ getModelStats(sample?: number): Promise<ModelStat[]>;
33
+ /** Network-wide rollup across all models over the last `sample` jobs. */
34
+ getNetworkAnalytics(sample?: number): Promise<NetworkAnalytics>;
35
+ /** Per-worker reliability (completion, p50/p95, incomplete) over the last `sample` jobs, busiest first. */
36
+ getWorkerStats(sample?: number, limit?: number): Promise<WorkerStat[]>;
37
+ /**
38
+ * Authoritative registration read straight from the chain's WorkerRegistry events
39
+ * (true/false), or null when the chain can't answer. Use this over getWorker().status
40
+ * when you need certainty: the indexer can lag a deregister -> re-register cycle.
41
+ */
42
+ isRegistered(address: string): Promise<boolean | null>;
43
+ /** Settled worker earnings in whole LCAI (from total_earned wei). */
44
+ getEarningsLcai(address: string): Promise<number>;
45
+ /** keccak256 of a model tag (its on-chain + indexer id). */
46
+ modelId(tag: string): `0x${string}`;
47
+ /** On-chain inference fee for a model, in whole LCAI (what submitJob must be paid). */
48
+ estimateFee(modelTag: string): Promise<number>;
49
+ }
50
+ export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, };
51
+ export type { NetworkId, NetworkConfig, Worker, Job, ModelInfo, NetworkStats, ModelStat, WorkerStat, NetworkAnalytics };
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ import { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS } from "./networks.js";
2
+ import { fetchWorker, fetchWorkerJobs, fetchRecentJobs, fetchModels, fetchWorkers, summarize, fromWei, } from "./subgraph.js";
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";
6
+ /**
7
+ * Read-only client for a LightChain AI network. Pure reads from the public indexer
8
+ * and the chain; no keys, no writes. Independent, community-built.
9
+ *
10
+ * ```ts
11
+ * import { LightNode } from "lightnode-sdk";
12
+ * const ln = new LightNode("mainnet");
13
+ * const worker = await ln.getWorker("0x...");
14
+ * const registered = await ln.isRegistered("0x..."); // on-chain truth
15
+ * const perModel = await ln.getModelStats();
16
+ * ```
17
+ */
18
+ export class LightNode {
19
+ constructor(network = "mainnet") {
20
+ this.network = typeof network === "string" ? NETWORKS[network] : network;
21
+ if (!this.network)
22
+ throw new Error(`unknown network: ${String(network)}`);
23
+ }
24
+ /** The full record for one worker (null if the indexer has never seen it). */
25
+ getWorker(address) {
26
+ return fetchWorker(this.network, address);
27
+ }
28
+ /** Recent jobs for one worker, newest first. */
29
+ getWorkerJobs(address, first = 20) {
30
+ return fetchWorkerJobs(this.network, address, first);
31
+ }
32
+ /** The network's registered models (name, fee, output limit, whitelist flags). */
33
+ getModels() {
34
+ return fetchModels(this.network);
35
+ }
36
+ /** Registered workers (default top 200). */
37
+ getWorkers(first = 200) {
38
+ return fetchWorkers(this.network, first);
39
+ }
40
+ /** A one-shot summary: totals, active count, jobs completed, earnings, model count. */
41
+ async getNetworkStats() {
42
+ const [workers, models] = await Promise.all([fetchWorkers(this.network), fetchModels(this.network)]);
43
+ return summarize(workers, models);
44
+ }
45
+ /** Per-model performance over the last `sample` jobs (completion, p50/p95, incomplete, disputes, earnings). */
46
+ async getModelStats(sample = 1000) {
47
+ const [jobs, models] = await Promise.all([fetchRecentJobs(this.network, sample), fetchModels(this.network)]);
48
+ return aggregateModelStats(jobs, models);
49
+ }
50
+ /** Network-wide rollup across all models over the last `sample` jobs. */
51
+ async getNetworkAnalytics(sample = 1000) {
52
+ return networkAnalytics(await this.getModelStats(sample));
53
+ }
54
+ /** Per-worker reliability (completion, p50/p95, incomplete) over the last `sample` jobs, busiest first. */
55
+ async getWorkerStats(sample = 1000, limit = 25) {
56
+ const jobs = await fetchRecentJobs(this.network, sample);
57
+ return aggregateWorkerStats(jobs, Math.floor(Date.now() / 1000), limit);
58
+ }
59
+ /**
60
+ * Authoritative registration read straight from the chain's WorkerRegistry events
61
+ * (true/false), or null when the chain can't answer. Use this over getWorker().status
62
+ * when you need certainty: the indexer can lag a deregister -> re-register cycle.
63
+ */
64
+ isRegistered(address) {
65
+ return isRegistered(this.network, address);
66
+ }
67
+ /** Settled worker earnings in whole LCAI (from total_earned wei). */
68
+ async getEarningsLcai(address) {
69
+ const w = await fetchWorker(this.network, address);
70
+ return w ? fromWei(w.total_earned) : 0;
71
+ }
72
+ /** keccak256 of a model tag (its on-chain + indexer id). */
73
+ modelId(tag) {
74
+ return computeModelId(tag);
75
+ }
76
+ /** On-chain inference fee for a model, in whole LCAI (what submitJob must be paid). */
77
+ estimateFee(modelTag) {
78
+ return estimateJobFee(this.network, modelTag);
79
+ }
80
+ }
81
+ export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, };
@@ -0,0 +1,24 @@
1
+ import type { NetworkConfig } from "./types.js";
2
+ /** modelId = keccak256(utf8(exact ollama tag)). Joins to the subgraph + contracts. */
3
+ export declare function modelId(tag: string): `0x${string}`;
4
+ /**
5
+ * On-chain inference fee for a model, in whole LCAI - read from
6
+ * AIConfig.calculateJobFee(modelId). This is what `submitJob` must be paid (native
7
+ * value), so a consumer can quote a price before submitting.
8
+ */
9
+ export declare function estimateJobFee(cfg: NetworkConfig, modelTag: string): Promise<number>;
10
+ /**
11
+ * The consumer-relevant JobRegistry surface (human-readable, viem-parseable). Use it
12
+ * to build the full submit flow: createSession -> submitJob(value: fee) -> watch
13
+ * JobCompleted / read the result blob.
14
+ *
15
+ * IMPORTANT: this ABI is reverse-engineered from the official client (lcai-chat-v2),
16
+ * verified by selector against the deployed bytecode, but NOT from published source.
17
+ * The full submit also requires an ECDH-P256 + AES-256-GCM handshake with the assigned
18
+ * worker and a blob upload to the consumer gateway - intentionally NOT bundled here
19
+ * (it's a large, protocol-specific, currently-undocumented surface). See the SDK
20
+ * README "Submitting inference" for the verified end-to-end steps and a reference.
21
+ */
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;
@@ -0,0 +1,48 @@
1
+ import { keccak256, toBytes } from "viem";
2
+ // AIConfig.calculateJobFee(bytes32) - verified live on both networks.
3
+ const CALCULATE_JOB_FEE_SELECTOR = "0x33763d83";
4
+ /** modelId = keccak256(utf8(exact ollama tag)). Joins to the subgraph + contracts. */
5
+ export function modelId(tag) {
6
+ return keccak256(toBytes(tag));
7
+ }
8
+ /**
9
+ * On-chain inference fee for a model, in whole LCAI - read from
10
+ * AIConfig.calculateJobFee(modelId). This is what `submitJob` must be paid (native
11
+ * value), so a consumer can quote a price before submitting.
12
+ */
13
+ export async function estimateJobFee(cfg, modelTag) {
14
+ const data = CALCULATE_JOB_FEE_SELECTOR + modelId(modelTag).slice(2);
15
+ const res = await fetch(cfg.rpc, {
16
+ method: "POST",
17
+ headers: { "content-type": "application/json" },
18
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: cfg.aiConfig, data }, "latest"] }),
19
+ });
20
+ const json = (await res.json());
21
+ if (json.error || !json.result || json.result === "0x") {
22
+ throw new Error(json.error?.message ?? "calculateJobFee returned no data");
23
+ }
24
+ return Number(BigInt(json.result)) / 1e18;
25
+ }
26
+ /**
27
+ * The consumer-relevant JobRegistry surface (human-readable, viem-parseable). Use it
28
+ * to build the full submit flow: createSession -> submitJob(value: fee) -> watch
29
+ * JobCompleted / read the result blob.
30
+ *
31
+ * IMPORTANT: this ABI is reverse-engineered from the official client (lcai-chat-v2),
32
+ * verified by selector against the deployed bytecode, but NOT from published source.
33
+ * The full submit also requires an ECDH-P256 + AES-256-GCM handshake with the assigned
34
+ * worker and a blob upload to the consumer gateway - intentionally NOT bundled here
35
+ * (it's a large, protocol-specific, currently-undocumented surface). See the SDK
36
+ * README "Submitting inference" for the verified end-to-end steps and a reference.
37
+ */
38
+ 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)",
44
+ ];
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`;
48
+ }
@@ -0,0 +1,19 @@
1
+ import type { NetworkConfig, NetworkId } from "./types.js";
2
+ /**
3
+ * LightChain AI network constants, verified against the live registries on both
4
+ * chains. AIConfig + JobRegistry are upgradeable proxies (stable addresses); the
5
+ * canonical source is WorkerRegistry.aiConfig() / .jobRegistry() if you ever need
6
+ * to re-resolve them.
7
+ */
8
+ export declare const NETWORKS: Record<NetworkId, NetworkConfig>;
9
+ /** WorkerRegistry genesis predeploy (same address on both networks). */
10
+ export declare const WORKER_REGISTRY = "0x0000000000000000000000000000000000001002";
11
+ /**
12
+ * WorkerRegistry event topics, derived empirically from the deployed predeploy
13
+ * (its source ABI differs from the deployed bytecode, so these are not computed
14
+ * from a signature). The latest of the two for a worker = its current state.
15
+ */
16
+ export declare const REGISTRY_TOPICS: {
17
+ readonly registered: "0x27987c0173113d0f969d0abbf00a8c583fd7f7f44c05af3739f808d2a0afba6f";
18
+ readonly exited: "0xde576c51e7828c269f7a259c68554d25364b596a7bd816f01d9b8cdb52e88d43";
19
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * LightChain AI network constants, verified against the live registries on both
3
+ * chains. AIConfig + JobRegistry are upgradeable proxies (stable addresses); the
4
+ * canonical source is WorkerRegistry.aiConfig() / .jobRegistry() if you ever need
5
+ * to re-resolve them.
6
+ */
7
+ export const NETWORKS = {
8
+ mainnet: {
9
+ id: "mainnet",
10
+ label: "Mainnet",
11
+ chainId: 9200,
12
+ rpc: "https://rpc.mainnet.lightchain.ai",
13
+ explorer: "https://mainnet.lightscan.app",
14
+ workerGateway: "https://worker-gateway.mainnet.lightchain.ai",
15
+ subgraph: "https://workers-api.mainnet.lightchain.ai/graphql",
16
+ workerRegistry: "0x0000000000000000000000000000000000001002",
17
+ aiConfig: "0x24D11533C354092ed6E18b964257819cE78Ce77D",
18
+ jobRegistry: "0xfB15F90298e4CcD7106E76fFB5e520315cC42B0b",
19
+ minStakeLcai: 50000,
20
+ },
21
+ testnet: {
22
+ id: "testnet",
23
+ label: "Testnet",
24
+ chainId: 8200,
25
+ rpc: "https://rpc.testnet.lightchain.ai",
26
+ explorer: "https://testnet.lightscan.app",
27
+ workerGateway: "https://worker-gateway.testnet.lightchain.ai",
28
+ subgraph: "https://workers-api.testnet.lightchain.ai/graphql",
29
+ workerRegistry: "0x0000000000000000000000000000000000001002",
30
+ aiConfig: "0xeCF4Ca5Ba6D97ae586993e170764a1E92231b67e",
31
+ jobRegistry: "0x531b3a87c5d785441b9cf55b98169f20fd9056a7",
32
+ minStakeLcai: 5000,
33
+ },
34
+ };
35
+ /** WorkerRegistry genesis predeploy (same address on both networks). */
36
+ export const WORKER_REGISTRY = "0x0000000000000000000000000000000000001002";
37
+ /**
38
+ * WorkerRegistry event topics, derived empirically from the deployed predeploy
39
+ * (its source ABI differs from the deployed bytecode, so these are not computed
40
+ * from a signature). The latest of the two for a worker = its current state.
41
+ */
42
+ export const REGISTRY_TOPICS = {
43
+ registered: "0x27987c0173113d0f969d0abbf00a8c583fd7f7f44c05af3739f808d2a0afba6f",
44
+ exited: "0xde576c51e7828c269f7a259c68554d25364b596a7bd816f01d9b8cdb52e88d43",
45
+ };
@@ -0,0 +1,8 @@
1
+ import type { NetworkConfig } from "./types.js";
2
+ /**
3
+ * Authoritative worker registration, read straight from the chain's WorkerRegistry
4
+ * events (works for ANY worker, independent of the public indexer, which can lag a
5
+ * deregister -> re-register cycle). Returns true/false from the latest join/exit
6
+ * event, or null when the chain can't answer (RPC error, or no events for it).
7
+ */
8
+ export declare function isRegistered(cfg: NetworkConfig, address: string): Promise<boolean | null>;
@@ -0,0 +1,57 @@
1
+ import { REGISTRY_TOPICS } from "./networks.js";
2
+ function addressTopic(address) {
3
+ return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
4
+ }
5
+ /**
6
+ * Authoritative worker registration, read straight from the chain's WorkerRegistry
7
+ * events (works for ANY worker, independent of the public indexer, which can lag a
8
+ * deregister -> re-register cycle). Returns true/false from the latest join/exit
9
+ * event, or null when the chain can't answer (RPC error, or no events for it).
10
+ */
11
+ export async function isRegistered(cfg, address) {
12
+ if (!/^0x[a-fA-F0-9]{40}$/.test(address))
13
+ return null;
14
+ const ctrl = new AbortController();
15
+ const timer = setTimeout(() => ctrl.abort(), 8000);
16
+ try {
17
+ const res = await fetch(cfg.rpc, {
18
+ method: "POST",
19
+ headers: { "content-type": "application/json" },
20
+ body: JSON.stringify({
21
+ jsonrpc: "2.0",
22
+ id: 1,
23
+ method: "eth_getLogs",
24
+ params: [
25
+ {
26
+ address: cfg.workerRegistry,
27
+ topics: [[REGISTRY_TOPICS.registered, REGISTRY_TOPICS.exited], addressTopic(address)],
28
+ fromBlock: "0x0",
29
+ toBlock: "latest",
30
+ },
31
+ ],
32
+ }),
33
+ signal: ctrl.signal,
34
+ });
35
+ if (!res.ok)
36
+ return null;
37
+ const json = (await res.json());
38
+ const logs = json.result;
39
+ if (!Array.isArray(logs) || logs.length === 0)
40
+ return null;
41
+ let latest = logs[0];
42
+ for (const lg of logs) {
43
+ const b = parseInt(lg.blockNumber, 16);
44
+ const i = parseInt(lg.logIndex, 16);
45
+ if (b > parseInt(latest.blockNumber, 16) || (b === parseInt(latest.blockNumber, 16) && i > parseInt(latest.logIndex, 16))) {
46
+ latest = lg;
47
+ }
48
+ }
49
+ return latest.topics?.[0]?.toLowerCase() === REGISTRY_TOPICS.registered;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
@@ -0,0 +1,10 @@
1
+ import type { NetworkConfig, Worker, Job, ModelInfo, NetworkStats } from "./types.js";
2
+ /** Convert a wei string to a number of whole tokens (18 decimals). */
3
+ export declare function fromWei(wei?: string): number;
4
+ export declare function fetchWorker(cfg: NetworkConfig, address: string): Promise<Worker | null>;
5
+ export declare function fetchWorkerJobs(cfg: NetworkConfig, address: string, first?: number): Promise<Job[]>;
6
+ /** Recent jobs across the whole network (not one worker), for analytics. */
7
+ export declare function fetchRecentJobs(cfg: NetworkConfig, first?: number): Promise<Job[]>;
8
+ export declare function fetchModels(cfg: NetworkConfig): Promise<ModelInfo[]>;
9
+ export declare function fetchWorkers(cfg: NetworkConfig, first?: number): Promise<Worker[]>;
10
+ export declare function summarize(workers: Worker[], models: ModelInfo[]): NetworkStats;
@@ -0,0 +1,84 @@
1
+ import { getAddress } from "viem";
2
+ const TIMEOUT_MS = 12000;
3
+ async function gql(url, query) {
4
+ const ctrl = new AbortController();
5
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
6
+ try {
7
+ const res = await fetch(url, {
8
+ method: "POST",
9
+ headers: { "content-type": "application/json" },
10
+ body: JSON.stringify({ query }),
11
+ signal: ctrl.signal,
12
+ });
13
+ if (!res.ok)
14
+ throw new Error(`subgraph ${res.status}`);
15
+ const json = (await res.json());
16
+ if (json.errors)
17
+ throw new Error(json.errors[0]?.message ?? "subgraph error");
18
+ return json.data;
19
+ }
20
+ catch (e) {
21
+ if (e.name === "AbortError")
22
+ throw new Error(`subgraph timeout after ${TIMEOUT_MS}ms`);
23
+ throw e;
24
+ }
25
+ finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
29
+ function checksum(addr) {
30
+ try {
31
+ return getAddress(addr);
32
+ }
33
+ catch {
34
+ return addr;
35
+ }
36
+ }
37
+ /** Convert a wei string to a number of whole tokens (18 decimals). */
38
+ export function fromWei(wei) {
39
+ if (!wei)
40
+ return 0;
41
+ try {
42
+ return Number(BigInt(wei)) / 1e18;
43
+ }
44
+ catch {
45
+ return 0;
46
+ }
47
+ }
48
+ export async function fetchWorker(cfg, address) {
49
+ try {
50
+ const data = await gql(cfg.subgraph, `{ worker(id:"${checksum(address)}") { id status stake active_job_count jobs_completed jobs_timed_out total_earned last_seen_at created_at } }`);
51
+ return data.worker ?? null;
52
+ }
53
+ catch (e) {
54
+ if (/not found/i.test(e.message))
55
+ return null; // unknown worker
56
+ throw e;
57
+ }
58
+ }
59
+ export async function fetchWorkerJobs(cfg, address, first = 20) {
60
+ const data = await gql(cfg.subgraph, `{ jobs(first:${first}, orderBy:submitted_at, orderDirection:desc, where:{worker:"${checksum(address)}"}) { id state model_id submitted_at ack_at completed_at worker_share } }`);
61
+ return data.jobs ?? [];
62
+ }
63
+ /** Recent jobs across the whole network (not one worker), for analytics. */
64
+ export async function fetchRecentJobs(cfg, first = 1000) {
65
+ const data = await gql(cfg.subgraph, `{ jobs(first:${first}, orderBy:submitted_at, orderDirection:desc) { id state model_id worker ack_at completed_at worker_share } }`);
66
+ return data.jobs ?? [];
67
+ }
68
+ export async function fetchModels(cfg) {
69
+ const data = await gql(cfg.subgraph, `{ modelinfos { id name fee max_output_tokens is_whitelisted is_enabled } }`);
70
+ return data.modelinfos ?? [];
71
+ }
72
+ export async function fetchWorkers(cfg, first = 200) {
73
+ const data = await gql(cfg.subgraph, `{ workers(first:${first}) { id status stake active_job_count jobs_completed jobs_timed_out total_earned last_seen_at created_at } }`);
74
+ return data.workers ?? [];
75
+ }
76
+ export function summarize(workers, models) {
77
+ return {
78
+ total: workers.length,
79
+ active: workers.filter((w) => w.status === "active").length,
80
+ jobsCompleted: workers.reduce((s, w) => s + (w.jobs_completed ?? 0), 0),
81
+ totalEarnedLcai: workers.reduce((s, w) => s + fromWei(w.total_earned), 0),
82
+ models: models.filter((m) => m.is_enabled && m.is_whitelisted).length,
83
+ };
84
+ }
@@ -0,0 +1,81 @@
1
+ export type NetworkId = "mainnet" | "testnet";
2
+ export interface NetworkConfig {
3
+ id: NetworkId;
4
+ label: string;
5
+ chainId: number;
6
+ rpc: string;
7
+ explorer: string;
8
+ workerGateway: string;
9
+ subgraph: string;
10
+ /** Genesis predeploy, same address on both networks. */
11
+ workerRegistry: string;
12
+ aiConfig: string;
13
+ jobRegistry: string;
14
+ minStakeLcai: number;
15
+ }
16
+ export interface Worker {
17
+ id: string;
18
+ status: string;
19
+ stake: string;
20
+ active_job_count?: number;
21
+ jobs_completed?: number;
22
+ jobs_timed_out?: number;
23
+ total_earned?: string;
24
+ last_seen_at?: number;
25
+ created_at?: number;
26
+ }
27
+ export interface Job {
28
+ id: string;
29
+ state: string;
30
+ model_id?: string;
31
+ worker?: string;
32
+ submitted_at?: number;
33
+ ack_at?: number;
34
+ completed_at?: number;
35
+ worker_share?: string;
36
+ }
37
+ export interface ModelInfo {
38
+ id: string;
39
+ name: string;
40
+ fee: string;
41
+ max_output_tokens: number;
42
+ is_whitelisted: boolean;
43
+ is_enabled: boolean;
44
+ }
45
+ export interface NetworkStats {
46
+ total: number;
47
+ active: number;
48
+ jobsCompleted: number;
49
+ totalEarnedLcai: number;
50
+ models: number;
51
+ }
52
+ export interface JobBuckets {
53
+ total: number;
54
+ success: number;
55
+ timedOut: number;
56
+ stuck: number;
57
+ disputed: number;
58
+ inFlight: number;
59
+ incomplete: number;
60
+ completionRate: number | null;
61
+ p50: number | null;
62
+ p95: number | null;
63
+ earnings: number;
64
+ }
65
+ export interface ModelStat extends JobBuckets {
66
+ modelId: string;
67
+ name: string;
68
+ }
69
+ export interface WorkerStat extends JobBuckets {
70
+ address: string;
71
+ }
72
+ export interface NetworkAnalytics {
73
+ models: number;
74
+ jobs: number;
75
+ success: number;
76
+ incomplete: number;
77
+ disputed: number;
78
+ inFlight: number;
79
+ completionRate: number | null;
80
+ earnings: number;
81
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "lightnode-sdk",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "lightnode": "dist/cli.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "typecheck": "tsc --noEmit -p tsconfig.json",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "lightchain",
30
+ "lightchain-ai",
31
+ "ai",
32
+ "worker",
33
+ "web3",
34
+ "evm",
35
+ "sdk"
36
+ ],
37
+ "author": "KykyRykyPaloma",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/marinom2/lightnode.git",
42
+ "directory": "sdk"
43
+ },
44
+ "homepage": "https://github.com/marinom2/lightnode/tree/main/sdk#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/marinom2/lightnode/issues"
47
+ },
48
+ "dependencies": {
49
+ "viem": ">=2"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ }
54
+ }