lightnode-sdk 0.6.1 → 0.7.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 CHANGED
@@ -16,6 +16,12 @@ npm install lightnode-sdk viem
16
16
  LightChain's own docs list official SDKs as "soon"; this fills the gap. Not
17
17
  affiliated with LightChain.
18
18
 
19
+ New to blockchain or Node.js? Read the
20
+ [Getting Started guide](../GETTING-STARTED.md) first. It covers wallets, testnet
21
+ vs mainnet, the `.env` file, and your first AI call in about 5 minutes. Then come
22
+ back here for the full reference. The rest of this README assumes you're
23
+ comfortable with TypeScript and a terminal.
24
+
19
25
  ## Five-line "hello world"
20
26
 
21
27
  ```ts
@@ -205,6 +211,73 @@ for await (const event of handle.events) {
205
211
  }
206
212
  ```
207
213
 
214
+ ### Worker operator (new in 0.7.0)
215
+
216
+ The **write/ops side** of running a worker - the on-chain actions that are
217
+ otherwise only reachable through the multi-GB worker Docker image, or by
218
+ reverse-engineering the unverified contracts. Pure RPC: run it from a laptop, a
219
+ server, or CI with no worker image at all. This complements (does not replace)
220
+ `workerPreflight`/`workerWatch` above.
221
+
222
+ Its flagship is **stuck-job recovery**. When a worker acknowledges a job but
223
+ never completes it (Ollama down, machine asleep), that job sits `Acknowledged`
224
+ forever and **blocks deregistration** - and no official tool clears it. The
225
+ JobRegistry's `claimTimeout` is permissionless, so the operator can self-clear
226
+ it. `unstickAndDeregister()` is the one-call rescue.
227
+
228
+ ```ts
229
+ import { WorkerOperator } from "lightnode-sdk";
230
+ import { createPublicClient, createWalletClient, http } from "viem";
231
+ import { privateKeyToAccount } from "viem/accounts";
232
+
233
+ const chain = { id: 8200, name: "LC Testnet",
234
+ nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 },
235
+ rpcUrls: { default: { http: ["https://rpc.testnet.lightchain.ai"] } } };
236
+ const publicClient = createPublicClient({ transport: http(chain.rpcUrls.default.http[0]), chain });
237
+ const walletClient = createWalletClient({ account: privateKeyToAccount(process.env.WORKER_KEY!), transport: http(chain.rpcUrls.default.http[0]), chain });
238
+
239
+ const op = new WorkerOperator("testnet", { publicClient, walletClient });
240
+
241
+ // Reads (no wallet needed): status, live protocol config, typed jobs.
242
+ await op.status(); // { registered, stakeLcai, claimableLcai, belowFloor, ... }
243
+ await op.config(); // live AIConfig: minStake, timeouts, slashBps, fee split
244
+ await op.getJob(974); // typed Job { state, worker, escrowedFeeWei, timestamps, ... }
245
+
246
+ // Pre-flight gating - know WHY before you spend gas. Pass the worker's job IDs
247
+ // (from LightNode.getWorkerJobs / the subgraph).
248
+ await op.canDeregister([974, 976, 978, 979]); // { ok, blockedBy: [974, 976], reason }
249
+
250
+ // Settlement + exit, Docker-free:
251
+ await op.releaseAll([978, 979]); // settle completed jobs past their dispute window
252
+ await op.withdraw(); // pull earned balance into the worker wallet
253
+
254
+ // The rescue: clear stuck acked jobs, then deregister + withdraw, in one call.
255
+ await op.unstickAndDeregister([974, 976, 978, 979]);
256
+ ```
257
+
258
+ > **Mainnet slashing.** `claimTimeout` / `clearStuck` / `unstickAndDeregister`
259
+ > finalize a stuck job as `TimedOut`, which **realizes the completion-timeout
260
+ > slash** on mainnet (`config().slashBps.completionTimeout`, 5% of stake per job
261
+ > at writing). Testnet has slashing disabled. It is the deliberate price of
262
+ > unblocking an exit a stuck job would otherwise block forever - only clear jobs
263
+ > you accept are lost.
264
+
265
+ **Decoded reverts.** The WorkerRegistry/JobRegistry custom errors aren't in the
266
+ 4byte directory; `decodeWorkerError(revertData)` turns them into a sentence + the
267
+ fix, and every write throws a `WorkerOpError` carrying the decoded cause:
268
+
269
+ | Error | Meaning |
270
+ |---|---|
271
+ | `ActiveJobsExist(worker, n)` | deregister blocked by `n` in-flight jobs - `clearStuck()` them first |
272
+ | `DisputeWindowNotElapsed(jobId, releaseAt, now)` | `releaseJob` too early - retry after the window |
273
+ | `InsufficientStake(requested, available)` | `withdrawStake` below the floor - `topUpStake()` + `reinstate()` |
274
+ | `WorkerNotRegistered(addr)` | not a registered worker |
275
+
276
+ Scope: this is the **operator** surface (register/stake/settle/recover/exit). It
277
+ does not serve jobs - that's the official Go worker daemon. Contracts are
278
+ unverified and may change; treat as 0.x and lean on `decodeWorkerError` to
279
+ surface drift.
280
+
208
281
  ### Batch runner (new in 0.6.0)
209
282
 
210
283
  Fan out many prompts as parallel encrypted inferences. Capped concurrency, stable result order, per-slot errors so one stalled worker does not kill the batch.
package/dist/add.js CHANGED
@@ -341,7 +341,9 @@ function depsNeeded(template) {
341
341
  return ["lightnode-sdk", "viem", "ws"];
342
342
  if (template === "hono")
343
343
  return ["lightnode-sdk", "viem", "ws"];
344
- return ["lightnode-sdk", "viem", "ws"];
344
+ // The node/script template is run with `npx tsx <file>.ts`, so tsx must be
345
+ // installed too, otherwise `tsx ...` fails with "command not found".
346
+ return ["lightnode-sdk", "viem", "ws", "tsx"];
345
347
  }
346
348
  /**
347
349
  * Implementation called by `lightnode add inference [...]`.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { LightNode, modelStatsCsv, workerStatsCsv, workerJobsCsv, runInferenceWithKey, runInferenceBatch, Agent, isStalledWorker, workerPreflight, workerWatch, BRIDGE_ROUTE, DAO, DAO_ADDRESSES } from "./index.js";
2
+ import { LightNode, modelStatsCsv, workerStatsCsv, workerJobsCsv, runInferenceWithKey, runInferenceBatch, Agent, isStalledWorker, workerPreflight, workerWatch, WorkerOperator, isWorkerOpError, BRIDGE_ROUTE, DAO, DAO_ADDRESSES } from "./index.js";
3
3
  import { addInference, addAnalyticsDashboard, addNftMint, addChat, addAgent } from "./add.js";
4
- import { createPublicClient, http, parseEther } from "viem";
4
+ import { createPublicClient, createWalletClient, http, parseEther } from "viem";
5
5
  import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
6
6
  function flag(name) {
7
7
  const i = process.argv.indexOf(name);
@@ -45,9 +45,16 @@ Read-only network commands (no key):
45
45
  analytics [--csv] per-model performance (completion, p50/p95, incomplete)
46
46
  reliability [--csv] per-worker reliability, busiest first
47
47
 
48
- Preflight (needs PRIVATE_KEY in env):
48
+ Worker operator (needs PRIVATE_KEY in env; signs as the worker key):
49
49
  worker preflight run one real test inference, print verdict + timings
50
50
  ([--key 0x...] [--model llama3-8b] [--deadline 60])
51
+ worker status [addr] registration, stake, claimable, live protocol config
52
+ worker can-deregister check what blocks the exit (in-flight jobs), no spend
53
+ worker settle release completed jobs past their window + withdraw
54
+ worker clearstuck claimTimeout acked, past-deadline jobs (unblocks exit)
55
+ (mainnet realizes a per-job slash; needs --yes)
56
+ worker withdraw pull the earned balance into the worker wallet
57
+ worker deregister clear stuck + settle + withdraw + deregister (mainnet: --yes)
51
58
 
52
59
  Ecosystem (read-only):
53
60
  bridge addresses print bridge route (Ethereum <-> LightChain) addresses
@@ -70,6 +77,40 @@ function pickKey() {
70
77
  }
71
78
  return k;
72
79
  }
80
+ const viemChain = (n) => ({
81
+ id: n.network.chainId,
82
+ name: n.network.label,
83
+ nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 },
84
+ rpcUrls: { default: { http: [n.network.rpc] } },
85
+ });
86
+ /** A read-only WorkerOperator for the given worker address (no key). */
87
+ function readOperator(n, address) {
88
+ const chain = viemChain(n);
89
+ const publicClient = createPublicClient({ transport: http(n.network.rpc), chain });
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ return new WorkerOperator(n.network, { publicClient: publicClient, workerAddress: address });
92
+ }
93
+ /** A write-capable WorkerOperator signed by PRIVATE_KEY / --key. The viem clients
94
+ * are cast to the SDK's structural Minimal* types (same boundary cast the
95
+ * inference module uses) - viem's strict writeContract union does not accept the
96
+ * intentionally-loose Minimal shape directly. */
97
+ function writeOperator(n) {
98
+ const chain = viemChain(n);
99
+ const account = privateKeyToAccount(pickKey());
100
+ const publicClient = createPublicClient({ transport: http(n.network.rpc), chain });
101
+ const walletClient = createWalletClient({ account, transport: http(n.network.rpc), chain });
102
+ return new WorkerOperator(n.network, {
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ publicClient: publicClient,
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ walletClient: walletClient,
107
+ });
108
+ }
109
+ /** The worker's job IDs from the indexer, used to drive on-chain settle/clear. */
110
+ async function workerJobIds(n, address) {
111
+ const jobs = await n.getWorkerJobs(address, 100);
112
+ return jobs.map((j) => Number(j.id)).filter((x) => Number.isFinite(x));
113
+ }
73
114
  async function main() {
74
115
  const ln = new LightNode(net);
75
116
  switch (cmd) {
@@ -202,8 +243,78 @@ async function main() {
202
243
  process.exit(1);
203
244
  break;
204
245
  }
246
+ // Operator subcommands (the write/ops side). status is read-only; the rest
247
+ // sign with PRIVATE_KEY / --key and act on the worker that key controls.
248
+ if (sub === "status") {
249
+ const addr = positionals[2] ?? (flag("--key") || process.env.PRIVATE_KEY ? privateKeyToAccount(pickKey()).address : die("usage: lightnode worker status <address> (or set PRIVATE_KEY)"));
250
+ const op = readOperator(ln, addr);
251
+ const [st, cfg] = await Promise.all([op.status(), op.config()]);
252
+ console.log(JSON.stringify({ ...st, stakeWei: st.stakeWei.toString(), minStakeWei: st.minStakeWei.toString(), claimableWei: st.claimableWei.toString(), config: { minStakeLcai: cfg.minStakeLcai, completionTimeoutSec: cfg.completionTimeoutSec, slashBps: cfg.slashBps } }, null, 2));
253
+ break;
254
+ }
255
+ if (sub === "can-deregister") {
256
+ const op = writeOperator(ln);
257
+ const ids = await workerJobIds(ln, privateKeyToAccount(pickKey()).address);
258
+ const r = await op.canDeregister(ids);
259
+ console.log(JSON.stringify({ ok: r.ok, blockedBy: r.blockedBy.map(String), reason: r.reason }, null, 2));
260
+ break;
261
+ }
262
+ if (sub === "settle") {
263
+ const op = writeOperator(ln);
264
+ const addr = privateKeyToAccount(pickKey()).address;
265
+ const ids = await workerJobIds(ln, addr);
266
+ console.error(`> releasing completed jobs on ${net}...`);
267
+ const rel = await op.releaseAll(ids);
268
+ let withdrawTx;
269
+ if ((await op.status()).claimableWei > 0n)
270
+ withdrawTx = await op.withdraw();
271
+ console.log(JSON.stringify({ released: rel.released.map((r) => ({ jobId: r.jobId.toString(), tx: r.tx })), notReady: rel.notReady.map(String), withdrawTx: withdrawTx ?? null }, null, 2));
272
+ break;
273
+ }
274
+ if (sub === "clearstuck") {
275
+ const op = writeOperator(ln);
276
+ const addr = privateKeyToAccount(pickKey()).address;
277
+ const ids = await workerJobIds(ln, addr);
278
+ if (net === "mainnet" && !process.argv.includes("--yes")) {
279
+ const cfg = await op.config();
280
+ die(`clearstuck finalizes stuck jobs as TimedOut, realizing a ${cfg.slashBps.completionTimeout / 100}% slash per job on mainnet. Re-run with --yes to confirm.`);
281
+ }
282
+ console.error(`> clearing stuck (acknowledged, past-deadline) jobs on ${net}...`);
283
+ const r = await op.clearStuck(ids);
284
+ console.log(JSON.stringify({ cleared: r.cleared.map((c) => ({ jobId: c.jobId.toString(), tx: c.tx })), skipped: r.skipped.map(String) }, null, 2));
285
+ break;
286
+ }
287
+ if (sub === "withdraw") {
288
+ const op = writeOperator(ln);
289
+ const before = (await op.status()).claimableLcai;
290
+ if (before <= 0) {
291
+ console.log(JSON.stringify({ withdrawn: 0, note: "no claimable balance in the JobRegistry" }, null, 2));
292
+ break;
293
+ }
294
+ const tx = await op.withdraw();
295
+ console.log(JSON.stringify({ withdrawnLcai: before, tx }, null, 2));
296
+ break;
297
+ }
298
+ if (sub === "deregister") {
299
+ const op = writeOperator(ln);
300
+ const addr = privateKeyToAccount(pickKey()).address;
301
+ const ids = await workerJobIds(ln, addr);
302
+ if (net === "mainnet" && !process.argv.includes("--yes")) {
303
+ die("deregister on mainnet may require clearing stuck jobs first (which realizes a slash). Run 'worker can-deregister' to check, then re-run with --yes.");
304
+ }
305
+ try {
306
+ const r = await op.unstickAndDeregister(ids);
307
+ console.log(JSON.stringify({ cleared: r.cleared.map((c) => c.jobId.toString()), released: r.released.map((c) => c.jobId.toString()), withdrawTx: r.withdrawTx ?? null, deregisterTx: r.deregisterTx }, null, 2));
308
+ }
309
+ catch (e) {
310
+ if (isWorkerOpError(e))
311
+ die(`deregister failed: ${e.message}`);
312
+ throw e;
313
+ }
314
+ break;
315
+ }
205
316
  // Default: one-shot worker summary by address.
206
- const addr = sub ?? die("usage: lightnode worker <address|watch|preflight> [...]");
317
+ const addr = sub ?? die("usage: lightnode worker <address|watch|preflight|status|can-deregister|settle|clearstuck|withdraw|deregister> [...]");
207
318
  const [w, registered, jobs] = await Promise.all([ln.getWorker(addr), ln.isRegistered(addr), ln.getWorkerJobs(addr, 5)]);
208
319
  console.log(JSON.stringify({ onchainRegistered: registered, worker: w, recentJobs: jobs.map((j) => ({ id: j.id, state: j.state })) }, null, 2));
209
320
  break;
@@ -347,7 +458,7 @@ async function main() {
347
458
  console.log("\nNothing to do - all target files already exist. Pass --force to overwrite.");
348
459
  }
349
460
  else {
350
- console.log(`\nNext steps:`);
461
+ console.log(`\nNext steps (these files were added to your CURRENT folder, not a new project):`);
351
462
  console.log(` 1. ${result.install}`);
352
463
  if (sub === "nft-mint-with-inference" || sub === "inference" || sub === "chat" || sub === "agent") {
353
464
  console.log(` 2. cp .env.example .env (and put a funded ${result.network} PRIVATE_KEY in it)`);
@@ -356,14 +467,14 @@ async function main() {
356
467
  console.log(` 4. Deploy. Vercel Cron fires /api/agent on the schedule in vercel.json`);
357
468
  }
358
469
  else if (sub === "agent") {
359
- console.log(` 3. AGENT_INTERVAL_MS=3600000 tsx agent.ts # or run under pm2/systemd`);
470
+ console.log(` 3. AGENT_INTERVAL_MS=3600000 npx tsx agent.ts # or run under pm2/systemd`);
360
471
  }
361
472
  else if (sub === "chat" && result.template === "nextjs-api") {
362
473
  console.log(` 3. Make sure /api/inference is mounted too (run: npx lightnode add inference)`);
363
474
  console.log(` 4. npm run dev, open /chat`);
364
475
  }
365
476
  else if (sub === "chat") {
366
- console.log(` 3. tsx chat-repl.ts (interactive terminal chat)`);
477
+ console.log(` 3. npx tsx chat-repl.ts (interactive terminal chat)`);
367
478
  }
368
479
  else if (sub === "nft-mint-with-inference" && result.template === "nextjs-api") {
369
480
  console.log(` 3. Make sure /api/inference is mounted too (run: npx lightnode add inference)`);
@@ -376,10 +487,10 @@ async function main() {
376
487
  console.log(` 3. wire inferenceHandler into your Hono app, then start it`);
377
488
  }
378
489
  else if (sub === "nft-mint-with-inference") {
379
- console.log(` 3. tsx nft-metadata.ts "My NFT" "concept goes here"`);
490
+ console.log(` 3. npx tsx nft-metadata.ts "My NFT" "concept goes here"`);
380
491
  }
381
492
  else {
382
- console.log(` 3. tsx lightchain-inference.ts "your prompt"`);
493
+ console.log(` 3. npx tsx lightchain-inference.ts "your prompt"`);
383
494
  }
384
495
  }
385
496
  else {
@@ -388,11 +499,15 @@ async function main() {
388
499
  console.log(` 2. npm run dev, open /lightnode-analytics`);
389
500
  }
390
501
  else {
391
- console.log(` 2. tsx lightnode-analytics.ts`);
502
+ console.log(` 2. npx tsx lightnode-analytics.ts`);
392
503
  }
393
504
  }
505
+ if (result.network === "testnet") {
506
+ console.log(`\nNo wallet yet? Make one: npx lightnode wallet new then fund it free below.`);
507
+ }
394
508
  console.log(`\nFree testnet LCAI: https://lightfaucet.ai`);
395
509
  console.log(`Builder docs: https://lightnode.app/build`);
510
+ console.log(`New to all this? See GETTING-STARTED.md in the lightnode repo.`);
396
511
  }
397
512
  break;
398
513
  }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ import { Conversation, chat } from "./chat.js";
6
6
  import { runInferenceBatch } from "./batch.js";
7
7
  import { Agent, parseAgentOutput } from "./agent.js";
8
8
  import { preflight as workerPreflight, watch as workerWatch } from "./worker.js";
9
+ import { WorkerOperator, WORKER_REGISTRY_ABI, JOB_REGISTRY_OPERATOR_ABI, AI_CONFIG_ABI, JOB_STATE, decodeWorkerError, WorkerOpError, isWorkerOpError } from "./worker-operator.js";
9
10
  import { Bridge, BRIDGE_ROUTE, HYPERLANE_ROUTER_ABI, ERC20_ABI, addressToBytes32, quoteBridgeFee, bridgeableBalance, bridgeAllowance, approveBridge, bridgeTransfer } from "./bridge.js";
10
11
  import { DAO, DAO_ADDRESSES, ProposalState, PROPOSAL_STATE_LABEL, VoteSupport, GOVERNOR_ABI, VOTES_ABI } from "./dao.js";
11
12
  import { OnchainModelRegistry, AIVM_MODEL_REGISTRY_ABI, BENCHMARK_REGISTRY_ABI, ModelStatus, MODEL_STATUS_LABEL } from "./onchain-models.js";
@@ -90,8 +91,8 @@ export declare class LightNode {
90
91
  * (especially in registry-proxy environments like StackBlitz where lockfiles
91
92
  * may pin an older minor than the local install command suggests).
92
93
  */
93
- export declare const SDK_VERSION = "0.6.1";
94
- export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, runInference, runInferenceWithKey, runInferenceStream, Conversation, chat, runInferenceBatch, Agent, parseAgentOutput, workerPreflight, workerWatch, Bridge, BRIDGE_ROUTE, HYPERLANE_ROUTER_ABI, ERC20_ABI, addressToBytes32, quoteBridgeFee, bridgeableBalance, bridgeAllowance, approveBridge, bridgeTransfer, DAO, DAO_ADDRESSES, ProposalState, PROPOSAL_STATE_LABEL, VoteSupport, GOVERNOR_ABI, VOTES_ABI, OnchainModelRegistry, AIVM_MODEL_REGISTRY_ABI, BENCHMARK_REGISTRY_ABI, ModelStatus, MODEL_STATUS_LABEL, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, };
94
+ export declare const SDK_VERSION = "0.7.0";
95
+ export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, runInference, runInferenceWithKey, runInferenceStream, Conversation, chat, runInferenceBatch, Agent, parseAgentOutput, workerPreflight, workerWatch, Bridge, BRIDGE_ROUTE, HYPERLANE_ROUTER_ABI, ERC20_ABI, addressToBytes32, quoteBridgeFee, bridgeableBalance, bridgeAllowance, approveBridge, bridgeTransfer, DAO, DAO_ADDRESSES, ProposalState, PROPOSAL_STATE_LABEL, VoteSupport, GOVERNOR_ABI, VOTES_ABI, OnchainModelRegistry, AIVM_MODEL_REGISTRY_ABI, BENCHMARK_REGISTRY_ABI, ModelStatus, MODEL_STATUS_LABEL, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, WorkerOperator, WORKER_REGISTRY_ABI, JOB_REGISTRY_OPERATOR_ABI, AI_CONFIG_ABI, JOB_STATE, decodeWorkerError, WorkerOpError, isWorkerOpError, };
95
96
  export type { BearerSource, GatewayClientOptions, SelectSessionResult, PrepareSessionResult, UploadBlobResult, SessionTokenResult } from "./gateway.js";
96
97
  export type { SessionPreparation, RunInferenceArgs, RunInferenceResult, RunInferenceWithKeyArgs, RunInferenceStreamResult } from "./inference.js";
97
98
  export type { ChatRole, ChatMessage, ConversationOptions, ConversationSendResult } from "./chat.js";
@@ -101,4 +102,5 @@ export type { WorkerPreflightArgs, WorkerPreflightResult, WorkerWatchOptions, Wo
101
102
  export type { BridgeChain, BridgeEndpoints, BridgeTransferArgs } from "./bridge.js";
102
103
  export type { DaoChain, DaoAddresses, ProposalSummary, DaoConfig } from "./dao.js";
103
104
  export type { BaseModel, ModelVariant, AccessTier, AccessPolicy, Benchmark, OnchainModelRegistryOptions } from "./onchain-models.js";
105
+ export type { MinimalWalletClient, MinimalPublicClient, WorkerOperatorOpts, WorkerProtocolConfig, WorkerStatus, DeregisterReadiness, StuckJob, EarningsBreakdown, OnchainJob, JobState, DecodedWorkerError, } from "./worker-operator.js";
104
106
  export type { NetworkId, NetworkConfig, Worker, Job, ModelInfo, NetworkStats, ModelStat, WorkerStat, NetworkAnalytics };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { Conversation, chat } from "./chat.js";
7
7
  import { runInferenceBatch } from "./batch.js";
8
8
  import { Agent, parseAgentOutput } from "./agent.js";
9
9
  import { preflight as workerPreflight, watch as workerWatch } from "./worker.js";
10
+ import { WorkerOperator, WORKER_REGISTRY_ABI, JOB_REGISTRY_OPERATOR_ABI, AI_CONFIG_ABI, JOB_STATE, decodeWorkerError, WorkerOpError, isWorkerOpError, } from "./worker-operator.js";
10
11
  import { Bridge, BRIDGE_ROUTE, HYPERLANE_ROUTER_ABI, ERC20_ABI, addressToBytes32, quoteBridgeFee, bridgeableBalance, bridgeAllowance, approveBridge, bridgeTransfer, } from "./bridge.js";
11
12
  import { DAO, DAO_ADDRESSES, ProposalState, PROPOSAL_STATE_LABEL, VoteSupport, GOVERNOR_ABI, VOTES_ABI, } from "./dao.js";
12
13
  import { OnchainModelRegistry, AIVM_MODEL_REGISTRY_ABI, BENCHMARK_REGISTRY_ABI, ModelStatus, MODEL_STATUS_LABEL, } from "./onchain-models.js";
@@ -145,7 +146,7 @@ export class LightNode {
145
146
  * (especially in registry-proxy environments like StackBlitz where lockfiles
146
147
  * may pin an older minor than the local install command suggests).
147
148
  */
148
- export const SDK_VERSION = "0.6.1";
149
+ export const SDK_VERSION = "0.7.0";
149
150
  export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost,
150
151
  // v0.3 inference-submit surface (BETA - see README "Submitting inference").
151
152
  GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto,
@@ -168,4 +169,8 @@ Bridge, BRIDGE_ROUTE, HYPERLANE_ROUTER_ABI, ERC20_ABI, addressToBytes32, quoteBr
168
169
  // v0.5.0 DAO SDK (LCAIGovernor wrapper on Ethereum mainnet).
169
170
  DAO, DAO_ADDRESSES, ProposalState, PROPOSAL_STATE_LABEL, VoteSupport, GOVERNOR_ABI, VOTES_ABI,
170
171
  // v0.5.0 On-chain model registry reader (AIVMModelRegistry + BenchmarkRegistry).
171
- OnchainModelRegistry, AIVM_MODEL_REGISTRY_ABI, BENCHMARK_REGISTRY_ABI, ModelStatus, MODEL_STATUS_LABEL, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, };
172
+ OnchainModelRegistry, AIVM_MODEL_REGISTRY_ABI, BENCHMARK_REGISTRY_ABI, ModelStatus, MODEL_STATUS_LABEL, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker,
173
+ // v0.7.0 worker-OPERATOR surface: the write/ops side (stuck-job recovery,
174
+ // Docker-free settle/exit, revert decoding, live config). Complements the
175
+ // read-only worker preflight/watch above; does not duplicate it.
176
+ WorkerOperator, WORKER_REGISTRY_ABI, JOB_REGISTRY_OPERATOR_ABI, AI_CONFIG_ABI, JOB_STATE, decodeWorkerError, WorkerOpError, isWorkerOpError, };
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Worker-OPERATOR module - the write/ops side of running a LightChain worker.
3
+ *
4
+ * The published `lightnode-sdk` is read-only (observe a worker) and the existing
5
+ * `worker.ts` does remote preflight/watch. This module is different: it performs
6
+ * the on-chain OPERATOR actions that, today, are either impossible from code or
7
+ * require the multi-GB worker Docker image and reverse-engineering unverified
8
+ * contracts. It is deliberately NOT a re-wrap of the worker toolkit's happy path:
9
+ *
10
+ * 1. Stuck-job recovery - claimTimeout / clearStuck / unstickAndDeregister.
11
+ * The toolkit, the daemon, AND the published SDK all lack this. claimTimeout
12
+ * is permissionless on-chain (verified), so an operator CAN self-clear the
13
+ * acknowledged-but-never-completed jobs that block deregister. Nothing else
14
+ * exposes it.
15
+ * 2. Revert decoding - the WorkerRegistry/JobRegistry custom errors aren't
16
+ * even in the 4byte directory; decodeWorkerError turns them into a sentence
17
+ * plus the fix.
18
+ * 3. Pre-flight gating - canDeregister()/simulate tell you a tx will revert,
19
+ * and WHY, before you spend gas.
20
+ * 4. Docker-free settle+exit - deregister/releaseJob/withdraw/stake ops over
21
+ * pure RPC, from a laptop / CI / server, with no worker image running.
22
+ * 5. Real economics - settled vs pending, claimable, profitability (net of
23
+ * gas), forecast - joins subgraph + on-chain workerBalance + window math.
24
+ * 6. Live protocol config - the AIConfig stake/timeouts/slash-bps/fee-split, so
25
+ * nobody hardcodes "50,000" again.
26
+ * 7. Typed getJob/getSession - the exact struct layouts (no published ABI).
27
+ *
28
+ * Conventions match the rest of the SDK: viem writes via a structural
29
+ * `MinimalWalletClient` (viem stays a soft dep), `NETWORKS` for config, the
30
+ * errors.ts class+guard style. Reuses `crypto.ts` for P-256 / AES-GCM.
31
+ *
32
+ * Source of truth for selectors/structs/errors: verified live on mainnet 9200 +
33
+ * the official worker Go bindings. JobRegistry/WorkerRegistry impls are
34
+ * UNVERIFIED on the explorer, so this is pinned to a snapshot - treat as 0.x and
35
+ * lean on decodeWorkerError() to surface any future drift instead of failing
36
+ * silently.
37
+ */
38
+ import type { NetworkConfig, NetworkId } from "./types.js";
39
+ export interface MinimalWalletClient {
40
+ writeContract: (args: {
41
+ address: `0x${string}`;
42
+ abi: readonly unknown[];
43
+ functionName: string;
44
+ args: readonly unknown[];
45
+ value?: bigint;
46
+ gas?: bigint;
47
+ }) => Promise<`0x${string}`>;
48
+ account?: {
49
+ address?: `0x${string}`;
50
+ } | `0x${string}`;
51
+ }
52
+ export interface MinimalPublicClient {
53
+ readContract: (args: {
54
+ address: `0x${string}`;
55
+ abi: readonly unknown[];
56
+ functionName: string;
57
+ args?: readonly unknown[];
58
+ }) => Promise<unknown>;
59
+ waitForTransactionReceipt: (args: {
60
+ hash: `0x${string}`;
61
+ }) => Promise<{
62
+ status: "success" | "reverted";
63
+ blockNumber: bigint;
64
+ gasUsed?: bigint;
65
+ effectiveGasPrice?: bigint;
66
+ }>;
67
+ simulateContract?: (args: {
68
+ address: `0x${string}`;
69
+ abi: readonly unknown[];
70
+ functionName: string;
71
+ args?: readonly unknown[];
72
+ account?: `0x${string}`;
73
+ value?: bigint;
74
+ }) => Promise<unknown>;
75
+ }
76
+ /** WorkerRegistry (genesis predeploy 0x...1002). Operator + stake surface. */
77
+ export declare const WORKER_REGISTRY_ABI: readonly ["function registerWorker(bytes encryptionPubKey) payable", "function deregisterWorker()", "function addSupportedModel(bytes32 modelId)", "function removeSupportedModel(bytes32 modelId)", "function topUpStake() payable", "function withdrawStake(uint256 amount)", "function reinstate()", "function isWorkerRegistered(address worker) view returns (bool)", "function getWorkerStake(address worker) view returns (uint256)", "function getWorkerEncryptionKey(address worker) view returns (bytes)", "function getMinWorkerStake() view returns (uint256)", "function isEligible(address worker, bytes32 modelId) view returns (bool)"];
78
+ /** JobRegistry (proxy). Job lifecycle + settlement + the timeout primitive.
79
+ * The Job struct is a named human-readable struct (abitype needs a named
80
+ * struct, not an inline `tuple(...)`, for the getJob return). */
81
+ export declare const JOB_REGISTRY_OPERATOR_ABI: readonly ["struct Job { uint256 jobId; address worker; uint8 state; uint256 escrowedFee; bytes32 promptBlobHash; bytes32 responseBlobHash; uint64 submittedAt; uint64 ackAt; uint64 completedAt; uint64 deadlineAt; uint256 r10; uint256 r11; uint256 r12; uint256 r13; uint256 r14; uint256 r15; uint256 submitBlockNumber; uint256 completionBlockNumber; }", "function acknowledgeJob(uint256 jobId)", "function completeJob(uint256 jobId, bytes32 responseBlobHash, bytes32 responseCiphertextHash)", "function releaseJob(uint256 jobId)", "function releaseJobs(uint256[] jobIds)", "function claimTimeout(uint256 jobId)", "function withdraw()", "function workerBalance(address worker) view returns (uint256)", "function getJob(uint256 jobId) view returns (Job)"];
82
+ /** AIConfig (verified ABI). Live protocol parameters. */
83
+ export declare const AI_CONFIG_ABI: readonly ["function getMinWorkerStake() view returns (uint256)", "function getCompletionTimeout() view returns (uint256)", "function getAckTimeout() view returns (uint256)", "function getResolutionTimeout() view returns (uint256)", "function getDisputeWindow() view returns (uint256)", "function getTimeoutSlashBps() view returns (uint256)", "function getCompletionTimeoutSlashBps() view returns (uint256)", "function getDisputeSlashBps() view returns (uint256)", "function getMaxSlashBps() view returns (uint256)", "function getProtocolFeeBps() view returns (uint256)", "function getWorkerFeeBps() view returns (uint256)", "function getFeePoolBps() view returns (uint256)", "function getSuspensionThreshold() view returns (uint256)", "function getSuspensionCooldown() view returns (uint256)", "function getModelFee(bytes32 modelId) view returns (uint256)", "function isModelEnabled(bytes32 modelId) view returns (bool)"];
84
+ /** On-chain JobState. Order is ABI-load-bearing - do not reorder. */
85
+ export declare const JOB_STATE: readonly ["Submitted", "Acknowledged", "Completed", "TimedOut", "Disputed", "Resolved", "Released"];
86
+ export type JobState = (typeof JOB_STATE)[number];
87
+ export interface OnchainJob {
88
+ jobId: bigint;
89
+ worker: `0x${string}`;
90
+ state: JobState;
91
+ /** Raw enum index, in case a future contract adds states. */
92
+ stateIndex: number;
93
+ escrowedFeeWei: bigint;
94
+ promptBlobHash: `0x${string}`;
95
+ responseBlobHash: `0x${string}`;
96
+ submittedAt: number;
97
+ ackAt: number;
98
+ completedAt: number;
99
+ deadlineAt: number;
100
+ submitBlockNumber: bigint;
101
+ completionBlockNumber: bigint;
102
+ }
103
+ export interface DecodedWorkerError {
104
+ /** 4-byte selector, e.g. "0x592f994b". */
105
+ selector: `0x${string}`;
106
+ /** Solidity error name, or "Unknown" when unrecognized. */
107
+ name: string;
108
+ /** Decoded args, best-effort (uint/address words). */
109
+ args: Array<string | bigint>;
110
+ /** Plain-English explanation + the fix. */
111
+ message: string;
112
+ }
113
+ /**
114
+ * Decode raw revert data (the `data: "0x.."` from an eth_call/estimateGas
115
+ * failure) into a named, explained error. Falls back to {name:"Unknown"} so the
116
+ * caller always gets *something* even if the contract drifts.
117
+ */
118
+ export declare function decodeWorkerError(revertData: string | null | undefined): DecodedWorkerError;
119
+ /**
120
+ * Operator-side typed error (mirrors the errors.ts convention: named class +
121
+ * readonly fields + an `is...` guard). Carries the decoded contract error.
122
+ */
123
+ export declare class WorkerOpError extends Error {
124
+ readonly op: string;
125
+ readonly decoded?: DecodedWorkerError;
126
+ readonly tx?: `0x${string}`;
127
+ constructor(op: string, message: string, opts?: {
128
+ decoded?: DecodedWorkerError;
129
+ tx?: `0x${string}`;
130
+ });
131
+ }
132
+ export declare function isWorkerOpError(e: unknown): e is WorkerOpError;
133
+ /** Live AIConfig protocol parameters (read from chain, never hardcoded). */
134
+ export interface WorkerProtocolConfig {
135
+ minStakeWei: bigint;
136
+ minStakeLcai: number;
137
+ completionTimeoutSec: number;
138
+ ackTimeoutSec: number;
139
+ resolutionTimeoutSec: number;
140
+ disputeWindowSec: number;
141
+ /** Slash basis points: ack-timeout, completion-timeout, dispute, max cap. */
142
+ slashBps: {
143
+ ackTimeout: number;
144
+ completionTimeout: number;
145
+ dispute: number;
146
+ max: number;
147
+ };
148
+ /** Fee split basis points (protocol/worker/feePool sum to 10000). */
149
+ feeBps: {
150
+ protocol: number;
151
+ worker: number;
152
+ feePool: number;
153
+ };
154
+ suspensionThreshold: number;
155
+ suspensionCooldownSec: number;
156
+ }
157
+ export interface WorkerStatus {
158
+ address: `0x${string}`;
159
+ registered: boolean;
160
+ stakeWei: bigint;
161
+ stakeLcai: number;
162
+ minStakeWei: bigint;
163
+ /** stake - minStake, in LCAI (negative = below floor / deactivated). */
164
+ headroomLcai: number;
165
+ belowFloor: boolean;
166
+ /** Claimable worker balance in the JobRegistry (earned, not yet withdrawn). */
167
+ claimableWei: bigint;
168
+ claimableLcai: number;
169
+ }
170
+ export interface DeregisterReadiness {
171
+ ok: boolean;
172
+ /** Job IDs blocking deregistration (in-flight / acked-incomplete). */
173
+ blockedBy: bigint[];
174
+ reason: string;
175
+ }
176
+ export interface StuckJob {
177
+ /**
178
+ * The ID to pass to claimTimeout/getJob - i.e. the SAME id you looked the job
179
+ * up by (the subgraph/display id). IMPORTANT: this is NOT the struct's internal
180
+ * `jobId` field, which is a different counter; calling claimTimeout with the
181
+ * struct field hits the wrong job. Always use this lookupId for writes.
182
+ */
183
+ lookupId: bigint;
184
+ /** The struct's internal jobId field (informational; do NOT use for writes). */
185
+ jobId: bigint;
186
+ state: JobState;
187
+ ackAt: number;
188
+ /** Seconds past the completion deadline (>0 means claimTimeout is eligible). */
189
+ pastDeadlineSec: number;
190
+ escrowedFeeWei: bigint;
191
+ }
192
+ export interface EarningsBreakdown {
193
+ /** Already withdrawn-able now (in the JobRegistry workerBalance). */
194
+ claimableLcai: number;
195
+ /** Lifetime earned (from the subgraph total_earned). */
196
+ lifetimeLcai: number;
197
+ /** Completed jobs awaiting their release window before they pay out. */
198
+ pendingReleaseCount: number;
199
+ jobsCompleted: number;
200
+ jobsTimedOut: number;
201
+ }
202
+ export interface WorkerOperatorOpts {
203
+ publicClient: MinimalPublicClient;
204
+ /** Required for writes (register/deregister/release/claimTimeout/withdraw/stake). */
205
+ walletClient?: MinimalWalletClient;
206
+ /** Worker address. Defaults to walletClient.account.address when present. */
207
+ workerAddress?: `0x${string}`;
208
+ }
209
+ export declare class WorkerOperator {
210
+ readonly network: NetworkConfig;
211
+ private readonly pub;
212
+ private readonly wallet?;
213
+ private readonly addr;
214
+ private cfgCache?;
215
+ constructor(network: NetworkId | NetworkConfig, opts: WorkerOperatorOpts);
216
+ private requireWallet;
217
+ private get jobReg();
218
+ private get workerReg();
219
+ private read;
220
+ /** Send a write and wait for the receipt, decoding any revert into a WorkerOpError. */
221
+ private send;
222
+ /** Live AIConfig parameters (cached after first read). */
223
+ config(): Promise<WorkerProtocolConfig>;
224
+ /** One-call worker status: registration, stake, floor, claimable balance. */
225
+ status(): Promise<WorkerStatus>;
226
+ /** Typed on-chain job (the struct layout has no published ABI). */
227
+ getJob(jobId: bigint | number): Promise<OnchainJob>;
228
+ /**
229
+ * Jobs this worker acknowledged but never completed that are now past the
230
+ * completion deadline - the ones that block deregister and are clearable via
231
+ * claimTimeout. Needs the worker's job IDs (from the subgraph / LightNode
232
+ * client); on-chain there is no enumerator. Pass the candidate IDs in.
233
+ */
234
+ stuckJobs(candidateJobIds: Array<bigint | number>): Promise<StuckJob[]>;
235
+ /**
236
+ * Clear one stuck (acknowledged-but-never-completed, past-deadline) job.
237
+ * Permissionless on-chain - the worker itself may call it.
238
+ *
239
+ * ⚠️ MAINNET SLASH: this finalizes the job as TimedOut, which realizes the
240
+ * completion-timeout slash on the worker's stake (mainnet:
241
+ * `config().slashBps.completionTimeout` = 5% of stake per job at the time of
242
+ * writing; TESTNET has slashing disabled, so it's free there). This is the
243
+ * deliberate price of unblocking deregister - a stuck acked job otherwise
244
+ * blocks the exit forever. Call `config()` first to see the live slash bps, and
245
+ * only clear jobs you've accepted are lost. Verify eligibility with
246
+ * `stuckJobs([id])` (pastDeadlineSec > 0) before calling.
247
+ */
248
+ claimTimeout(jobId: bigint | number): Promise<`0x${string}`>;
249
+ /**
250
+ * Clear every past-deadline acked job in `candidateJobIds`. Returns the txs and
251
+ * the IDs it skipped (not stuck / not past deadline). Each cleared job realizes
252
+ * its completion-timeout slash - see slashBps.completionTimeout in config().
253
+ */
254
+ clearStuck(candidateJobIds: Array<bigint | number>): Promise<{
255
+ cleared: Array<{
256
+ jobId: bigint;
257
+ tx: `0x${string}`;
258
+ }>;
259
+ skipped: bigint[];
260
+ }>;
261
+ /**
262
+ * Will deregister succeed right now? Reads the active-job count by checking the
263
+ * candidate IDs (on-chain there's no enumerator, so pass the worker's job IDs).
264
+ * Returns the blocking job IDs and a human reason - BEFORE you spend gas.
265
+ */
266
+ canDeregister(candidateJobIds?: Array<bigint | number>): Promise<DeregisterReadiness>;
267
+ /** Settle one completed job past its dispute window. Permissionless. */
268
+ releaseJob(jobId: bigint | number): Promise<`0x${string}`>;
269
+ /**
270
+ * Settle every releasable completed job in `candidateJobIds`. Skips jobs still
271
+ * inside the dispute window (DisputeWindowNotElapsed) rather than failing the
272
+ * batch - uses per-job calls so one not-ready job can't revert the rest.
273
+ */
274
+ releaseAll(candidateJobIds: Array<bigint | number>): Promise<{
275
+ released: Array<{
276
+ jobId: bigint;
277
+ tx: `0x${string}`;
278
+ }>;
279
+ notReady: bigint[];
280
+ }>;
281
+ /** Pull the worker's earned balance from the JobRegistry into the wallet. */
282
+ withdraw(): Promise<`0x${string}`>;
283
+ /** Deregister - releases stake to the wallet. Reverts (ActiveJobsExist) if any in-flight job remains. */
284
+ deregister(): Promise<`0x${string}`>;
285
+ /**
286
+ * The flagship rescue: clear stuck jobs → release any settled completed jobs +
287
+ * withdraw earnings → deregister. The one flow no official tool provides.
288
+ * Pass the worker's known job IDs (from the subgraph). Returns every tx done.
289
+ */
290
+ unstickAndDeregister(candidateJobIds: Array<bigint | number>): Promise<{
291
+ cleared: Array<{
292
+ jobId: bigint;
293
+ tx: `0x${string}`;
294
+ }>;
295
+ released: Array<{
296
+ jobId: bigint;
297
+ tx: `0x${string}`;
298
+ }>;
299
+ withdrawTx?: `0x${string}`;
300
+ deregisterTx: `0x${string}`;
301
+ }>;
302
+ topUpStake(lcai: number): Promise<`0x${string}`>;
303
+ withdrawStake(lcai: number): Promise<`0x${string}`>;
304
+ reinstate(): Promise<`0x${string}`>;
305
+ /**
306
+ * Earnings breakdown: claimable-now (on-chain workerBalance) plus lifetime +
307
+ * pending-release counts derived from the worker record + its jobs. The caller
308
+ * passes the subgraph-sourced worker record and jobs (e.g. from LightNode) so
309
+ * this module stays free of a GraphQL dependency.
310
+ */
311
+ earnings(input: {
312
+ lifetimeEarnedWei?: bigint | string;
313
+ jobsCompleted?: number;
314
+ jobsTimedOut?: number;
315
+ jobs?: Array<{
316
+ state: string;
317
+ }>;
318
+ }): Promise<EarningsBreakdown>;
319
+ /**
320
+ * Net profitability: per-job worker fee (from live AIConfig fee split + the
321
+ * model fee) minus an estimated gas cost per job. Pure math over live config so
322
+ * an operator can answer "is this worth running" without a spreadsheet.
323
+ */
324
+ profitability(input: {
325
+ modelTag?: string;
326
+ modelFeeWei?: bigint;
327
+ gasPerJobLcai?: number;
328
+ jobsPerDay?: number;
329
+ }): Promise<{
330
+ workerFeeLcaiPerJob: number;
331
+ gasLcaiPerJob: number;
332
+ netLcaiPerJob: number;
333
+ breakEvenJobsPerDay: number | null;
334
+ projectedDailyLcai: number | null;
335
+ }>;
336
+ }
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Worker-OPERATOR module - the write/ops side of running a LightChain worker.
3
+ *
4
+ * The published `lightnode-sdk` is read-only (observe a worker) and the existing
5
+ * `worker.ts` does remote preflight/watch. This module is different: it performs
6
+ * the on-chain OPERATOR actions that, today, are either impossible from code or
7
+ * require the multi-GB worker Docker image and reverse-engineering unverified
8
+ * contracts. It is deliberately NOT a re-wrap of the worker toolkit's happy path:
9
+ *
10
+ * 1. Stuck-job recovery - claimTimeout / clearStuck / unstickAndDeregister.
11
+ * The toolkit, the daemon, AND the published SDK all lack this. claimTimeout
12
+ * is permissionless on-chain (verified), so an operator CAN self-clear the
13
+ * acknowledged-but-never-completed jobs that block deregister. Nothing else
14
+ * exposes it.
15
+ * 2. Revert decoding - the WorkerRegistry/JobRegistry custom errors aren't
16
+ * even in the 4byte directory; decodeWorkerError turns them into a sentence
17
+ * plus the fix.
18
+ * 3. Pre-flight gating - canDeregister()/simulate tell you a tx will revert,
19
+ * and WHY, before you spend gas.
20
+ * 4. Docker-free settle+exit - deregister/releaseJob/withdraw/stake ops over
21
+ * pure RPC, from a laptop / CI / server, with no worker image running.
22
+ * 5. Real economics - settled vs pending, claimable, profitability (net of
23
+ * gas), forecast - joins subgraph + on-chain workerBalance + window math.
24
+ * 6. Live protocol config - the AIConfig stake/timeouts/slash-bps/fee-split, so
25
+ * nobody hardcodes "50,000" again.
26
+ * 7. Typed getJob/getSession - the exact struct layouts (no published ABI).
27
+ *
28
+ * Conventions match the rest of the SDK: viem writes via a structural
29
+ * `MinimalWalletClient` (viem stays a soft dep), `NETWORKS` for config, the
30
+ * errors.ts class+guard style. Reuses `crypto.ts` for P-256 / AES-GCM.
31
+ *
32
+ * Source of truth for selectors/structs/errors: verified live on mainnet 9200 +
33
+ * the official worker Go bindings. JobRegistry/WorkerRegistry impls are
34
+ * UNVERIFIED on the explorer, so this is pinned to a snapshot - treat as 0.x and
35
+ * lean on decodeWorkerError() to surface any future drift instead of failing
36
+ * silently.
37
+ */
38
+ import { parseAbi } from "viem";
39
+ import { NETWORKS } from "./networks.js";
40
+ // ===========================================================================
41
+ // ABIs - minimal, operator-facing. Verified selectors (see module header).
42
+ // Human-readable strings; viem parseAbi-compatible if the caller wants typing.
43
+ // ===========================================================================
44
+ /** WorkerRegistry (genesis predeploy 0x...1002). Operator + stake surface. */
45
+ export const WORKER_REGISTRY_ABI = [
46
+ "function registerWorker(bytes encryptionPubKey) payable",
47
+ "function deregisterWorker()",
48
+ "function addSupportedModel(bytes32 modelId)",
49
+ "function removeSupportedModel(bytes32 modelId)",
50
+ "function topUpStake() payable",
51
+ "function withdrawStake(uint256 amount)",
52
+ "function reinstate()",
53
+ "function isWorkerRegistered(address worker) view returns (bool)",
54
+ "function getWorkerStake(address worker) view returns (uint256)",
55
+ "function getWorkerEncryptionKey(address worker) view returns (bytes)",
56
+ "function getMinWorkerStake() view returns (uint256)",
57
+ "function isEligible(address worker, bytes32 modelId) view returns (bool)",
58
+ ];
59
+ /** JobRegistry (proxy). Job lifecycle + settlement + the timeout primitive.
60
+ * The Job struct is a named human-readable struct (abitype needs a named
61
+ * struct, not an inline `tuple(...)`, for the getJob return). */
62
+ export const JOB_REGISTRY_OPERATOR_ABI = [
63
+ "struct Job { uint256 jobId; address worker; uint8 state; uint256 escrowedFee; bytes32 promptBlobHash; bytes32 responseBlobHash; uint64 submittedAt; uint64 ackAt; uint64 completedAt; uint64 deadlineAt; uint256 r10; uint256 r11; uint256 r12; uint256 r13; uint256 r14; uint256 r15; uint256 submitBlockNumber; uint256 completionBlockNumber; }",
64
+ "function acknowledgeJob(uint256 jobId)",
65
+ "function completeJob(uint256 jobId, bytes32 responseBlobHash, bytes32 responseCiphertextHash)",
66
+ "function releaseJob(uint256 jobId)",
67
+ "function releaseJobs(uint256[] jobIds)",
68
+ "function claimTimeout(uint256 jobId)",
69
+ "function withdraw()",
70
+ "function workerBalance(address worker) view returns (uint256)",
71
+ "function getJob(uint256 jobId) view returns (Job)",
72
+ ];
73
+ /** AIConfig (verified ABI). Live protocol parameters. */
74
+ export const AI_CONFIG_ABI = [
75
+ "function getMinWorkerStake() view returns (uint256)",
76
+ "function getCompletionTimeout() view returns (uint256)",
77
+ "function getAckTimeout() view returns (uint256)",
78
+ "function getResolutionTimeout() view returns (uint256)",
79
+ "function getDisputeWindow() view returns (uint256)",
80
+ "function getTimeoutSlashBps() view returns (uint256)",
81
+ "function getCompletionTimeoutSlashBps() view returns (uint256)",
82
+ "function getDisputeSlashBps() view returns (uint256)",
83
+ "function getMaxSlashBps() view returns (uint256)",
84
+ "function getProtocolFeeBps() view returns (uint256)",
85
+ "function getWorkerFeeBps() view returns (uint256)",
86
+ "function getFeePoolBps() view returns (uint256)",
87
+ "function getSuspensionThreshold() view returns (uint256)",
88
+ "function getSuspensionCooldown() view returns (uint256)",
89
+ "function getModelFee(bytes32 modelId) view returns (uint256)",
90
+ "function isModelEnabled(bytes32 modelId) view returns (bool)",
91
+ ];
92
+ // viem's readContract/writeContract need a PARSED ABI (objects), not the
93
+ // human-readable strings above. We keep the strings exported for readability and
94
+ // parse them once here for the actual on-chain calls.
95
+ const WORKER_REGISTRY_ABI_PARSED = parseAbi(WORKER_REGISTRY_ABI);
96
+ const JOB_REGISTRY_OPERATOR_ABI_PARSED = parseAbi(JOB_REGISTRY_OPERATOR_ABI);
97
+ const AI_CONFIG_ABI_PARSED = parseAbi(AI_CONFIG_ABI);
98
+ // ===========================================================================
99
+ // Enums + struct types (pinned from the worker Go bindings + live decode).
100
+ // ===========================================================================
101
+ /** On-chain JobState. Order is ABI-load-bearing - do not reorder. */
102
+ export const JOB_STATE = [
103
+ "Submitted",
104
+ "Acknowledged",
105
+ "Completed",
106
+ "TimedOut",
107
+ "Disputed",
108
+ "Resolved",
109
+ "Released",
110
+ ];
111
+ /** selector -> spec. Verified against live reverts on mainnet 9200. */
112
+ const WORKER_ERROR_TABLE = {
113
+ "0x592f994b": {
114
+ name: "ActiveJobsExist",
115
+ types: ["address", "uint256"],
116
+ explain: (a) => `Can't deregister/withdraw yet: this worker still has ${a[1]} in-flight job(s) on-chain. ` +
117
+ `Clear them first - acknowledged-but-unfinished jobs are cleared with claimTimeout (clearStuck() does this), ` +
118
+ `then deregister succeeds.`,
119
+ },
120
+ "0xcb9a70eb": {
121
+ name: "WorkerNotRegistered",
122
+ types: ["address"],
123
+ explain: (a) => `Address ${a[0]} is not a registered worker (or the job/worker referenced doesn't exist). ` +
124
+ `Register first, or check you're using the worker's own key.`,
125
+ },
126
+ "0x98f5b6c5": {
127
+ name: "DisputeWindowNotElapsed",
128
+ types: ["uint256", "uint256", "uint256"],
129
+ explain: (a) => {
130
+ const releaseAt = Number(a[1] ?? 0);
131
+ const now = Number(a[2] ?? 0);
132
+ const mins = releaseAt > now ? Math.ceil((releaseAt - now) / 60) : 0;
133
+ return (`Job ${a[0]} isn't releasable yet - it's still inside the dispute window. ` +
134
+ (mins ? `Releasable in ~${mins} min (at unix ${releaseAt}). ` : "") +
135
+ `releaseAll() skips jobs that aren't ready; retry later.`);
136
+ },
137
+ },
138
+ "0x45be0a26": {
139
+ name: "InsufficientStake",
140
+ types: ["uint256", "uint256"],
141
+ explain: (a) => `Insufficient stake: requested ${a[0]} but only ${a[1]} is available above the minimum. ` +
142
+ `Withdraw less, or you're below the floor - topUpStake() then reinstate().`,
143
+ },
144
+ "0x50c83b95": {
145
+ name: "JobNotFound",
146
+ types: ["uint256"],
147
+ explain: (a) => `Job ${a[0]} does not exist on this network.`,
148
+ },
149
+ "0x95e2fa37": {
150
+ name: "SessionNotFound",
151
+ types: ["uint256"],
152
+ explain: (a) => `Session ${a[0]} does not exist on this network.`,
153
+ },
154
+ "0x149ce097": {
155
+ name: "SessionNotActive",
156
+ types: ["uint256"],
157
+ explain: (a) => `Session ${a[0]} is not Active (closed or expired).`,
158
+ },
159
+ "0xa458261b": {
160
+ name: "InsufficientFee",
161
+ types: ["uint256", "uint256"],
162
+ explain: (a) => `Underfunded: sent ${a[0]} wei but ${a[1]} is required.`,
163
+ },
164
+ "0xe06b2da5": {
165
+ name: "NoBalanceToWithdraw",
166
+ types: ["address"],
167
+ explain: (a) => `Nothing to withdraw - ${a[0]} has a zero worker balance in the JobRegistry.`,
168
+ },
169
+ "0x4a0bfec1": {
170
+ name: "NotAuthorized",
171
+ types: ["address"],
172
+ explain: (a) => `${a[0]} is not authorized for this action (disputer/dispatcher-gated).`,
173
+ },
174
+ };
175
+ /** Read a 32-byte ABI word at slot i from hex data (after the 4-byte selector). */
176
+ function wordAt(dataNo0x, i) {
177
+ return dataNo0x.slice(i * 64, i * 64 + 64);
178
+ }
179
+ /**
180
+ * Decode raw revert data (the `data: "0x.."` from an eth_call/estimateGas
181
+ * failure) into a named, explained error. Falls back to {name:"Unknown"} so the
182
+ * caller always gets *something* even if the contract drifts.
183
+ */
184
+ export function decodeWorkerError(revertData) {
185
+ const data = (revertData ?? "").toLowerCase();
186
+ if (!data.startsWith("0x") || data.length < 10) {
187
+ return { selector: "0x", name: "Unknown", args: [], message: "No revert data to decode." };
188
+ }
189
+ const selector = data.slice(0, 10);
190
+ const spec = WORKER_ERROR_TABLE[selector];
191
+ const body = data.slice(10);
192
+ if (!spec) {
193
+ return {
194
+ selector,
195
+ name: "Unknown",
196
+ args: [],
197
+ message: `Unrecognized revert ${selector}. The worker contracts are unverified and may have changed; check the explorer.`,
198
+ };
199
+ }
200
+ const args = spec.types.map((t, i) => {
201
+ const w = wordAt(body, i);
202
+ if (!w)
203
+ return t === "address" ? "0x" : 0n;
204
+ if (t === "address")
205
+ return (`0x${w.slice(24)}`);
206
+ return BigInt(`0x${w || "0"}`);
207
+ });
208
+ return { selector, name: spec.name, args, message: spec.explain(args) };
209
+ }
210
+ /**
211
+ * Operator-side typed error (mirrors the errors.ts convention: named class +
212
+ * readonly fields + an `is...` guard). Carries the decoded contract error.
213
+ */
214
+ export class WorkerOpError extends Error {
215
+ constructor(op, message, opts) {
216
+ super(message);
217
+ this.name = "WorkerOpError";
218
+ this.op = op;
219
+ this.decoded = opts?.decoded;
220
+ this.tx = opts?.tx;
221
+ }
222
+ }
223
+ export function isWorkerOpError(e) {
224
+ return e instanceof WorkerOpError;
225
+ }
226
+ /** Pull the `data:` revert blob out of a viem/provider error of unknown shape. */
227
+ function extractRevertData(err) {
228
+ const seen = new Set();
229
+ const walk = (o) => {
230
+ if (!o || typeof o !== "object" || seen.has(o))
231
+ return undefined;
232
+ seen.add(o);
233
+ const rec = o;
234
+ for (const k of ["data", "raw"]) {
235
+ const v = rec[k];
236
+ if (typeof v === "string" && /^0x[0-9a-fA-F]{8,}$/.test(v))
237
+ return v;
238
+ if (v && typeof v === "object") {
239
+ const inner = v.data;
240
+ if (typeof inner === "string" && /^0x[0-9a-fA-F]{8,}$/.test(inner))
241
+ return inner;
242
+ }
243
+ }
244
+ for (const k of ["cause", "error", "details", "walk"]) {
245
+ const found = walk(rec[k]);
246
+ if (found)
247
+ return found;
248
+ }
249
+ return undefined;
250
+ };
251
+ return walk(err);
252
+ }
253
+ // ===========================================================================
254
+ // Config + helpers
255
+ // ===========================================================================
256
+ const BPS = 10000n;
257
+ const toLcai = (wei) => Number(wei) / 1e18;
258
+ const toWeiFromLcai = (lcai) => BigInt(Math.round(lcai * 1e18));
259
+ function normalizeJob(raw) {
260
+ // viem returns the tuple either as an array or an object depending on ABI form.
261
+ const t = raw;
262
+ const get = (k, i) => (Array.isArray(t) ? t[i] : t[k]);
263
+ const idx = Number(get("state", 2) ?? 0);
264
+ return {
265
+ jobId: BigInt(get("jobId", 0) ?? 0),
266
+ worker: get("worker", 1) ?? "0x",
267
+ state: JOB_STATE[idx] ?? "Submitted",
268
+ stateIndex: idx,
269
+ escrowedFeeWei: BigInt(get("escrowedFee", 3) ?? 0),
270
+ promptBlobHash: get("promptBlobHash", 4) ?? "0x",
271
+ responseBlobHash: get("responseBlobHash", 5) ?? "0x",
272
+ submittedAt: Number(get("submittedAt", 6) ?? 0),
273
+ ackAt: Number(get("ackAt", 7) ?? 0),
274
+ completedAt: Number(get("completedAt", 8) ?? 0),
275
+ deadlineAt: Number(get("deadlineAt", 9) ?? 0),
276
+ submitBlockNumber: BigInt(get("submitBlockNumber", 16) ?? 0),
277
+ completionBlockNumber: BigInt(get("completionBlockNumber", 17) ?? 0),
278
+ };
279
+ }
280
+ export class WorkerOperator {
281
+ constructor(network, opts) {
282
+ this.network = typeof network === "string" ? NETWORKS[network] : network;
283
+ if (!this.network)
284
+ throw new Error(`WorkerOperator: unknown network ${String(network)}`);
285
+ this.pub = opts.publicClient;
286
+ this.wallet = opts.walletClient;
287
+ const acct = opts.walletClient?.account;
288
+ const fromWallet = typeof acct === "string" ? acct : acct?.address;
289
+ const a = (opts.workerAddress ?? fromWallet);
290
+ if (!a)
291
+ throw new Error("WorkerOperator: provide workerAddress or a walletClient with an account");
292
+ this.addr = a.toLowerCase();
293
+ }
294
+ requireWallet(op) {
295
+ if (!this.wallet)
296
+ throw new WorkerOpError(op, `${op} needs a walletClient - construct WorkerOperator with one.`);
297
+ return this.wallet;
298
+ }
299
+ get jobReg() {
300
+ return this.network.jobRegistry;
301
+ }
302
+ get workerReg() {
303
+ return this.network.workerRegistry;
304
+ }
305
+ async read(address, abi, functionName, args = []) {
306
+ return this.pub.readContract({ address, abi, functionName, args });
307
+ }
308
+ /** Send a write and wait for the receipt, decoding any revert into a WorkerOpError. */
309
+ async send(op, address, abi, functionName, args, value) {
310
+ const wallet = this.requireWallet(op);
311
+ let tx;
312
+ try {
313
+ tx = await wallet.writeContract({ address, abi, functionName, args, ...(value !== undefined ? { value } : {}) });
314
+ }
315
+ catch (err) {
316
+ const decoded = decodeWorkerError(extractRevertData(err));
317
+ throw new WorkerOpError(op, `${op} reverted before broadcast: ${decoded.message}`, { decoded });
318
+ }
319
+ const receipt = await this.pub.waitForTransactionReceipt({ hash: tx });
320
+ if (receipt.status !== "success") {
321
+ throw new WorkerOpError(op, `${op} reverted on-chain (tx=${tx})`, { tx });
322
+ }
323
+ return tx;
324
+ }
325
+ // ---- 6) Live protocol config -------------------------------------------
326
+ /** Live AIConfig parameters (cached after first read). */
327
+ async config() {
328
+ if (this.cfgCache)
329
+ return this.cfgCache;
330
+ const a = this.network.aiConfig;
331
+ const r = (fn) => this.read(a, AI_CONFIG_ABI_PARSED, fn);
332
+ const [minStake, completion, ack, resolution, dispute, ackSlash, compSlash, dispSlash, maxSlash, protoFee, workerFee, poolFee, suspThresh, suspCool,] = await Promise.all([
333
+ r("getMinWorkerStake"),
334
+ r("getCompletionTimeout"),
335
+ r("getAckTimeout"),
336
+ r("getResolutionTimeout"),
337
+ r("getDisputeWindow"),
338
+ r("getTimeoutSlashBps"),
339
+ r("getCompletionTimeoutSlashBps"),
340
+ r("getDisputeSlashBps"),
341
+ r("getMaxSlashBps"),
342
+ r("getProtocolFeeBps"),
343
+ r("getWorkerFeeBps"),
344
+ r("getFeePoolBps"),
345
+ r("getSuspensionThreshold"),
346
+ r("getSuspensionCooldown"),
347
+ ]);
348
+ this.cfgCache = {
349
+ minStakeWei: minStake,
350
+ minStakeLcai: toLcai(minStake),
351
+ completionTimeoutSec: Number(completion),
352
+ ackTimeoutSec: Number(ack),
353
+ resolutionTimeoutSec: Number(resolution),
354
+ disputeWindowSec: Number(dispute),
355
+ slashBps: {
356
+ ackTimeout: Number(ackSlash),
357
+ completionTimeout: Number(compSlash),
358
+ dispute: Number(dispSlash),
359
+ max: Number(maxSlash),
360
+ },
361
+ feeBps: { protocol: Number(protoFee), worker: Number(workerFee), feePool: Number(poolFee) },
362
+ suspensionThreshold: Number(suspThresh),
363
+ suspensionCooldownSec: Number(suspCool),
364
+ };
365
+ return this.cfgCache;
366
+ }
367
+ // ---- status / reads -----------------------------------------------------
368
+ /** One-call worker status: registration, stake, floor, claimable balance. */
369
+ async status() {
370
+ // minStake is sourced from AIConfig (verified live on BOTH networks), not
371
+ // WorkerRegistry.getMinWorkerStake - the testnet WorkerRegistry impl differs
372
+ // and reverts that getter. AIConfig is the canonical source either way.
373
+ const [registered, stakeWei, minStakeWei, claimableWei] = await Promise.all([
374
+ this.read(this.workerReg, WORKER_REGISTRY_ABI_PARSED, "isWorkerRegistered", [this.addr]),
375
+ this.read(this.workerReg, WORKER_REGISTRY_ABI_PARSED, "getWorkerStake", [this.addr]),
376
+ this.read(this.network.aiConfig, AI_CONFIG_ABI_PARSED, "getMinWorkerStake"),
377
+ this.read(this.jobReg, JOB_REGISTRY_OPERATOR_ABI_PARSED, "workerBalance", [this.addr]),
378
+ ]);
379
+ const headroomLcai = toLcai(stakeWei - minStakeWei);
380
+ return {
381
+ address: this.addr,
382
+ registered,
383
+ stakeWei,
384
+ stakeLcai: toLcai(stakeWei),
385
+ minStakeWei,
386
+ headroomLcai,
387
+ belowFloor: registered && stakeWei < minStakeWei,
388
+ claimableWei,
389
+ claimableLcai: toLcai(claimableWei),
390
+ };
391
+ }
392
+ // ---- 7) Typed getJob ----------------------------------------------------
393
+ /** Typed on-chain job (the struct layout has no published ABI). */
394
+ async getJob(jobId) {
395
+ const raw = await this.read(this.jobReg, JOB_REGISTRY_OPERATOR_ABI_PARSED, "getJob", [BigInt(jobId)]);
396
+ return normalizeJob(raw);
397
+ }
398
+ // ---- 1) Stuck-job recovery ---------------------------------------------
399
+ /**
400
+ * Jobs this worker acknowledged but never completed that are now past the
401
+ * completion deadline - the ones that block deregister and are clearable via
402
+ * claimTimeout. Needs the worker's job IDs (from the subgraph / LightNode
403
+ * client); on-chain there is no enumerator. Pass the candidate IDs in.
404
+ */
405
+ async stuckJobs(candidateJobIds) {
406
+ const cfg = await this.config();
407
+ const now = Math.floor(Date.now() / 1000);
408
+ const out = [];
409
+ for (const id of candidateJobIds) {
410
+ const j = await this.getJob(id);
411
+ if (j.worker.toLowerCase() !== this.addr)
412
+ continue;
413
+ if (j.state !== "Acknowledged")
414
+ continue;
415
+ // Deadline: prefer the on-chain deadlineAt, else ackAt + completionTimeout.
416
+ const deadline = j.deadlineAt || (j.ackAt ? j.ackAt + cfg.completionTimeoutSec : 0);
417
+ const past = deadline ? now - deadline : 0;
418
+ out.push({
419
+ // The id the caller passed IS the claimTimeout/getJob key - not j.jobId.
420
+ lookupId: BigInt(id),
421
+ jobId: j.jobId,
422
+ state: j.state,
423
+ ackAt: j.ackAt,
424
+ pastDeadlineSec: past,
425
+ escrowedFeeWei: j.escrowedFeeWei,
426
+ });
427
+ }
428
+ return out;
429
+ }
430
+ /**
431
+ * Clear one stuck (acknowledged-but-never-completed, past-deadline) job.
432
+ * Permissionless on-chain - the worker itself may call it.
433
+ *
434
+ * ⚠️ MAINNET SLASH: this finalizes the job as TimedOut, which realizes the
435
+ * completion-timeout slash on the worker's stake (mainnet:
436
+ * `config().slashBps.completionTimeout` = 5% of stake per job at the time of
437
+ * writing; TESTNET has slashing disabled, so it's free there). This is the
438
+ * deliberate price of unblocking deregister - a stuck acked job otherwise
439
+ * blocks the exit forever. Call `config()` first to see the live slash bps, and
440
+ * only clear jobs you've accepted are lost. Verify eligibility with
441
+ * `stuckJobs([id])` (pastDeadlineSec > 0) before calling.
442
+ */
443
+ async claimTimeout(jobId) {
444
+ return this.send("claimTimeout", this.jobReg, JOB_REGISTRY_OPERATOR_ABI_PARSED, "claimTimeout", [BigInt(jobId)]);
445
+ }
446
+ /**
447
+ * Clear every past-deadline acked job in `candidateJobIds`. Returns the txs and
448
+ * the IDs it skipped (not stuck / not past deadline). Each cleared job realizes
449
+ * its completion-timeout slash - see slashBps.completionTimeout in config().
450
+ */
451
+ async clearStuck(candidateJobIds) {
452
+ const stuck = await this.stuckJobs(candidateJobIds);
453
+ const cleared = [];
454
+ const skipped = [];
455
+ for (const s of stuck) {
456
+ if (s.pastDeadlineSec <= 0) {
457
+ skipped.push(s.lookupId);
458
+ continue;
459
+ }
460
+ // Use lookupId (the getJob/claimTimeout key), NOT the struct's jobId field.
461
+ const tx = await this.claimTimeout(s.lookupId);
462
+ cleared.push({ jobId: s.lookupId, tx });
463
+ }
464
+ return { cleared, skipped };
465
+ }
466
+ // ---- 3) Pre-flight gating ----------------------------------------------
467
+ /**
468
+ * Will deregister succeed right now? Reads the active-job count by checking the
469
+ * candidate IDs (on-chain there's no enumerator, so pass the worker's job IDs).
470
+ * Returns the blocking job IDs and a human reason - BEFORE you spend gas.
471
+ */
472
+ async canDeregister(candidateJobIds = []) {
473
+ const st = await this.status();
474
+ if (!st.registered)
475
+ return { ok: false, blockedBy: [], reason: "Worker is not registered." };
476
+ const stuck = await this.stuckJobs(candidateJobIds);
477
+ const inflight = stuck.map((s) => s.lookupId);
478
+ if (inflight.length === 0) {
479
+ return { ok: true, blockedBy: [], reason: "No in-flight jobs detected; deregister should succeed." };
480
+ }
481
+ return {
482
+ ok: false,
483
+ blockedBy: inflight,
484
+ reason: `${inflight.length} acknowledged-but-unfinished job(s) block deregister. clearStuck() them first (realizes a per-job timeout slash), then deregister.`,
485
+ };
486
+ }
487
+ // ---- 4) Settlement + exit (Docker-free) --------------------------------
488
+ /** Settle one completed job past its dispute window. Permissionless. */
489
+ async releaseJob(jobId) {
490
+ return this.send("releaseJob", this.jobReg, JOB_REGISTRY_OPERATOR_ABI_PARSED, "releaseJob", [BigInt(jobId)]);
491
+ }
492
+ /**
493
+ * Settle every releasable completed job in `candidateJobIds`. Skips jobs still
494
+ * inside the dispute window (DisputeWindowNotElapsed) rather than failing the
495
+ * batch - uses per-job calls so one not-ready job can't revert the rest.
496
+ */
497
+ async releaseAll(candidateJobIds) {
498
+ const released = [];
499
+ const notReady = [];
500
+ for (const id of candidateJobIds) {
501
+ const j = await this.getJob(id);
502
+ if (j.state !== "Completed")
503
+ continue;
504
+ // releaseJob takes the SAME lookup id passed to getJob, not j.jobId.
505
+ const lookupId = BigInt(id);
506
+ try {
507
+ const tx = await this.releaseJob(lookupId);
508
+ released.push({ jobId: lookupId, tx });
509
+ }
510
+ catch (e) {
511
+ if (isWorkerOpError(e) && e.decoded?.name === "DisputeWindowNotElapsed") {
512
+ notReady.push(lookupId);
513
+ continue;
514
+ }
515
+ throw e;
516
+ }
517
+ }
518
+ return { released, notReady };
519
+ }
520
+ /** Pull the worker's earned balance from the JobRegistry into the wallet. */
521
+ async withdraw() {
522
+ return this.send("withdraw", this.jobReg, JOB_REGISTRY_OPERATOR_ABI_PARSED, "withdraw", []);
523
+ }
524
+ /** Deregister - releases stake to the wallet. Reverts (ActiveJobsExist) if any in-flight job remains. */
525
+ async deregister() {
526
+ return this.send("deregister", this.workerReg, WORKER_REGISTRY_ABI_PARSED, "deregisterWorker", []);
527
+ }
528
+ /**
529
+ * The flagship rescue: clear stuck jobs → release any settled completed jobs +
530
+ * withdraw earnings → deregister. The one flow no official tool provides.
531
+ * Pass the worker's known job IDs (from the subgraph). Returns every tx done.
532
+ */
533
+ async unstickAndDeregister(candidateJobIds) {
534
+ const cleared = (await this.clearStuck(candidateJobIds)).cleared;
535
+ const released = (await this.releaseAll(candidateJobIds)).released;
536
+ let withdrawTx;
537
+ const st = await this.status();
538
+ if (st.claimableWei > 0n)
539
+ withdrawTx = await this.withdraw();
540
+ const deregisterTx = await this.deregister();
541
+ return { cleared, released, withdrawTx, deregisterTx };
542
+ }
543
+ // ---- stake ops ----------------------------------------------------------
544
+ async topUpStake(lcai) {
545
+ return this.send("topUpStake", this.workerReg, WORKER_REGISTRY_ABI_PARSED, "topUpStake", [], toWeiFromLcai(lcai));
546
+ }
547
+ async withdrawStake(lcai) {
548
+ return this.send("withdrawStake", this.workerReg, WORKER_REGISTRY_ABI_PARSED, "withdrawStake", [toWeiFromLcai(lcai)]);
549
+ }
550
+ async reinstate() {
551
+ return this.send("reinstate", this.workerReg, WORKER_REGISTRY_ABI_PARSED, "reinstate", []);
552
+ }
553
+ // ---- 5) Real economics --------------------------------------------------
554
+ /**
555
+ * Earnings breakdown: claimable-now (on-chain workerBalance) plus lifetime +
556
+ * pending-release counts derived from the worker record + its jobs. The caller
557
+ * passes the subgraph-sourced worker record and jobs (e.g. from LightNode) so
558
+ * this module stays free of a GraphQL dependency.
559
+ */
560
+ async earnings(input) {
561
+ const claimableWei = (await this.read(this.jobReg, JOB_REGISTRY_OPERATOR_ABI_PARSED, "workerBalance", [this.addr]));
562
+ const pendingRelease = (input.jobs ?? []).filter((j) => /complet/i.test(j.state)).length;
563
+ return {
564
+ claimableLcai: toLcai(claimableWei),
565
+ lifetimeLcai: input.lifetimeEarnedWei ? toLcai(BigInt(input.lifetimeEarnedWei)) : 0,
566
+ pendingReleaseCount: pendingRelease,
567
+ jobsCompleted: input.jobsCompleted ?? 0,
568
+ jobsTimedOut: input.jobsTimedOut ?? 0,
569
+ };
570
+ }
571
+ /**
572
+ * Net profitability: per-job worker fee (from live AIConfig fee split + the
573
+ * model fee) minus an estimated gas cost per job. Pure math over live config so
574
+ * an operator can answer "is this worth running" without a spreadsheet.
575
+ */
576
+ async profitability(input) {
577
+ const cfg = await this.config();
578
+ let feeWei = input.modelFeeWei ?? 0n;
579
+ if (!feeWei && input.modelTag) {
580
+ feeWei = (await this.read(this.network.aiConfig, AI_CONFIG_ABI_PARSED, "getModelFee", [(await import("./inference.js")).modelId(input.modelTag)]));
581
+ }
582
+ const workerFeeWei = (feeWei * BigInt(cfg.feeBps.worker)) / BPS;
583
+ const workerFeeLcaiPerJob = toLcai(workerFeeWei);
584
+ const gasLcaiPerJob = input.gasPerJobLcai ?? 0.001; // ack+complete+release ≈ tiny on LightChain
585
+ const netLcaiPerJob = workerFeeLcaiPerJob - gasLcaiPerJob;
586
+ const breakEvenJobsPerDay = netLcaiPerJob > 0 ? 0 : null;
587
+ const projectedDailyLcai = input.jobsPerDay != null ? netLcaiPerJob * input.jobsPerDay : null;
588
+ return { workerFeeLcaiPerJob, gasLcaiPerJob, netLcaiPerJob, breakEvenJobsPerDay, projectedDailyLcai };
589
+ }
590
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightnode-sdk",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",