@genkaku/protocol 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bridge.d.ts +46 -0
  3. package/dist/bridge.d.ts.map +1 -0
  4. package/dist/bridge.js +609 -0
  5. package/dist/capabilities.d.ts +50 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +66 -0
  8. package/dist/capabilities.test.d.ts +2 -0
  9. package/dist/capabilities.test.d.ts.map +1 -0
  10. package/dist/capabilities.test.js +39 -0
  11. package/dist/chain.d.ts +496 -0
  12. package/dist/chain.d.ts.map +1 -0
  13. package/dist/chain.js +763 -0
  14. package/dist/credit.d.ts +35 -0
  15. package/dist/credit.d.ts.map +1 -0
  16. package/dist/credit.js +112 -0
  17. package/dist/dial.d.ts +51 -0
  18. package/dist/dial.d.ts.map +1 -0
  19. package/dist/dial.js +287 -0
  20. package/dist/directory.d.ts +39 -0
  21. package/dist/directory.d.ts.map +1 -0
  22. package/dist/directory.js +149 -0
  23. package/dist/discovery.d.ts +20 -0
  24. package/dist/discovery.d.ts.map +1 -0
  25. package/dist/discovery.js +58 -0
  26. package/dist/hello.d.ts +14 -0
  27. package/dist/hello.d.ts.map +1 -0
  28. package/dist/hello.js +42 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +32 -0
  32. package/dist/keys.d.ts +30 -0
  33. package/dist/keys.d.ts.map +1 -0
  34. package/dist/keys.js +126 -0
  35. package/dist/llmBridge.d.ts +100 -0
  36. package/dist/llmBridge.d.ts.map +1 -0
  37. package/dist/llmBridge.js +284 -0
  38. package/dist/lp.d.ts +31 -0
  39. package/dist/lp.d.ts.map +1 -0
  40. package/dist/lp.js +116 -0
  41. package/dist/modeManager.d.ts +30 -0
  42. package/dist/modeManager.d.ts.map +1 -0
  43. package/dist/modeManager.js +81 -0
  44. package/dist/netconfig.d.ts +11 -0
  45. package/dist/netconfig.d.ts.map +1 -0
  46. package/dist/netconfig.js +39 -0
  47. package/dist/offer.d.ts +89 -0
  48. package/dist/offer.d.ts.map +1 -0
  49. package/dist/offer.js +86 -0
  50. package/dist/offer.test.d.ts +2 -0
  51. package/dist/offer.test.d.ts.map +1 -0
  52. package/dist/offer.test.js +33 -0
  53. package/dist/ollama.d.ts +53 -0
  54. package/dist/ollama.d.ts.map +1 -0
  55. package/dist/ollama.js +162 -0
  56. package/dist/p2pNode.d.ts +52 -0
  57. package/dist/p2pNode.d.ts.map +1 -0
  58. package/dist/p2pNode.js +454 -0
  59. package/dist/paywall.d.ts +30 -0
  60. package/dist/paywall.d.ts.map +1 -0
  61. package/dist/paywall.js +84 -0
  62. package/dist/publishOptIn.d.ts +15 -0
  63. package/dist/publishOptIn.d.ts.map +1 -0
  64. package/dist/publishOptIn.js +34 -0
  65. package/dist/receipts.d.ts +61 -0
  66. package/dist/receipts.d.ts.map +1 -0
  67. package/dist/receipts.js +135 -0
  68. package/dist/registry.d.ts +19 -0
  69. package/dist/registry.d.ts.map +1 -0
  70. package/dist/registry.js +50 -0
  71. package/dist/rpc.d.ts +24 -0
  72. package/dist/rpc.d.ts.map +1 -0
  73. package/dist/rpc.js +143 -0
  74. package/dist/status.d.ts +56 -0
  75. package/dist/status.d.ts.map +1 -0
  76. package/dist/status.js +354 -0
  77. package/dist/userSession.d.ts +77 -0
  78. package/dist/userSession.d.ts.map +1 -0
  79. package/dist/userSession.js +132 -0
  80. package/dist/workerSession.d.ts +87 -0
  81. package/dist/workerSession.d.ts.map +1 -0
  82. package/dist/workerSession.js +97 -0
  83. package/dist/workerSession.test.d.ts +2 -0
  84. package/dist/workerSession.test.d.ts.map +1 -0
  85. package/dist/workerSession.test.js +49 -0
  86. package/package.json +56 -3
  87. package/README.md +0 -3
@@ -0,0 +1,454 @@
1
+ // GENKAKU: libp2p nodes (P4.2 sidecar / P4.3 client / P4.4 settle co-sign).
2
+ // Workers serve the /genkaku/* protocols (sidecar); users get a bare dial node.
3
+ // The mode manager gates handler installs — user mode never constructs the
4
+ // worker side, and worker modes never open remote sessions (D-block defense
5
+ // in depth, P4-ADAPTATION §2).
6
+ import { noise } from '@chainsafe/libp2p-noise';
7
+ import { yamux } from '@chainsafe/libp2p-yamux';
8
+ import { Secp256k1PrivateKey } from '@libp2p/crypto/keys';
9
+ import { identify } from '@libp2p/identify';
10
+ import { createFromPrivKey, createSecp256k1PeerId } from '@libp2p/peer-id-factory';
11
+ import { tcp } from '@libp2p/tcp';
12
+ import { createLibp2p } from 'libp2p';
13
+ import { toBytes } from 'viem';
14
+ import { settleDigest, walletAddress } from "./chain.js";
15
+ import { signHello } from "./hello.js";
16
+ import { FrameReader, lpEncode, WriteQueue } from "./lp.js";
17
+ import { CHAIN_ID, CONTRACTS } from "./netconfig.js";
18
+ import { cumulativeBilled, toWire } from "./offer.js";
19
+ import { digestForHex, inputHashOf, outputHashOf, ReceiptLedger, recoverSigner, sessionIdHex, signDigest, ZERO_HASH, } from "./receipts.js";
20
+ import { POLICY_VERSION, policyHashOf } from "./registry.js";
21
+ // re-exported for consumers of the codec (tests, user-side dialer)
22
+ export { FrameReader, lpEncode, lpFrames } from "./lp.js";
23
+ export const GENKAKU_PROTOCOLS = [
24
+ '/genkaku/open/0',
25
+ '/genkaku/token/0',
26
+ '/genkaku/receipt/0',
27
+ '/genkaku/settle/0',
28
+ '/genkaku/offers/0',
29
+ '/genkaku/policy/0',
30
+ '/genkaku/hello/0',
31
+ ];
32
+ // libp2p 1.9.4's own default nodeInfo omits the REQUIRED `userAgent` field,
33
+ // which makes @libp2p/identify crash on start (getAgentVersion →
34
+ // nodeInfo.userAgent → undefined → uint8ArrayFromString throws). Provide a
35
+ // full NodeInfo; also advertised in identify metadata.
36
+ const NODE_INFO = { name: 'genkaku', version: '0.1.0', userAgent: 'genkaku/0.1.0 (desktop)' };
37
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
38
+ /** Bounded window AFTER the token EOF in which the client's through-end
39
+ * receipt may still land before we stop watching. Sidecar-scope: the receipt
40
+ * protocol stream stays alive independently of the released token stream. */
41
+ const RECEIPT_DRAIN_MS = 250;
42
+ /** Slack for worker/client clock differences in the receipt clock bound. */
43
+ const CLOCK_SLACK_MS = 2_000;
44
+ /** A bare libp2p node for the USER side (no genkaku handlers — dials only). */
45
+ export async function createUserNode(listen = []) {
46
+ const peerId = await createSecp256k1PeerId();
47
+ return createLibp2p({
48
+ peerId,
49
+ nodeInfo: NODE_INFO,
50
+ addresses: { listen },
51
+ transports: [tcp()],
52
+ connectionEncryption: [noise()],
53
+ streamMuxers: [yamux()],
54
+ services: { identify: identify() },
55
+ });
56
+ }
57
+ export async function createSidecar(opts) {
58
+ // D-block: do not even construct listeners without a sharing-capable mode.
59
+ const mode = opts.modeManager.current();
60
+ if (mode.mode === 'user') {
61
+ throw new Error('[genkaku:sidecar] refusing to build worker listeners in user mode (D-block)');
62
+ }
63
+ opts.modeManager.installWorkerListeners();
64
+ const ledger = new ReceiptLedger();
65
+ const inbox = new Map();
66
+ /** jobId → escrowId as declared in the open envelope (receipt/bind side). */
67
+ const escrowByJob = new Map();
68
+ /** jobId → wall time the open envelope arrived (the clock-bound anchor). */
69
+ const openedAtByJob = new Map();
70
+ /** jobId → the escrow payer proven by the paywall (receipt signer must match). */
71
+ const payerByJob = new Map();
72
+ /** jobId → commitment to the request the open envelope asked for. */
73
+ const inputHashByJob = new Map();
74
+ /** jobId → the answer text emitted so far (the reveal side of a challenge). */
75
+ const outputByJob = new Map();
76
+ /** jobId → the model the open envelope asked for (bounds price lookup). */
77
+ const sessionModelByJob = new Map();
78
+ /** jobId → the client's billing tick (granularity, wallet: the worker owns price). */
79
+ const sessionUnitMsByJob = new Map();
80
+ let sessions = 0;
81
+ let currentCatalog = [...opts.catalog];
82
+ // The worker-side receipt constructor: EXACTLY what the user signed —
83
+ // chainId/escrow from NETCONFIG (one deployment per env), sessionId from
84
+ // (jobId, escrowId). Nothing the caller controls feeds the struct, so a
85
+ // forged sig can't bind a different amount or stream handle.
86
+ const receiptOf = (jobId, held) => {
87
+ if (held == null) {
88
+ return null;
89
+ }
90
+ return {
91
+ chainId: CHAIN_ID[ENV],
92
+ escrow: (CONTRACTS[ENV].escrow || ZERO_ADDRESS),
93
+ sessionId: sessionIdHex(jobId, held.escrowId),
94
+ seq: held.seq,
95
+ amount: held.amount,
96
+ inputHash: held.inputHash,
97
+ outputHash: held.outputHash,
98
+ };
99
+ };
100
+ // Identity: secp256k1 peer key — libp2p supports it natively and it is the
101
+ // SAME curve as the EVM wallet, so the P4.4 registry binding proves one key
102
+ // owns both sides of (PeerId, wallet) address. With a worker wallet, derive
103
+ // the PeerId from that exact key; otherwise keep a random identity.
104
+ const peerId = opts.workerKey != null
105
+ ? await createFromPrivKey(new Secp256k1PrivateKey(toBytes(opts.workerKey)))
106
+ : await createSecp256k1PeerId();
107
+ const node = await createLibp2p({
108
+ peerId,
109
+ nodeInfo: NODE_INFO,
110
+ addresses: { listen: opts.listen },
111
+ transports: [tcp()],
112
+ connectionEncryption: [noise()],
113
+ streamMuxers: [yamux()],
114
+ services: { identify: identify() },
115
+ });
116
+ await node.handle('/genkaku/open/0', ({ connection, stream }) => {
117
+ void (async () => {
118
+ // Read ONLY the open envelope, then start serving — the client keeps its
119
+ // write half open for the session, so a drain-to-EOS read would deadlock
120
+ // with muxer window backpressure.
121
+ const reader = new FrameReader();
122
+ let env;
123
+ for await (const chunk of stream.source) {
124
+ reader.push(chunk);
125
+ const frame = reader.next();
126
+ if (frame != null) {
127
+ env = frame;
128
+ break;
129
+ }
130
+ }
131
+ if (!env || (env.proto !== 'open/0' && env.proto !== 'open/1')) {
132
+ await stream.close();
133
+ return;
134
+ }
135
+ let payer = null;
136
+ if (opts.paywall != null) {
137
+ const verdict = await opts.paywall({
138
+ jobId: env.jobId,
139
+ escrowId: env.escrowId || '0',
140
+ clientPeer: env.clientPeer ?? '',
141
+ }).catch(() => ({ ok: false, code: 'paywall-error', reason: 'paywall check failed' }));
142
+ if (!verdict.ok) {
143
+ opts.sessionEvents?.({
144
+ type: 'session-error',
145
+ message: `refused ${env.jobId}: ${verdict.code} (${verdict.reason})`,
146
+ });
147
+ try {
148
+ await stream.sink([lpEncode({
149
+ proto: 'err/0',
150
+ jobId: env.jobId,
151
+ code: verdict.code,
152
+ message: verdict.reason,
153
+ })]);
154
+ }
155
+ catch {
156
+ // the client may already have gone; the close below still ends it
157
+ }
158
+ await stream.close().catch(() => { });
159
+ return;
160
+ }
161
+ payer = verdict.payer;
162
+ }
163
+ if (payer != null)
164
+ payerByJob.set(env.jobId, payer);
165
+ inputHashByJob.set(env.jobId, inputHashOf({
166
+ model: env.model,
167
+ prompt: env.prompt,
168
+ system: env.system,
169
+ maxTokens: env.maxTokens,
170
+ }));
171
+ outputByJob.set(env.jobId, '');
172
+ escrowByJob.set(env.jobId, env.escrowId || '0');
173
+ openedAtByJob.set(env.jobId, Date.now());
174
+ sessionModelByJob.set(env.jobId, env.model);
175
+ sessionUnitMsByJob.set(env.jobId, env.billing?.unitMs ?? 60_000);
176
+ // ONE sink for the whole session: token frames push through a durable
177
+ // write queue (a second stream.sink() call would explode it).
178
+ const dq = new WriteQueue();
179
+ const sinkDone = stream.sink(dq.source()).catch(() => { });
180
+ // Admission ack (P4.9d): the client waits for this before it reports a
181
+ // success to its own caller, so a paywall refusal reaches the caller as
182
+ // an error instead of an empty completion.
183
+ dq.push(lpEncode({ proto: 'ok/0', jobId: env.jobId }));
184
+ const emit = (e) => opts.sessionEvents?.(e);
185
+ const session = await import("./workerSession.js").then(({ createWorkerSession }) => createWorkerSession(env, {
186
+ modeManager: opts.modeManager,
187
+ source: opts.source,
188
+ ...(opts.chatSource === undefined ? {} : { chatSource: opts.chatSource }),
189
+ onToolCall: (call) => {
190
+ dq.push(lpEncode({ proto: 'tool/0', ...call }));
191
+ },
192
+ ledger,
193
+ emit,
194
+ onDelta: (delta) => {
195
+ outputByJob.set(env.jobId, (outputByJob.get(env.jobId) ?? '') + delta);
196
+ dq.push(lpEncode({ proto: 'tok/0', delta }));
197
+ },
198
+ latestReceipt: () => inbox.get(env.jobId) ?? null,
199
+ }));
200
+ sessions += 1;
201
+ try {
202
+ await session.run();
203
+ // P2 §3 / P4.4: the client sends its THROUGH-END receipt only after
204
+ // the token EOF reaches it — i.e. after this handler would have
205
+ // closed. Release the token stream first (never meter artificial
206
+ // hold time), then poll the shared inbox for one bounded window and
207
+ // push any post-end receipt through the SAME strictly-increasing
208
+ // ledger rule as the live session did. The settle co-sign (below)
209
+ // defends the final evidence either way.
210
+ dq.end();
211
+ await sinkDone;
212
+ await stream.close();
213
+ const deadline = Date.now() + RECEIPT_DRAIN_MS;
214
+ while (Date.now() < deadline) {
215
+ const held = inbox.get(env.jobId);
216
+ if (held != null) {
217
+ const verdict = ledger.offer(env.jobId, held.seq, held.amount);
218
+ if (verdict.accepted) {
219
+ emit({ type: 'receipt-accepted', seq: held.seq, amount: held.amount });
220
+ break;
221
+ }
222
+ // Stale (already merged into the ledger during the live session):
223
+ // keep the window open — a newer receipt may still be in flight.
224
+ }
225
+ await new Promise(r => setTimeout(r, 20));
226
+ }
227
+ }
228
+ finally {
229
+ sessions -= 1;
230
+ if (!dq.ended) { // drain-parked path already closed — safe no-op here
231
+ dq.end();
232
+ await sinkDone;
233
+ await stream.close();
234
+ }
235
+ }
236
+ void connection;
237
+ })();
238
+ });
239
+ await node.handle('/genkaku/receipt/0', ({ stream }) => {
240
+ void (async () => {
241
+ const reader = new FrameReader();
242
+ for await (const chunk of stream.source) {
243
+ reader.push(chunk);
244
+ while (true) {
245
+ const f = reader.next();
246
+ if (f == null) {
247
+ break;
248
+ }
249
+ if (f.proto === 'receipt/0' && typeof f.seq === 'number' && typeof f.amount === 'string' && typeof f.jobId === 'string') {
250
+ // escrowId comes from the OPEN envelope, never from this frame —
251
+ // the binding is the session's, not the receipt-sender's claim.
252
+ const stored = {
253
+ seq: f.seq,
254
+ amount: BigInt(f.amount),
255
+ sig: f.sig ?? '',
256
+ escrowId: escrowByJob.get(f.jobId) ?? '0',
257
+ inputHash: (f.inputHash ?? ZERO_HASH),
258
+ outputHash: (f.outputHash ?? ZERO_HASH),
259
+ };
260
+ // P4.9f commit-challenge: the client may not pay for a different
261
+ // request, and a non-zero output commitment must match the text
262
+ // this worker actually emitted.
263
+ const expectedInput = inputHashByJob.get(f.jobId);
264
+ if (expectedInput != null && stored.inputHash !== expectedInput) {
265
+ opts.sessionEvents?.({ type: 'session-error', message: `receipt ${stored.seq}: input commitment mismatch` });
266
+ continue;
267
+ }
268
+ if (stored.outputHash !== ZERO_HASH
269
+ && stored.outputHash !== outputHashOf(outputByJob.get(f.jobId) ?? '')) {
270
+ opts.sessionEvents?.({ type: 'session-error', message: `receipt ${stored.seq}: output commitment mismatch` });
271
+ continue;
272
+ }
273
+ // P4.4: priced receipts are SIGNATURE-BOUND evidence — a forged
274
+ // or anonymous offer never reaches the escrow at settle.
275
+ if (opts.workerKey != null && stored.escrowId !== '0') {
276
+ const bad = await rejectUnsigned(stored, f.jobId);
277
+ if (bad) {
278
+ opts.sessionEvents?.({ type: 'session-error', message: `receipt ${stored.seq}: unsigned/malformed offer dropped` });
279
+ continue;
280
+ }
281
+ const over = overClockBound(stored, f.jobId);
282
+ if (over != null) {
283
+ opts.sessionEvents?.({ type: 'session-error', message: `receipt ${stored.seq} for ${stored.amount}: ${over}` });
284
+ continue;
285
+ }
286
+ }
287
+ // Inbox ONLY: the ledger entry is claimed by the session that owns
288
+ // this jobId (single ingestion point — a pre-offer here would make
289
+ // the session's own offer stale).
290
+ inbox.set(f.jobId, stored);
291
+ }
292
+ }
293
+ }
294
+ await stream.close();
295
+ })();
296
+ });
297
+ // P4.4 settle co-sign: one request frame, one reply frame. The digest is
298
+ // rebuilt WORKER-SIDE from the held receipt (the caller's receiptHash is
299
+ // only compared against it), and the spend must equal the held max
300
+ // cumulative receipt — anti-inflation by construction (P1 §8). JobEscrow.
301
+ // settle() then recovers this EIP-191 sig against the session's worker.
302
+ await node.handle('/genkaku/settle/0', ({ stream }) => {
303
+ void (async () => {
304
+ const reader = new FrameReader();
305
+ let req;
306
+ for await (const chunk of stream.source) {
307
+ reader.push(chunk);
308
+ const f = reader.next();
309
+ if (f != null) {
310
+ req = f;
311
+ break;
312
+ }
313
+ }
314
+ const reply = (ok, sig, reason) => stream.sink([lpEncode({ proto: 'settle/0', ok, sig, reason })]).then(() => stream.close());
315
+ if (req?.proto !== 'settle/0') {
316
+ await reply(false, undefined, 'bad frame');
317
+ return;
318
+ }
319
+ if (opts.workerKey == null) {
320
+ await reply(false, undefined, 'worker has no wallet');
321
+ return;
322
+ }
323
+ const jobId = String(req.jobId ?? '');
324
+ const escrowId = escrowByJob.get(jobId) ?? '0';
325
+ const held = inbox.get(jobId);
326
+ if (escrowId === '0' || held == null) {
327
+ await reply(false, undefined, 'no priced session bound for this jobId');
328
+ return;
329
+ }
330
+ // Defense in depth: the caller's escrowId must agree with the session's
331
+ // binding. A mismatch can never settle on chain anyway (the worker's
332
+ // digest carries ONLY the envelope escrowId; JobEscrow.settle recovers
333
+ // against the session id), but failing here keeps wire errors local.
334
+ if (req.escrowId != null && String(req.escrowId) !== escrowId) {
335
+ await reply(false, undefined, 'escrowId does not match the session envelope');
336
+ return;
337
+ }
338
+ if (req.spend == null) {
339
+ await reply(false, undefined, 'spend is required');
340
+ return;
341
+ }
342
+ const spend = BigInt(req.spend);
343
+ if (spend !== held.amount) {
344
+ await reply(false, undefined, 'spend does not match the held max cumulative receipt');
345
+ return;
346
+ }
347
+ const receipt = receiptOf(jobId, held);
348
+ const receiptHash = receipt == null ? null : digestForHex(receipt);
349
+ if (receiptHash == null || String(req.receiptHash ?? '').toLowerCase() !== receiptHash.toLowerCase()) {
350
+ await reply(false, undefined, 'receiptHash does not match the held receipt');
351
+ return;
352
+ }
353
+ const sig = await signDigest(opts.workerKey, settleDigest(BigInt(escrowId), receiptHash, spend));
354
+ await reply(true, sig);
355
+ })();
356
+ });
357
+ // P4.9e identity proof: one nonce in, one signed proof out. The client
358
+ // recovers the signer and refuses offers whose wallet it cannot prove.
359
+ await node.handle('/genkaku/hello/0', ({ stream }) => {
360
+ void (async () => {
361
+ const reader = new FrameReader();
362
+ let req;
363
+ for await (const chunk of stream.source) {
364
+ reader.push(chunk);
365
+ const f = reader.next();
366
+ if (f != null) {
367
+ req = f;
368
+ break;
369
+ }
370
+ }
371
+ const reply = (body) => stream.sink([lpEncode({ proto: 'hello/0', ...body })]).then(() => stream.close());
372
+ if (req?.proto !== 'hello/0' || typeof req.nonce !== 'string' || !req.nonce.startsWith('0x')) {
373
+ await reply({ ok: false, reason: 'bad frame' });
374
+ return;
375
+ }
376
+ if (opts.workerKey == null) {
377
+ await reply({ ok: false, reason: 'worker has no wallet' });
378
+ return;
379
+ }
380
+ const proof = await signHello(opts.workerKey, node.peerId.toString(), req.nonce);
381
+ await reply({ ok: true, peerId: proof.peerId, wallet: proof.wallet, sig: proof.sig });
382
+ })();
383
+ });
384
+ // The offer board: a user dials this on every discovered peer and sorts
385
+ // locally (P4.3). bigint prices travel as decimal strings (OfferWire); the
386
+ // wallet here is a CLAIM — the client only trusts it after /genkaku/hello/0
387
+ // proves the key (P4.9e) or the registry binds it (P4.5).
388
+ await node.handle('/genkaku/offers/0', ({ stream }) => {
389
+ const wallet = opts.workerKey != null ? walletAddress(opts.workerKey) : undefined;
390
+ const board = currentCatalog.map(o => toWire(wallet == null ? o : { ...o, workerWallet: wallet }));
391
+ void stream.sink([lpEncode({ proto: 'offers/0', board })]).then(() => stream.close());
392
+ });
393
+ // token is served in-session (open handler); policy is reserved v0.
394
+ await node.handle('/genkaku/token/0', ({ stream }) => { void stream.close(); });
395
+ await node.handle('/genkaku/policy/0', ({ stream }) => {
396
+ // P4.5: the wire advertises the SAME digest the worker pins on
397
+ // WorkerRegistry, so a user can compare registry.policyHash against it.
398
+ void stream.sink([lpEncode({ proto: 'policy/0', policyHash: policyHashOf(POLICY_VERSION), policyVersion: POLICY_VERSION })]).then(() => stream.close());
399
+ });
400
+ // ── helpers (close over netconfig + maps, never over the wire) ──────────
401
+ /** Starts true when a priced receipt cannot be signature-verified. P4.9e:
402
+ * the signer must also equal the escrow's payer, so evidence and money name
403
+ * the same party (a third party can no longer sign for someone's escrow). */
404
+ async function rejectUnsigned(stored, jobId) {
405
+ if (stored.sig === '' || !stored.sig.startsWith('0x')) {
406
+ return true;
407
+ }
408
+ const receipt = receiptOf(jobId, stored);
409
+ if (receipt == null) {
410
+ return true;
411
+ }
412
+ const signer = await recoverSigner(toBytes(digestForHex(receipt)), stored.sig);
413
+ if (signer == null) {
414
+ return true;
415
+ }
416
+ const payer = payerByJob.get(jobId);
417
+ if (payer != null && payer !== ZERO_ADDRESS && signer.toLowerCase() !== payer.toLowerCase()) {
418
+ return true;
419
+ }
420
+ return false;
421
+ }
422
+ /** The P2 §3 clock bound: a receipt can never exceed what the worker's OWN
423
+ * catalog price + wall time since the open envelope allow. Returns a
424
+ * reason string when the receipt is over, null when within the bound. */
425
+ function overClockBound(stored, jobId) {
426
+ const openedAt = openedAtByJob.get(jobId);
427
+ if (openedAt == null) {
428
+ return 'session was never opened here';
429
+ }
430
+ const env = currentCatalog.find(o => o.model === sessionModelByJob.get(jobId)) ?? currentCatalog[0];
431
+ if (env == null) {
432
+ return 'worker has no priced model';
433
+ }
434
+ const unitMs = sessionUnitMsByJob.get(jobId) ?? 60_000;
435
+ const elapsed = Date.now() - openedAt + CLOCK_SLACK_MS;
436
+ const max = cumulativeBilled(env.pricePerHour, unitMs, Math.max(elapsed, unitMs));
437
+ if (stored.amount > max) {
438
+ return `exceeds the clock bound (${stored.amount} > ${max})`;
439
+ }
440
+ return null;
441
+ }
442
+ return {
443
+ node,
444
+ peerAddrs: () => node.getMultiaddrs().map(m => m.toString()),
445
+ stop: async () => { await opts.modeManager.removeWorkerListeners(); await node.stop(); },
446
+ activeSessions: () => sessions,
447
+ peerIdStr: () => peerId.toString(),
448
+ setCatalog: (offers) => { currentCatalog = [...offers]; },
449
+ };
450
+ }
451
+ // data-plane net env the sidecar binds receipts to (single deployment — the
452
+ // base mainnet bindings stay empty until P6).
453
+ const ENV = 'base-sepolia';
454
+ //# sourceMappingURL=p2pNode.js.map
@@ -0,0 +1,30 @@
1
+ import { type GenkakuPublic } from './chain.ts';
2
+ import type { NetEnvironment } from './netconfig.ts';
3
+ export type PaywallRequest = {
4
+ jobId: string;
5
+ escrowId: string;
6
+ clientPeer: string;
7
+ };
8
+ export type PaywallVerdict = {
9
+ ok: true; /** the escrow's payer — receipts must be signed by this wallet */
10
+ payer: `0x${string}`;
11
+ } | {
12
+ ok: false;
13
+ code: string;
14
+ reason: string;
15
+ };
16
+ export type Paywall = (request: PaywallRequest) => Promise<PaywallVerdict>;
17
+ export type PaywallOptions = {
18
+ publicC: GenkakuPublic;
19
+ env: NetEnvironment;
20
+ /** the wallet that must be the escrow's payee */
21
+ worker: `0x${string}`;
22
+ /** dev escape hatch: allow `escrowId: "0"` (unpriced) sessions */
23
+ allowUnpriced?: boolean;
24
+ now?: () => number;
25
+ log?: {
26
+ warn: (msg: string, ...args: unknown[]) => void;
27
+ };
28
+ };
29
+ export declare function createPaywall(opts: PaywallOptions): Paywall;
30
+ //# sourceMappingURL=paywall.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paywall.d.ts","sourceRoot":"","sources":["../src/paywall.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,YAAY,CAAA;AAClE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAEpD,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,EAAE,EAAE,IAAI,CAAC,CAAC,kEAAkE;IAAC,KAAK,EAAE,KAAK,MAAM,EAAE,CAAA;CAAE,GACrG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAE/C,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,cAAc,CAAC,CAAA;AAK1E,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,aAAa,CAAA;IACtB,GAAG,EAAE,cAAc,CAAA;IACnB,iDAAiD;IACjD,MAAM,EAAE,KAAK,MAAM,EAAE,CAAA;IACrB,kEAAkE;IAClE,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,GAAG,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;KAAE,CAAA;CAC1D,CAAA;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CA4C3D"}
@@ -0,0 +1,84 @@
1
+ // GENKAKU: worker paywall (P4.9d).
2
+ //
3
+ // A public directory makes a worker dialable by strangers. Without a gate,
4
+ // "dialable" means "free GPU": anyone who scans the chain can open a stream and
5
+ // burn inference time. The gate is therefore not a billing nicety — it is the
6
+ // condition for publishing an address at all.
7
+ //
8
+ // The check is cheap and self-contained: the client names an escrow id in the
9
+ // OPEN envelope; the worker reads that session from JobEscrow and serves only
10
+ // if the record exists, pays THIS worker, is still open, and has money in it.
11
+ // The money itself never moves here — settlement stays the client's signed
12
+ // receipt + JobEscrow.settle.
13
+ //
14
+ // Deliberate properties:
15
+ // * a verdict is cached for a few seconds (a retry storm must not become RPC
16
+ // load) but never longer than the escrow can change state;
17
+ // * the amount is a ceiling, not a reservation: two clients may name the same
18
+ // escrow, and the ledger still caps what can be settled;
19
+ // * `unpriced` (escrowId "0") is allowed ONLY when the operator explicitly
20
+ // disabled the paywall, so a dev session can never be reached by a stranger
21
+ // that merely guessed "0".
22
+ import { readEscrowSession } from "./chain.js";
23
+ const VERDICT_TTL_MS = 15_000;
24
+ const ZERO = '0x0000000000000000000000000000000000000000';
25
+ export function createPaywall(opts) {
26
+ const cache = new Map();
27
+ const now = opts.now ?? Date.now;
28
+ return async (request) => {
29
+ const raw = (request.escrowId ?? '').trim();
30
+ if (raw === '' || raw === '0') {
31
+ // Unpriced sessions have no payer to certify; the dev escape hatch keeps
32
+ // them working for an explicit opt-out only.
33
+ return opts.allowUnpriced === true
34
+ ? { ok: true, payer: ZERO }
35
+ : { ok: false, code: 'payment-required', reason: 'unpriced session rejected: open an escrow first' };
36
+ }
37
+ let id;
38
+ try {
39
+ id = BigInt(raw);
40
+ }
41
+ catch {
42
+ return { ok: false, code: 'bad-escrow', reason: 'escrow id is not an integer' };
43
+ }
44
+ const key = id.toString();
45
+ const hit = cache.get(key);
46
+ if (hit != null && now() - hit.at < VERDICT_TTL_MS)
47
+ return hit.verdict;
48
+ let verdict;
49
+ try {
50
+ const session = await readEscrowSession(opts.publicC, opts.env, id);
51
+ verdict = judge(session, opts.worker);
52
+ }
53
+ catch (error) {
54
+ // An RPC failure must not silently turn into free compute: fail closed,
55
+ // but say so, because a flaky endpoint should be retried by the client.
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ opts.log?.warn('paywall: escrow %s read failed (%s)', key, message);
58
+ verdict = { ok: false, code: 'chain-unavailable', reason: 'could not verify the escrow; retry shortly' };
59
+ }
60
+ // Cache failures too: a stranger replaying a dead escrow id must not turn
61
+ // into one chain read per attempt.
62
+ cache.set(key, { at: now(), verdict });
63
+ return verdict;
64
+ };
65
+ }
66
+ function judge(session, worker) {
67
+ if (session == null) {
68
+ return { ok: false, code: 'unknown-escrow', reason: 'escrow session does not exist' };
69
+ }
70
+ if (session.worker.toLowerCase() !== worker.toLowerCase()) {
71
+ return { ok: false, code: 'wrong-worker', reason: 'escrow pays a different worker' };
72
+ }
73
+ if (session.settled || session.refunded) {
74
+ return { ok: false, code: 'closed-escrow', reason: 'escrow is already settled or refunded' };
75
+ }
76
+ if (session.amount <= 0n) {
77
+ return { ok: false, code: 'empty-escrow', reason: 'escrow has no funds' };
78
+ }
79
+ if (session.user.toLowerCase() === ZERO) {
80
+ return { ok: false, code: 'unknown-escrow', reason: 'escrow has no payer' };
81
+ }
82
+ return { ok: true, payer: session.user };
83
+ }
84
+ //# sourceMappingURL=paywall.js.map
@@ -0,0 +1,15 @@
1
+ /** File name under the GenKaKu key directory. */
2
+ export declare const PUBLISH_OPT_IN_FILE = "publish.json";
3
+ /**
4
+ * Read the worker's directory opt-in.
5
+ * @param keyDir - GenKaKu state directory.
6
+ * @returns true only when the worker explicitly opted in.
7
+ */
8
+ export declare function readPublishOptIn(keyDir: string): boolean;
9
+ /**
10
+ * Persist the worker's directory opt-in.
11
+ * @param keyDir - GenKaKu state directory.
12
+ * @param publish - whether a public address may be written to the chain.
13
+ */
14
+ export declare function writePublishOptIn(keyDir: string, publish: boolean): void;
15
+ //# sourceMappingURL=publishOptIn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publishOptIn.d.ts","sourceRoot":"","sources":["../src/publishOptIn.ts"],"names":[],"mappings":"AAUA,iDAAiD;AACjD,eAAO,MAAM,mBAAmB,iBAAiB,CAAA;AAEjD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAOxD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxE"}
@@ -0,0 +1,34 @@
1
+ // GENKAKU: worker directory opt-in (P4.7b).
2
+ //
3
+ // `config.publishAddress` is the deployment default and stays false for
4
+ // privacy. The settings page writes this per-machine opt-in instead of editing
5
+ // the profile YAML, so a non-technical worker can join the global directory
6
+ // after reading the warning that a public address is permanent on chain.
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ /** File name under the GenKaKu key directory. */
10
+ export const PUBLISH_OPT_IN_FILE = 'publish.json';
11
+ /**
12
+ * Read the worker's directory opt-in.
13
+ * @param keyDir - GenKaKu state directory.
14
+ * @returns true only when the worker explicitly opted in.
15
+ */
16
+ export function readPublishOptIn(keyDir) {
17
+ try {
18
+ const value = JSON.parse(readFileSync(join(keyDir, PUBLISH_OPT_IN_FILE), 'utf8'));
19
+ return typeof value === 'object' && value !== null && value.publish === true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /**
26
+ * Persist the worker's directory opt-in.
27
+ * @param keyDir - GenKaKu state directory.
28
+ * @param publish - whether a public address may be written to the chain.
29
+ */
30
+ export function writePublishOptIn(keyDir, publish) {
31
+ mkdirSync(keyDir, { recursive: true, mode: 0o700 });
32
+ writeFileSync(join(keyDir, PUBLISH_OPT_IN_FILE), `${JSON.stringify({ publish }, undefined, 2)}\n`, { mode: 0o600 });
33
+ }
34
+ //# sourceMappingURL=publishOptIn.js.map