@genkaku/protocol 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) 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 +542 -0
  5. package/dist/chain.d.ts +496 -0
  6. package/dist/chain.d.ts.map +1 -0
  7. package/dist/chain.js +763 -0
  8. package/dist/credit.d.ts +35 -0
  9. package/dist/credit.d.ts.map +1 -0
  10. package/dist/credit.js +112 -0
  11. package/dist/dial.d.ts +43 -0
  12. package/dist/dial.d.ts.map +1 -0
  13. package/dist/dial.js +266 -0
  14. package/dist/directory.d.ts +39 -0
  15. package/dist/directory.d.ts.map +1 -0
  16. package/dist/directory.js +149 -0
  17. package/dist/discovery.d.ts +20 -0
  18. package/dist/discovery.d.ts.map +1 -0
  19. package/dist/discovery.js +58 -0
  20. package/dist/hello.d.ts +14 -0
  21. package/dist/hello.d.ts.map +1 -0
  22. package/dist/hello.js +42 -0
  23. package/dist/index.d.ts +32 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +32 -0
  26. package/dist/keys.d.ts +30 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +126 -0
  29. package/dist/llmBridge.d.ts +34 -0
  30. package/dist/llmBridge.d.ts.map +1 -0
  31. package/dist/llmBridge.js +151 -0
  32. package/dist/lp.d.ts +31 -0
  33. package/dist/lp.d.ts.map +1 -0
  34. package/dist/lp.js +116 -0
  35. package/dist/modeManager.d.ts +30 -0
  36. package/dist/modeManager.d.ts.map +1 -0
  37. package/dist/modeManager.js +81 -0
  38. package/dist/netconfig.d.ts +11 -0
  39. package/dist/netconfig.d.ts.map +1 -0
  40. package/dist/netconfig.js +39 -0
  41. package/dist/offer.d.ts +70 -0
  42. package/dist/offer.d.ts.map +1 -0
  43. package/dist/offer.js +84 -0
  44. package/dist/ollama.d.ts +53 -0
  45. package/dist/ollama.d.ts.map +1 -0
  46. package/dist/ollama.js +162 -0
  47. package/dist/p2pNode.d.ts +50 -0
  48. package/dist/p2pNode.d.ts.map +1 -0
  49. package/dist/p2pNode.js +450 -0
  50. package/dist/paywall.d.ts +30 -0
  51. package/dist/paywall.d.ts.map +1 -0
  52. package/dist/paywall.js +84 -0
  53. package/dist/publishOptIn.d.ts +15 -0
  54. package/dist/publishOptIn.d.ts.map +1 -0
  55. package/dist/publishOptIn.js +34 -0
  56. package/dist/receipts.d.ts +61 -0
  57. package/dist/receipts.d.ts.map +1 -0
  58. package/dist/receipts.js +135 -0
  59. package/dist/registry.d.ts +19 -0
  60. package/dist/registry.d.ts.map +1 -0
  61. package/dist/registry.js +50 -0
  62. package/dist/rpc.d.ts +24 -0
  63. package/dist/rpc.d.ts.map +1 -0
  64. package/dist/rpc.js +143 -0
  65. package/dist/status.d.ts +56 -0
  66. package/dist/status.d.ts.map +1 -0
  67. package/dist/status.js +354 -0
  68. package/dist/userSession.d.ts +64 -0
  69. package/dist/userSession.d.ts.map +1 -0
  70. package/dist/userSession.js +125 -0
  71. package/dist/workerSession.d.ts +67 -0
  72. package/dist/workerSession.d.ts.map +1 -0
  73. package/dist/workerSession.js +65 -0
  74. package/package.json +56 -3
  75. package/README.md +0 -3
@@ -0,0 +1,35 @@
1
+ import { type GenkakuPublic } from './chain.ts';
2
+ import { type NetEnvironment } from './netconfig.ts';
3
+ export type WorkerStats = {
4
+ opened: number;
5
+ settled: number;
6
+ /** cumulative settled USDC (6 decimals) */
7
+ spend: bigint;
8
+ };
9
+ export type CreditSnapshot = {
10
+ score: number;
11
+ sessions: number;
12
+ spend: bigint;
13
+ };
14
+ /** The published formula; pure so tests and the UI can reuse it verbatim. */
15
+ export declare function creditScoreOf(stats: Pick<WorkerStats, 'opened' | 'settled'>): number;
16
+ /** Incremental, process-local index. The chain is the source of truth; this is
17
+ * only a cache so a status page never pays for a full re-scan. */
18
+ export declare class CreditTracker {
19
+ private readonly publicC;
20
+ private readonly env;
21
+ private stats;
22
+ private next;
23
+ private running;
24
+ private lastBlock;
25
+ constructor(publicC: GenkakuPublic, env: NetEnvironment);
26
+ /** Scan only the blocks nobody has scanned yet. Concurrent callers share one
27
+ * pass; a failed pass leaves the cursor where it was so the next one retries. */
28
+ refresh(): Promise<void>;
29
+ /** null = this wallet has no history (leave the offer's self-report alone). */
30
+ snapshot(wallet: string): CreditSnapshot | null;
31
+ statsOf(wallet: string): WorkerStats | null;
32
+ blockNumber(): bigint;
33
+ private scan;
34
+ }
35
+ //# sourceMappingURL=credit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credit.d.ts","sourceRoot":"","sources":["../src/credit.ts"],"names":[],"mappings":"AAiBA,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAa,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAe/D,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,GAAG,SAAS,CAAC,GAAG,MAAM,CAOpF;AAID;mEACmE;AACnE,qBAAa,aAAa;IAOtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,KAAK,CAAiC;IAC9C,OAAO,CAAC,IAAI,CAAQ;IACpB,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,SAAS,CAAK;gBAGH,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,cAAc;IAKtC;sFACkF;IAC5E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAS9B,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAQ/C,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAI3C,WAAW,IAAI,MAAM;YAIP,IAAI;CAwCnB"}
package/dist/credit.js ADDED
@@ -0,0 +1,112 @@
1
+ // GENKAKU: behavioural credit (trust layer A) — derived off-chain from escrow
2
+ // events. There is no token, no oracle and no extra transaction: anyone with a
3
+ // Base RPC can re-derive exactly these numbers from the same logs, which is the
4
+ // whole point of putting the formula in the open (strata-migration §36).
5
+ //
6
+ // A worker's score answers one question a payer asks before funding an escrow:
7
+ // "when this peer opens a session, does it finish?" It does NOT attest to model
8
+ // quality or honesty — that is P4.10 (deposits) and the commit-challenge.
9
+ //
10
+ // opened = SessionOpened(worker=w) sessions funded for w
11
+ // settled = SessionSettled(worker=w) sessions that reached settlement
12
+ // completion = settled / opened (in-flight sessions count as not yet)
13
+ // confidence = settled / (settled + 5) (shrink small samples toward 0)
14
+ // score = round(100 * completion * confidence)
15
+ //
16
+ // New worker = 0. 5 settled of 5 = 50. 20 of 20 = 80. 95 of 95 = 95.
17
+ import { collectLogs, ESCROW_ABI, ESCROW_DEPLOY_BLOCK, } from "./chain.js";
18
+ import { CONTRACTS } from "./netconfig.js";
19
+ function escrowEvent(name) {
20
+ const found = ESCROW_ABI.find(entry => entry.type === 'event' && entry.name === name);
21
+ if (found == null || found.type !== 'event') {
22
+ throw new Error('[genkaku:credit] escrow ABI is missing event ' + name);
23
+ }
24
+ return found;
25
+ }
26
+ /** The published formula; pure so tests and the UI can reuse it verbatim. */
27
+ export function creditScoreOf(stats) {
28
+ if (stats.settled <= 0) {
29
+ return 0;
30
+ }
31
+ const completion = stats.opened > 0 ? stats.settled / stats.opened : 0;
32
+ const confidence = stats.settled / (stats.settled + 5);
33
+ return Math.round(100 * Math.min(1, completion) * confidence);
34
+ }
35
+ const SCAN_CHUNK = 50000n;
36
+ /** Incremental, process-local index. The chain is the source of truth; this is
37
+ * only a cache so a status page never pays for a full re-scan. */
38
+ export class CreditTracker {
39
+ publicC;
40
+ env;
41
+ stats = new Map();
42
+ next;
43
+ running = null;
44
+ lastBlock = 0n;
45
+ constructor(publicC, env) {
46
+ this.publicC = publicC;
47
+ this.env = env;
48
+ this.next = ESCROW_DEPLOY_BLOCK[env];
49
+ }
50
+ /** Scan only the blocks nobody has scanned yet. Concurrent callers share one
51
+ * pass; a failed pass leaves the cursor where it was so the next one retries. */
52
+ async refresh() {
53
+ if (CONTRACTS[this.env].escrow === '') {
54
+ return;
55
+ }
56
+ if (this.running != null) {
57
+ return await this.running;
58
+ }
59
+ this.running = this.scan().finally(() => { this.running = null; });
60
+ return await this.running;
61
+ }
62
+ /** null = this wallet has no history (leave the offer's self-report alone). */
63
+ snapshot(wallet) {
64
+ const stats = this.stats.get(wallet.toLowerCase());
65
+ if (stats == null) {
66
+ return null;
67
+ }
68
+ return { score: creditScoreOf(stats), sessions: stats.settled, spend: stats.spend };
69
+ }
70
+ statsOf(wallet) {
71
+ return this.stats.get(wallet.toLowerCase()) ?? null;
72
+ }
73
+ blockNumber() {
74
+ return this.lastBlock;
75
+ }
76
+ async scan() {
77
+ const latest = await this.publicC.getBlockNumber();
78
+ if (latest < this.next) {
79
+ return;
80
+ }
81
+ const escrow = CONTRACTS[this.env].escrow;
82
+ const openedEvent = escrowEvent('SessionOpened');
83
+ const settledEvent = escrowEvent('SessionSettled');
84
+ const bump = (wallet, change) => {
85
+ const key = wallet.toLowerCase();
86
+ const row = this.stats.get(key) ?? { opened: 0, settled: 0, spend: 0n };
87
+ change(row);
88
+ this.stats.set(key, row);
89
+ };
90
+ for (let start = this.next; start <= latest; start += SCAN_CHUNK) {
91
+ const end = start + SCAN_CHUNK - 1n > latest ? latest : start + SCAN_CHUNK - 1n;
92
+ const [opens, settles] = await Promise.all([
93
+ collectLogs(this.publicC, escrow, openedEvent, start, end),
94
+ collectLogs(this.publicC, escrow, settledEvent, start, end),
95
+ ]);
96
+ for (const log of opens) {
97
+ const a = log.args;
98
+ bump(a.worker, (row) => { row.opened += 1; });
99
+ }
100
+ for (const log of settles) {
101
+ const a = log.args;
102
+ bump(a.worker, (row) => {
103
+ row.settled += 1;
104
+ row.spend += a.spend;
105
+ });
106
+ }
107
+ }
108
+ this.next = latest + 1n;
109
+ this.lastBlock = latest;
110
+ }
111
+ }
112
+ //# sourceMappingURL=credit.js.map
package/dist/dial.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Libp2p } from 'libp2p';
2
+ import { type HelloProof } from './hello.ts';
3
+ import { type Offer } from './offer.ts';
4
+ import type { OpenEnvelopeV0 } from './workerSession.ts';
5
+ export type WorkerTransport = {
6
+ open(env: OpenEnvelopeV0): Promise<void>;
7
+ onToken(cb: (delta: string) => void): void;
8
+ onEnd(cb: () => void): void;
9
+ onError(cb: (e: Error) => void): void;
10
+ /** sig: EIP-191 signature over the packed receipt digest (P4.4 priced
11
+ * sessions; empty string for escrowless self-tests). */
12
+ sendReceipt(jobId: string, seq: number, amount: bigint, sig?: string, inputHash?: `0x${string}`, outputHash?: `0x${string}`): Promise<void>;
13
+ close(): Promise<void>;
14
+ };
15
+ export declare function dialWorker(node: Libp2p, addr: string): Promise<WorkerTransport>;
16
+ /** Fetch + deserialize a worker's offer board (one dial, whole-board read). */
17
+ /** P4.9e: ask the peer to prove it holds the EVM key of the wallet it puts on
18
+ * its offers. Throws when the answer is missing, malformed, signed by another
19
+ * key, or claims a different PeerId than the one we dialed. */
20
+ export declare function helloWorker(node: Libp2p, addr: string): Promise<HelloProof>;
21
+ export declare function fetchOffers(node: Libp2p, addr: string): Promise<Offer[]>;
22
+ /** P4.5 seed discovery: ask a bootstrap seed which peers it currently sees.
23
+ * The seed is a pure matchmaker — one frame in, one frame out, no storage. */
24
+ export declare function fetchSeedPeers(node: Libp2p, addr: string): Promise<string[]>;
25
+ /**
26
+ * P4.4 settle co-sign (one dial, one frame each way): the user, at session
27
+ * end, presents (jobId, escrowId, receiptHash, spend); the worker checks its
28
+ * held max-cumulative receipt (anti-inflation: it never signs beyond the
29
+ * largest receipt the client piped — P1 §8) and returns an EIP-191 signature
30
+ * over keccak256(abi.encodePacked(escrowId, receiptHash, spend)) — the exact
31
+ * digest JobEscrow.settle recovers against the session's worker address.
32
+ */
33
+ export declare function requestSettleCosign(node: Libp2p, addr: string, args: {
34
+ jobId: string;
35
+ escrowId: string;
36
+ receiptHash: `0x${string}`;
37
+ spend: bigint;
38
+ }): Promise<{
39
+ ok: boolean;
40
+ sig: `0x${string}` | null;
41
+ reason?: string;
42
+ }>;
43
+ //# sourceMappingURL=dial.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dial.d.ts","sourceRoot":"","sources":["../src/dial.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAEpC,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAA;AAEnE,OAAO,EAAY,KAAK,KAAK,EAAE,MAAM,YAAY,CAAA;AAGjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAQxD,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxC,OAAO,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAA;IAC1C,KAAK,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IAC3B,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAA;IACrC;6DACyD;IACzD,WAAW,CACT,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,GAAG,CAAC,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,KAAK,MAAM,EAAE,EACzB,UAAU,CAAC,EAAE,KAAK,MAAM,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC,CAAA;IAChB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB,CAAA;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAsIrF;AAED,+EAA+E;AAC/E;;gEAEgE;AAChE,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAqDjF;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC,CAqB9E;AAuBD;+EAC+E;AAC/E,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAmBlF;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,KAAK,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACnF,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,KAAK,MAAM,EAAE,GAAG,IAAI,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAgCtE"}
package/dist/dial.js ADDED
@@ -0,0 +1,266 @@
1
+ // GENKAKU: user-side worker transport (P4.3).
2
+ // Dials a worker's sidecar over real libp2p: open stream carries the envelope
3
+ // out and tok/0 frames in; receipts flow over a second stream so the worker's
4
+ // open handler never blocks on them. Offers are fetched per-discovered peer
5
+ // and sorted locally — the marketplace columns never touch the wire twice.
6
+ import { multiaddr } from '@multiformats/multiaddr';
7
+ import { newNonce, verifyHello } from "./hello.js";
8
+ import { FrameReader, lpEncode, WriteQueue } from "./lp.js";
9
+ import { fromWire } from "./offer.js";
10
+ import { ZERO_HASH } from "./receipts.js";
11
+ import { peerIdOf } from "./registry.js";
12
+ /** How long to wait for the worker's admission ack before assuming an older
13
+ * worker that streams without one. */
14
+ const ADMISSION_TIMEOUT_MS = 8_000;
15
+ export async function dialWorker(node, addr) {
16
+ const ma = multiaddr(addr);
17
+ const oq = new WriteQueue();
18
+ const rq = new WriteQueue();
19
+ let openStream = null;
20
+ let receiptStream = null;
21
+ let openSinkDone = Promise.resolve();
22
+ let receiptSinkDone = Promise.resolve();
23
+ const tokenCbs = new Set();
24
+ const endCbs = new Set();
25
+ const errCbs = new Set();
26
+ /** Resolved once the worker admits the session (ok/0) or sends a token;
27
+ * rejected when it refuses (err/0). An older worker never acks, so a
28
+ * timeout keeps this from hanging on compatibility. */
29
+ let admitted = null;
30
+ let refused = null;
31
+ function fail(e) {
32
+ const err = e instanceof Error ? e : new Error(String(e));
33
+ for (const cb of errCbs) {
34
+ cb(err);
35
+ }
36
+ }
37
+ function startPosixRead(s) {
38
+ void (async () => {
39
+ const reader = new FrameReader();
40
+ let dead = false;
41
+ try {
42
+ for await (const chunk of s.source) {
43
+ reader.push(chunk);
44
+ while (!dead) {
45
+ const f = reader.next();
46
+ if (f == null) {
47
+ break;
48
+ }
49
+ if (f.proto === 'ok/0') {
50
+ admitted?.();
51
+ }
52
+ else if (f.proto === 'err/0') {
53
+ // The worker refused the session (P4.9d paywall). This must reach
54
+ // the caller as an error, never as an empty successful answer.
55
+ const err = new Error(`${f.code ?? 'refused'}: ${f.message ?? 'worker refused the session'}`);
56
+ err.code = f.code;
57
+ dead = true;
58
+ refused?.(err);
59
+ for (const cb of errCbs) {
60
+ cb(err);
61
+ }
62
+ }
63
+ else if (f.proto === 'tok/0') {
64
+ admitted?.();
65
+ for (const cb of tokenCbs) {
66
+ cb(String(f.delta ?? ''));
67
+ }
68
+ }
69
+ }
70
+ if (dead) {
71
+ break;
72
+ }
73
+ }
74
+ if (!dead) {
75
+ for (const cb of endCbs) {
76
+ cb();
77
+ }
78
+ }
79
+ }
80
+ catch (e) {
81
+ fail(e);
82
+ }
83
+ })();
84
+ }
85
+ return {
86
+ async open(env) {
87
+ openStream = await node.dialProtocol(ma, '/genkaku/open/0');
88
+ // errors surface via the read side
89
+ openSinkDone = openStream.sink(oq.source()).catch(() => { });
90
+ oq.push(lpEncode(env));
91
+ const admission = new Promise((resolve, reject) => {
92
+ admitted = resolve;
93
+ refused = reject;
94
+ });
95
+ startPosixRead(openStream);
96
+ // Compatibility: a worker that predates the ack simply starts streaming,
97
+ // so wait for a token forever would be wrong — but neither should a slow
98
+ // model look like a refusal. Bound the wait, then trust the stream.
99
+ let timer = null;
100
+ const timeout = new Promise((resolve) => {
101
+ timer = setTimeout(resolve, ADMISSION_TIMEOUT_MS);
102
+ });
103
+ try {
104
+ await Promise.race([admission, timeout]);
105
+ }
106
+ finally {
107
+ if (timer != null)
108
+ clearTimeout(timer);
109
+ admitted = null;
110
+ refused = null;
111
+ }
112
+ },
113
+ onToken(cb) { tokenCbs.add(cb); },
114
+ onEnd(cb) { endCbs.add(cb); },
115
+ onError(cb) { errCbs.add(cb); },
116
+ async sendReceipt(jobId, seq, amount, sig = '', inputHash = ZERO_HASH, outputHash = ZERO_HASH) {
117
+ if (!receiptStream) {
118
+ receiptStream = await node.dialProtocol(ma, '/genkaku/receipt/0');
119
+ receiptSinkDone = receiptStream.sink(rq.source()).catch(() => { });
120
+ }
121
+ rq.push(lpEncode({
122
+ proto: 'receipt/0',
123
+ jobId,
124
+ seq,
125
+ amount: amount.toString(),
126
+ sig,
127
+ inputHash,
128
+ outputHash,
129
+ }));
130
+ },
131
+ async close() {
132
+ oq.end();
133
+ rq.end();
134
+ // drain queued writes before the CLOSE frame hits the wire
135
+ await Promise.allSettled([openSinkDone, receiptSinkDone]);
136
+ await Promise.allSettled([openStream?.close(), receiptStream?.close()]);
137
+ },
138
+ };
139
+ }
140
+ /** Fetch + deserialize a worker's offer board (one dial, whole-board read). */
141
+ /** P4.9e: ask the peer to prove it holds the EVM key of the wallet it puts on
142
+ * its offers. Throws when the answer is missing, malformed, signed by another
143
+ * key, or claims a different PeerId than the one we dialed. */
144
+ export async function helloWorker(node, addr) {
145
+ const expectedPeer = peerIdOf(addr);
146
+ const nonce = newNonce();
147
+ const stream = await node.dialProtocol(multiaddr(addr), '/genkaku/hello/0');
148
+ let reply;
149
+ try {
150
+ stream.sink([lpEncode({ proto: 'hello/0', nonce })]).catch(() => { });
151
+ const reader = new FrameReader();
152
+ for await (const chunk of stream.source) {
153
+ reader.push(chunk);
154
+ const frame = reader.next();
155
+ if (frame != null) {
156
+ reply = frame;
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ finally {
162
+ await stream.close().catch(() => { });
163
+ }
164
+ if (reply?.proto !== 'hello/0' || reply.ok !== true) {
165
+ throw new Error(`hello: ${reply?.reason ?? 'no reply from the worker'}`);
166
+ }
167
+ if (typeof reply.peerId !== 'string' || typeof reply.wallet !== 'string' || typeof reply.sig !== 'string') {
168
+ throw new Error('hello: malformed proof');
169
+ }
170
+ // The signature binds the peerId it was made for; a mismatch means the peer
171
+ // replayed someone else's proof or the address was rewritten in transit.
172
+ if (expectedPeer != null && reply.peerId !== expectedPeer) {
173
+ throw new Error(`hello: peer ${reply.peerId} is not ${expectedPeer}`);
174
+ }
175
+ const proof = { peerId: reply.peerId, wallet: reply.wallet, sig: reply.sig };
176
+ if (!(await verifyHello(proof, nonce))) {
177
+ throw new Error(`hello: signature does not recover to ${proof.wallet}`);
178
+ }
179
+ return proof;
180
+ }
181
+ export async function fetchOffers(node, addr) {
182
+ const offers = await fetchOfferBoard(node, addr);
183
+ let proof;
184
+ try {
185
+ proof = await helloWorker(node, addr);
186
+ }
187
+ catch {
188
+ // An older worker (or one with no wallet) cannot prove anything; the board
189
+ // stays unverified and the caller decides whether that is acceptable.
190
+ return offers;
191
+ }
192
+ return offers.map((offer) => {
193
+ if (offer.workerWallet?.startsWith('0x') && offer.workerWallet.toLowerCase() !== proof.wallet.toLowerCase()) {
194
+ throw new Error(`offers: ${proof.peerId} claims ${offer.workerWallet} but proved ${proof.wallet}`);
195
+ }
196
+ // The proven wallet wins: a worker that omits its wallet still becomes
197
+ // payable, and a lying one was already rejected above.
198
+ return { ...offer, workerWallet: proof.wallet, walletVerified: true };
199
+ });
200
+ }
201
+ async function fetchOfferBoard(node, addr) {
202
+ const s = await node.dialProtocol(multiaddr(addr), '/genkaku/offers/0');
203
+ const reader = new FrameReader();
204
+ let board = [];
205
+ for await (const chunk of s.source) {
206
+ reader.push(chunk);
207
+ const f = reader.next();
208
+ if (f?.proto === 'offers/0' && Array.isArray(f.board)) {
209
+ board = f.board;
210
+ break;
211
+ }
212
+ }
213
+ await s.close().catch(() => { });
214
+ return board.filter(w => w.proto === 'offers/0').map(w => fromWire(w));
215
+ }
216
+ /** P4.5 seed discovery: ask a bootstrap seed which peers it currently sees.
217
+ * The seed is a pure matchmaker — one frame in, one frame out, no storage. */
218
+ export async function fetchSeedPeers(node, addr) {
219
+ const s = await node.dialProtocol(multiaddr(addr), '/genkaku/peers/0');
220
+ const reader = new FrameReader();
221
+ for await (const chunk of s.source) {
222
+ reader.push(chunk);
223
+ const f = reader.next();
224
+ if (f?.proto === 'peers/0' && Array.isArray(f.peers)) {
225
+ await s.close().catch(() => { });
226
+ return f.peers.filter((p) => typeof p === 'string' && p.startsWith('/'));
227
+ }
228
+ }
229
+ await s.close().catch(() => { });
230
+ return [];
231
+ }
232
+ /**
233
+ * P4.4 settle co-sign (one dial, one frame each way): the user, at session
234
+ * end, presents (jobId, escrowId, receiptHash, spend); the worker checks its
235
+ * held max-cumulative receipt (anti-inflation: it never signs beyond the
236
+ * largest receipt the client piped — P1 §8) and returns an EIP-191 signature
237
+ * over keccak256(abi.encodePacked(escrowId, receiptHash, spend)) — the exact
238
+ * digest JobEscrow.settle recovers against the session's worker address.
239
+ */
240
+ export async function requestSettleCosign(node, addr, args) {
241
+ const s = await node.dialProtocol(multiaddr(addr), '/genkaku/settle/0');
242
+ const reader = new FrameReader();
243
+ let reply = null;
244
+ const sinkDone = s.sink([lpEncode({
245
+ proto: 'settle/0',
246
+ jobId: args.jobId,
247
+ escrowId: args.escrowId,
248
+ receiptHash: args.receiptHash,
249
+ spend: args.spend.toString(),
250
+ })]).catch(() => { });
251
+ for await (const chunk of s.source) {
252
+ reader.push(chunk);
253
+ const f = reader.next();
254
+ if (f?.proto === 'settle/0') {
255
+ reply = f;
256
+ break;
257
+ }
258
+ }
259
+ await sinkDone; // empty the queue BEFORE CLOSE or the request frame dies
260
+ await s.close().catch(() => { });
261
+ if (reply?.ok === true && typeof reply.sig === 'string' && reply.sig.startsWith('0x')) {
262
+ return { ok: true, sig: reply.sig };
263
+ }
264
+ return { ok: false, sig: null, reason: reply?.reason ?? 'worker did not co-sign' };
265
+ }
266
+ //# sourceMappingURL=dial.js.map
@@ -0,0 +1,39 @@
1
+ import { type GenkakuPublic } from './chain.ts';
2
+ import type { NetEnvironment } from './netconfig.ts';
3
+ export type DirectoryCacheEntry = {
4
+ wallet: `0x${string}`;
5
+ peerId: `0x${string}`;
6
+ locator: string;
7
+ updatedAt: number;
8
+ blockNumber: string;
9
+ };
10
+ export type DirectoryCache = {
11
+ version: 1;
12
+ env: NetEnvironment;
13
+ lastBlock: string;
14
+ entries: DirectoryCacheEntry[];
15
+ updatedAt: number;
16
+ };
17
+ type Logger = {
18
+ info: (msg: string, ...args: unknown[]) => void;
19
+ warn: (msg: string, ...args: unknown[]) => void;
20
+ };
21
+ export declare function directoryPath(keyDir: string): string;
22
+ export declare function loadDirectory(keyDir: string, env: NetEnvironment): DirectoryCache;
23
+ export type DirectorySync = {
24
+ entries: DirectoryCacheEntry[];
25
+ scannedFrom: bigint;
26
+ scannedTo: bigint;
27
+ added: number;
28
+ removed: number;
29
+ };
30
+ /** Scan forward from the checkpoint, merge into the cache, and persist.
31
+ * Returns the merged directory (live entries only). */
32
+ export declare function syncDirectory(publicC: GenkakuPublic, env: NetEnvironment, keyDir: string, log: Logger): Promise<DirectorySync>;
33
+ /** Every dialable multiaddr in the directory, newest worker first. */
34
+ export declare function workerAddrs(cache: DirectoryCache): string[];
35
+ /** Drop a wallet from the cache after repeated dial failures, so a dead worker
36
+ * stops costing a connection attempt on every boot until it advertises again. */
37
+ export declare function dropEntry(keyDir: string, env: NetEnvironment, wallet: string): void;
38
+ export {};
39
+ //# sourceMappingURL=directory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directory.d.ts","sourceRoot":"","sources":["../src/directory.ts"],"names":[],"mappings":"AAeA,OAAO,EAKL,KAAK,aAAa,EACnB,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAGpD,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,KAAK,MAAM,EAAE,CAAA;IACrB,MAAM,EAAE,KAAK,MAAM,EAAE,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,CAAC,CAAA;IACV,GAAG,EAAE,cAAc,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,mBAAmB,EAAE,CAAA;IAC9B,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,KAAK,MAAM,GAAG;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAAC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;CAAE,CAAA;AAElH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,cAAc,GAAG,cAAc,CAiBjF;AAgCD,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,mBAAmB,EAAE,CAAA;IAC9B,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED;wDACwD;AACxD,wBAAsB,aAAa,CACjC,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,aAAa,CAAC,CAoDxB;AAYD,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,EAAE,CAc3D;AAED;kFACkF;AAClF,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKnF"}
@@ -0,0 +1,149 @@
1
+ // GENKAKU: on-chain worker discovery (P4.9b).
2
+ //
3
+ // Replaces the bootstrap seed. The directory is built from `WorkerLocated` logs
4
+ // and never from a server we run: a cold client with an empty config and no
5
+ // cache can find workers using nothing but a public Base RPC.
6
+ //
7
+ // Two cost controls make that affordable at scale:
8
+ // 1. a persisted checkpoint (`lastBlock`) so a normal boot scans only the
9
+ // blocks since the last run — usually a handful;
10
+ // 2. an on-disk entry cache, so the offer list is available even when the
11
+ // chain read fails (offline start, RPC trouble).
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { readWorkerDeactivations, readWorkerLocators, REGISTRY_DEPLOY_BLOCK, } from "./chain.js";
15
+ import { peerIdDigestOf } from "./registry.js";
16
+ export function directoryPath(keyDir) {
17
+ return join(keyDir, 'directory.json');
18
+ }
19
+ export function loadDirectory(keyDir, env) {
20
+ const empty = {
21
+ version: 1,
22
+ env,
23
+ lastBlock: (REGISTRY_DEPLOY_BLOCK[env] - 1n).toString(),
24
+ entries: [],
25
+ updatedAt: 0,
26
+ };
27
+ try {
28
+ if (!existsSync(directoryPath(keyDir)))
29
+ return empty;
30
+ const raw = JSON.parse(readFileSync(directoryPath(keyDir), 'utf8'));
31
+ if (raw?.version !== 1 || raw.env !== env || !Array.isArray(raw.entries))
32
+ return empty;
33
+ return raw;
34
+ }
35
+ catch {
36
+ return empty;
37
+ }
38
+ }
39
+ function saveDirectory(keyDir, cache) {
40
+ try {
41
+ mkdirSync(keyDir, { recursive: true });
42
+ writeFileSync(directoryPath(keyDir), JSON.stringify(cache, null, 2), { mode: 0o600 });
43
+ }
44
+ catch {
45
+ // the cache is an optimisation; never fail discovery because of it
46
+ }
47
+ }
48
+ /** Does the locator actually point at the peerId the wallet pinned?
49
+ * A worker could advertise someone else's address (or an old one); the digest
50
+ * compare is the only thing that makes the directory trustworthy. */
51
+ function locatorMatchesPeer(entry) {
52
+ const wanted = entry.peerId.toLowerCase();
53
+ const ids = entry.locator
54
+ .split(/[;\n]+/)
55
+ .map(part => part.split('/p2p/').pop()?.split('/')[0])
56
+ .filter((id) => id != null && id !== '');
57
+ if (ids.length === 0)
58
+ return false;
59
+ return ids.every((id) => {
60
+ try {
61
+ return peerIdDigestOf(id).toLowerCase() === wanted;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ });
67
+ }
68
+ /** Scan forward from the checkpoint, merge into the cache, and persist.
69
+ * Returns the merged directory (live entries only). */
70
+ export async function syncDirectory(publicC, env, keyDir, log) {
71
+ const cache = loadDirectory(keyDir, env);
72
+ const floor = REGISTRY_DEPLOY_BLOCK[env] - 1n;
73
+ const scannedFrom = BigInt(cache.lastBlock) + 1n > floor ? BigInt(cache.lastBlock) + 1n : floor;
74
+ const latest = await publicC.getBlockNumber();
75
+ const byWallet = new Map();
76
+ for (const entry of cache.entries)
77
+ byWallet.set(entry.wallet.toLowerCase(), entry);
78
+ let added = 0;
79
+ let removed = 0;
80
+ if (latest >= scannedFrom) {
81
+ const [located, dead] = await Promise.all([
82
+ readWorkerLocators(publicC, env, { fromBlock: scannedFrom, toBlock: latest }),
83
+ readWorkerDeactivations(publicC, env, { fromBlock: scannedFrom, toBlock: latest }),
84
+ ]);
85
+ for (const entry of located) {
86
+ if (!locatorMatchesPeer(entry)) {
87
+ log.warn('directory: %s advertises a locator that does not contain its pinned peerId — ignored', entry.wallet);
88
+ continue;
89
+ }
90
+ const key = entry.wallet.toLowerCase();
91
+ const prev = byWallet.get(key);
92
+ if (prev == null || BigInt(prev.blockNumber) < entry.blockNumber)
93
+ added++;
94
+ byWallet.set(key, toCacheEntry(entry));
95
+ }
96
+ for (const gone of dead) {
97
+ const key = gone.wallet.toLowerCase();
98
+ const prev = byWallet.get(key);
99
+ if (prev != null && BigInt(prev.blockNumber) < gone.blockNumber) {
100
+ byWallet.delete(key);
101
+ removed++;
102
+ }
103
+ }
104
+ log.info('directory: scanned %s..%s (%d located, %d deactivated, %d new, %d dropped)', scannedFrom.toString(), latest.toString(), located.length, dead.length, added, removed);
105
+ }
106
+ const next = {
107
+ version: 1,
108
+ env,
109
+ lastBlock: latest.toString(),
110
+ entries: [...byWallet.values()],
111
+ updatedAt: Date.now(),
112
+ };
113
+ saveDirectory(keyDir, next);
114
+ return { entries: next.entries, scannedFrom, scannedTo: latest, added, removed };
115
+ }
116
+ function toCacheEntry(entry) {
117
+ return {
118
+ wallet: entry.wallet,
119
+ peerId: entry.peerId,
120
+ locator: entry.locator,
121
+ updatedAt: entry.updatedAt,
122
+ blockNumber: entry.blockNumber.toString(),
123
+ };
124
+ }
125
+ /** Every dialable multiaddr in the directory, newest worker first. */
126
+ export function workerAddrs(cache) {
127
+ const addrs = [];
128
+ const newestFirst = [...cache.entries].sort((a, b) => {
129
+ const delta = BigInt(b.blockNumber) - BigInt(a.blockNumber);
130
+ return delta === 0n ? 0 : delta > 0n ? 1 : -1;
131
+ });
132
+ for (const entry of newestFirst) {
133
+ for (const addr of entry.locator.split(/[;\n]+/).map(s => s.trim())) {
134
+ if (addr.startsWith('/'))
135
+ addrs.push(addr);
136
+ }
137
+ }
138
+ return [...new Set(addrs)];
139
+ }
140
+ /** Drop a wallet from the cache after repeated dial failures, so a dead worker
141
+ * stops costing a connection attempt on every boot until it advertises again. */
142
+ export function dropEntry(keyDir, env, wallet) {
143
+ const cache = loadDirectory(keyDir, env);
144
+ const next = cache.entries.filter(e => e.wallet.toLowerCase() !== wallet.toLowerCase());
145
+ if (next.length === cache.entries.length)
146
+ return;
147
+ saveDirectory(keyDir, { ...cache, entries: next, updatedAt: Date.now() });
148
+ }
149
+ //# sourceMappingURL=directory.js.map