mycomesh-consumer 0.3.0 → 0.5.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/src/consumer.mjs CHANGED
@@ -9,9 +9,14 @@ import { rpcCall } from "./chain.mjs";
9
9
  import { openDelta, openResponse, outputText, prepareRequest, verifiedProvider } from "./protocol.mjs";
10
10
  import { recordRequest } from "./disputes.mjs";
11
11
  import { chatStream, responseStream } from "./sse.mjs";
12
+ import { consoleRoutes, localRequestAllowed } from "./console.mjs";
13
+ import { pinnedOptions, splitPin } from "./tlspin.mjs";
14
+ import { compareRank, rankKey, reputation } from "./reputation.mjs";
15
+ import { hashApiKey } from "./tenants.mjs";
12
16
 
13
17
  const PROVIDER_CACHE_MS = 30_000;
14
18
  const RELAY_CACHE_MS = 300_000;
19
+ const REPUTATION_CACHE_MS = 600_000;
15
20
  const MAX_BODY = 16 * 1024 * 1024;
16
21
 
17
22
  export function loadNetwork(path) {
@@ -21,14 +26,21 @@ export function loadNetwork(path) {
21
26
  return network;
22
27
  }
23
28
 
24
- export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 } = {}) {
29
+ async function tlsOptions(target, ca, pin) {
30
+ if (target.protocol !== "https:") return {};
31
+ if (pin) return pinnedOptions(target.href, pin);
32
+ return ca ? { ca: [...rootCertificates, ca] } : {};
33
+ }
34
+
35
+ export async function httpJson(url, { method = "GET", body, ca, pin, timeoutMs = 330_000 } = {}) {
25
36
  const target = new URL(url);
26
37
  const data = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
27
38
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
39
+ const tls = await tlsOptions(target, ca, pin);
28
40
  return new Promise((resolvePromise, reject) => {
29
41
  const req = send(target, {
30
42
  method, headers: { accept: "application/json", ...(data ? { "content-type": "application/json", "content-length": data.length } : {}) },
31
- ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
43
+ ...tls,
32
44
  }, (res) => {
33
45
  const chunks = [];
34
46
  let size = 0;
@@ -50,15 +62,16 @@ export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 }
50
62
  }
51
63
 
52
64
  /** POST expecting NDJSON; each line goes to onLine. Non-streamed replies resolve as JSON. */
53
- export function httpNdjson(url, { body, ca, timeoutMs = 340_000, onLine }) {
65
+ export async function httpNdjson(url, { body, ca, pin, timeoutMs = 340_000, onLine }) {
54
66
  const target = new URL(url);
55
67
  const data = Buffer.from(JSON.stringify(body));
56
68
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
69
+ const tls = await tlsOptions(target, ca, pin);
57
70
  return new Promise((resolvePromise, reject) => {
58
71
  const req = send(target, {
59
72
  method: "POST",
60
73
  headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
61
- ...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
74
+ ...tls,
62
75
  }, (res) => {
63
76
  const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
64
77
  let buffered = "";
@@ -103,10 +116,9 @@ export async function directoryRelays(network) {
103
116
  const base = Number(BigInt(`0x${raw.slice(0, 64)}`));
104
117
  if (!Number(BigInt(`0x${raw.slice(64, 128)}`))) continue;
105
118
  const entry = raw.slice(base * 2);
106
- relays.push({
107
- signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
108
- url: decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))).replace(/\/+$/, ""),
109
- });
119
+ const [url, pin] = splitPin(decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))));
120
+ relays.push({ owner: `0x${entry.slice(24, 64)}`.toLowerCase(), signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
121
+ url: url.replace(/\/+$/, ""), pin });
110
122
  }
111
123
  return relays;
112
124
  }
@@ -126,6 +138,25 @@ export class Consumer {
126
138
  this.relayCache = null;
127
139
  }
128
140
 
141
+ /** On-chain reputation and verified probe verdicts for every Provider in view, cached. */
142
+ async reputations() {
143
+ if (this.reputationCache && Date.now() - this.reputationCache.at < REPUTATION_CACHE_MS) return this.reputationCache.value;
144
+ const descriptors = new Map();
145
+ const relaysByOwner = {};
146
+ for (const relay of await this.relays()) {
147
+ try {
148
+ const owner = relay.owner || `0x${(await rpcCall(this.network.rpc_urls, "eth_call", [{ to: this.network.settlement,
149
+ data: encodeCall("relaySignerOwner(address)", [["address", relay.signer]]) }, "latest"])).slice(26)}`;
150
+ relaysByOwner[owner.toLowerCase()] = relay;
151
+ } catch {}
152
+ try { for (const descriptor of await this.providers(relay)) descriptors.set(descriptor.provider_signer, descriptor); } catch {}
153
+ }
154
+ let value = {};
155
+ try { value = await reputation(this, [...descriptors.values()], relaysByOwner); } catch {}
156
+ this.reputationCache = { at: Date.now(), value };
157
+ return value;
158
+ }
159
+
129
160
  /** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
130
161
  async relays() {
131
162
  if (this.relayCache && Date.now() - this.relayCache.at < RELAY_CACHE_MS) return this.relayCache.relays;
@@ -135,7 +166,7 @@ export class Consumer {
135
166
  for (const relay of await directoryRelays(this.network)) {
136
167
  if (known.has(relay.signer)) continue;
137
168
  try {
138
- const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, timeoutMs: 5_000 });
169
+ const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 5_000 });
139
170
  if (status === 200 && String(body.relay_signer).toLowerCase() === relay.signer
140
171
  && String(body.settlement).toLowerCase() === this.deployment.settlement) {
141
172
  relays.push(relay);
@@ -151,7 +182,7 @@ export class Consumer {
151
182
  async providers(relay) {
152
183
  const cached = this.cache.get(relay.url);
153
184
  if (cached && Date.now() - cached.at < PROVIDER_CACHE_MS) return cached.providers;
154
- const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, timeoutMs: 10_000 });
185
+ const { status, body } = await this.fetchJson(`${relay.url}/providers`, { ca: this.network.tls_ca, pin: relay.pin, timeoutMs: 10_000 });
155
186
  if (status !== 200) throw new Error(`Relay ${relay.url} /providers returned ${status}`);
156
187
  const now = this.now();
157
188
  const providers = (body.providers || []).filter((descriptor) => {
@@ -179,7 +210,9 @@ export class Consumer {
179
210
  && (!provider || descriptor.provider_signer === provider.toLowerCase()));
180
211
  }
181
212
  catch (error) { lastError = error; continue; }
182
- candidates.sort((a, b) => (a.prices.output_per_1k - b.prices.output_per_1k) || (a.prices.input_per_1k - b.prices.input_per_1k));
213
+ // What the chain proves ranks first: recent fraud, verified probe failures, reputation; then price.
214
+ const records = await this.reputations().catch(() => ({}));
215
+ candidates.sort((a, b) => compareRank(rankKey(a, records[a.provider_signer]), rankKey(b, records[b.provider_signer])));
183
216
  for (const descriptor of candidates) {
184
217
  const prepared = prepareRequest({
185
218
  descriptor, deployment: this.deployment, keyPrivate: this.keyPrivate, relaySigner: relay.signer,
@@ -190,7 +223,7 @@ export class Consumer {
190
223
  let final = null;
191
224
  try {
192
225
  reply = onDelta
193
- ? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, onLine: (line) => {
226
+ ? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, pin: relay.pin, onLine: (line) => {
194
227
  if (line.type === "delta") {
195
228
  streamed.push(openDelta(prepared, line.sealed, streamed.length, this.now()));
196
229
  onDelta(streamed.at(-1));
@@ -198,7 +231,7 @@ export class Consumer {
198
231
  final = line;
199
232
  }
200
233
  } })
201
- : await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
234
+ : await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca, pin: relay.pin });
202
235
  } catch (error) {
203
236
  // The request may have reached the Relay: never replay it elsewhere.
204
237
  throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
@@ -241,7 +274,9 @@ async function readJson(req) {
241
274
  }
242
275
 
243
276
  /** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
244
- export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey } = {}) {
277
+ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir, tenants = () => ({}) } = {}) {
278
+ const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
279
+ const bearer = (req) => /^Bearer (.+)$/.exec(String(req.headers.authorization || ""))?.[1];
245
280
  const server = createServer(async (req, res) => {
246
281
  let writer = null;
247
282
  const send = (status, body, type = "application/json") => {
@@ -249,11 +284,26 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
249
284
  res.end(type === "application/json" ? JSON.stringify(body) : body);
250
285
  };
251
286
  try {
252
- if (apiKey && req.headers.authorization !== `Bearer ${apiKey}`) return send(401, openaiError("invalid API key", "unauthorized"));
253
287
  const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
288
+ // A tenant's API key selects its own payment key and budget; tenants may call from anywhere.
289
+ const tenant = path.startsWith("/v1/") && bearer(req) ? tenants()[hashApiKey(bearer(req))] : undefined;
290
+ if (!tenant && !localRequestAllowed(req, server.address().port)) {
291
+ return send(403, openaiError("only this machine may use the local node", "forbidden"));
292
+ }
293
+ const active = tenant || consumer;
294
+ const route = routes[`${req.method} ${path}`];
295
+ if (route) {
296
+ if (req.method === "POST" && !String(req.headers["content-type"] || "").startsWith("application/json")) {
297
+ return send(415, { error: "JSON body required" });
298
+ }
299
+ const result = await route(req.method === "POST" ? await readJson(req) : {});
300
+ return result?.type ? send(200, result.body, result.type) : send(200, result);
301
+ }
302
+ if (!tenant && bearer(req) && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
303
+ if (!tenant && apiKey && bearer(req) !== apiKey) return send(401, openaiError("invalid API key", "unauthorized"));
254
304
  if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
255
305
  if (req.method === "GET" && path === "/v1/models") {
256
- return send(200, { object: "list", data: (await consumer.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
306
+ return send(200, { object: "list", data: (await active.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
257
307
  }
258
308
  if (req.method !== "POST" || !["/v1/responses", "/v1/chat/completions", "/responses", "/chat/completions"].includes(path)) {
259
309
  return send(404, openaiError("not found", "not_found"));
@@ -269,7 +319,7 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
269
319
  delete rest.max_tokens;
270
320
  delete rest.max_completion_tokens;
271
321
  if (!stream) {
272
- const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
322
+ const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
273
323
  return send(200, response.output);
274
324
  }
275
325
  // Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
@@ -278,12 +328,16 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
278
328
  res.write(text);
279
329
  };
280
330
  writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
281
- const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
331
+ const { response } = await active.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
282
332
  options: rest, onDelta: (text) => writer.delta(text) });
283
333
  writer.finish(response.output);
284
334
  return res.end();
285
335
  } catch (error) {
286
336
  if (res.headersSent) { writer?.fail(error.message); return res.end(); }
337
+ if (error.message === "wrong wallet password") return send(401, { error: error.message });
338
+ if (error.status && error.status < 500 && !error.dispatched && routes[`${req.method} ${new URL(req.url, "http://x").pathname}`]) {
339
+ return send(error.status, { error: error.message });
340
+ }
287
341
  const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
288
342
  return send(status, openaiError(error.message, error.code || "request_failed"));
289
343
  }
package/src/disputes.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // V11 Consumer disputes: keep each request's plaintexts locally, reveal them as evidence on demand.
2
- import { chmodSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { canonicalJson } from "./secure-envelope.mjs";
5
5
  import { encodeCall, encodeWords, hex, keccak, settlementKey } from "./eip712.mjs";
@@ -52,3 +52,31 @@ export const reportId = (key, reporter, digest) =>
52
52
  hex(keccak(encodeWords([["bytes32", key], ["address", reporter], ["bytes32", digest]])));
53
53
 
54
54
  export const openDisputeCall = (key, digest) => encodeCall("openDispute(bytes32,bytes32)", [["bytes32", key], ["bytes32", digest]]);
55
+
56
+ function preview(document) {
57
+ const content = document.endpoint === "chat" ? (document.messages || []).filter((m) => m.role === "user").at(-1)?.content : document.input;
58
+ const text = typeof content === "string" ? content : Array.isArray(content)
59
+ ? content.map((part) => (typeof part === "string" ? part : part?.text ?? part?.content ?? "")).join(" ") : JSON.stringify(content ?? "");
60
+ return String(text).slice(0, 400);
61
+ }
62
+
63
+ /** Recorded requests, newest first, with short previews (the plaintexts never leave this machine). */
64
+ export function listRequests(dir, outputText, limit = 100) {
65
+ const folder = journal(dir);
66
+ if (!existsSync(folder)) return [];
67
+ return readdirSync(folder).filter((name) => name.endsWith(".json"))
68
+ .map((name) => JSON.parse(readFileSync(join(folder, name), "utf8")))
69
+ .sort((a, b) => b.recorded_at - a.recorded_at).slice(0, limit)
70
+ .map((record) => {
71
+ let request = {}; let response = {};
72
+ try { request = JSON.parse(Buffer.from(record.request, "base64").toString("utf8")); } catch {}
73
+ try { response = JSON.parse(Buffer.from(record.response, "base64").toString("utf8")); } catch {}
74
+ const { authorization, receipt } = record.signed_receipt;
75
+ return {
76
+ settlement_key: record.settlement_key, recorded_at: record.recorded_at, relay_url: record.relay_url,
77
+ model: request.model, endpoint: request.endpoint, prompt: preview(request),
78
+ answer: String(outputText(response.output)).slice(0, 600), fee: String(receipt.actual_fee),
79
+ input_tokens: receipt.input_tokens, output_tokens: receipt.output_tokens, provider: authorization.provider_signer,
80
+ };
81
+ });
82
+ }
package/src/probes.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // Re-grading Relay probe verdicts (mirrors mycomesh/relay/probes.py and mycomesh/probe_evidence.py).
2
+ import { sha256Hex, outputText } from "./protocol.mjs";
3
+ import { verifySignedReceipt } from "./eip712.mjs";
4
+
5
+ export const PROBE_EVIDENCE_SCHEMA = "mycomesh.v11.probe-evidence.v1";
6
+ const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
7
+ const numbers = (text) => (text.match(/\d{1,3}(?:[,\s_]\d{3})+(?!\d)|\d+/g) || []).map((match) => match.replace(/[,\s_]/g, ""));
8
+
9
+ export function buildTask(kind, params) {
10
+ if (kind === "multiply") return { kind, question: `What is ${params.a} multiplied by ${params.b}?`, reference: String(BigInt(params.a) * BigInt(params.b)), numeric: true };
11
+ if (kind === "sum") {
12
+ return { kind, question: `What is the sum of ${params.values.join(", ")}?`, reference: String(params.values.reduce((a, b) => a + Number(b), 0)), numeric: true };
13
+ }
14
+ if (kind === "count") {
15
+ return { kind, question: `How many times does the letter "${params.target}" appear in "${params.letters}"?`,
16
+ reference: String(params.letters.split(params.target).length - 1), numeric: true };
17
+ }
18
+ if (kind === "reverse") return { kind, question: `Write the string "${params.word}" backwards, letter by letter.`, reference: [...params.word].reverse().join(""), numeric: false };
19
+ if (kind === "sort") return { kind, question: `Sort these words alphabetically: ${params.words.join(", ")}.`, reference: [...params.words].sort().join(", "), numeric: false };
20
+ if (kind === "weekday") {
21
+ const day = new Date(`${params.start}T00:00:00Z`);
22
+ day.setUTCDate(day.getUTCDate() + Number(params.offset));
23
+ return { kind, question: `What day of the week is ${params.offset} days after ${params.start}?`, reference: WEEKDAYS[(day.getUTCDay() + 6) % 7], numeric: false };
24
+ }
25
+ throw new Error(`unknown probe kind ${kind}`);
26
+ }
27
+
28
+ export function grade(task, answer) {
29
+ const text = String(answer).trim();
30
+ if (!text) return "unrelated";
31
+ if (task.numeric) {
32
+ const found = numbers(text);
33
+ return found.length ? (found.includes(task.reference) ? "pass" : "wrong") : "unrelated";
34
+ }
35
+ const lower = text.toLowerCase();
36
+ if (task.kind === "reverse") return lower.includes(task.reference) ? "pass" : "wrong";
37
+ if (task.kind === "sort") {
38
+ const positions = task.reference.split(", ").map((word) => lower.indexOf(word));
39
+ if (positions.every((position) => position < 0)) return "unrelated";
40
+ return positions.every((p, i) => p >= 0 && (i === 0 || p >= positions[i - 1])) ? "pass" : "wrong";
41
+ }
42
+ const named = WEEKDAYS.filter((day) => lower.includes(day.toLowerCase()));
43
+ if (!named.length) return "unrelated";
44
+ return named.length === 1 && named[0] === task.reference ? "pass" : "wrong";
45
+ }
46
+
47
+ function* strings(value) {
48
+ if (typeof value === "string") yield value;
49
+ else if (Array.isArray(value)) for (const item of value) yield* strings(item);
50
+ else if (value && typeof value === "object") for (const item of Object.values(value)) yield* strings(item);
51
+ }
52
+
53
+ /** Returns { provider, verdict } after re-grading the Provider's own signed answer; throws otherwise. */
54
+ export function verifyProbeEvidence(evidence, deployment) {
55
+ if (evidence?.schema !== PROBE_EVIDENCE_SCHEMA) throw new Error("not probe evidence");
56
+ const signed = evidence.signed_receipt;
57
+ verifySignedReceipt(signed, deployment);
58
+ const request = Buffer.from(evidence.request, "base64");
59
+ const response = Buffer.from(evidence.response, "base64");
60
+ if (sha256Hex(request) !== signed.authorization.request_hash || sha256Hex(response) !== signed.receipt.response_hash) {
61
+ throw new Error("plaintexts do not match the Provider-signed receipt");
62
+ }
63
+ const task = buildTask(evidence.task.kind, evidence.task.params);
64
+ if (![...strings(JSON.parse(request.toString("utf8")))].some((text) => text.includes(task.question))) {
65
+ throw new Error("the request does not ask the stated question");
66
+ }
67
+ const verdict = grade(task, outputText(JSON.parse(response.toString("utf8")).output));
68
+ if (verdict !== evidence.verdict || verdict === "unrelated") throw new Error(`re-grading gives ${verdict}`);
69
+ return { provider: signed.authorization.provider_signer, verdict };
70
+ }
@@ -0,0 +1,86 @@
1
+ // Provider ranking from what the chain and the probe ledger prove, not from what anyone claims.
2
+ import { encodeCall, keccak, hex } from "./eip712.mjs";
3
+ import { rpcCall } from "./chain.mjs";
4
+ import { evidenceHash } from "./disputes.mjs";
5
+ import { verifyProbeEvidence } from "./probes.mjs";
6
+
7
+ const PROBE_RECORDED = hex(keccak(Buffer.from("ProbeRecorded(address,address,bytes32,bytes32,uint8)")));
8
+ const LOOKBACK_BLOCKS = 50_400n; // about 7 days of 12-second blocks
9
+ const LOG_CHUNK = 10_000n;
10
+ const FRAUD_COOLDOWN = 30 * 86_400;
11
+ const MAX_VERIFIED = 20; // evidence downloads per refresh
12
+
13
+ const word = (raw, index) => BigInt(`0x${raw.slice(2 + index * 64, 2 + (index + 1) * 64)}`);
14
+ const address = (topic) => `0x${topic.slice(26)}`.toLowerCase();
15
+
16
+ async function providerRecord(network, signer) {
17
+ const owner = address((await rpcCall(network.rpc_urls, "eth_call", [{ to: network.settlement,
18
+ data: encodeCall("providerSignerOwner(address)", [["address", signer]]) }, "latest"])).slice(0, 66));
19
+ const raw = await rpcCall(network.rpc_urls, "eth_call", [{ to: network.registry,
20
+ data: encodeCall("providerOf(address)", [["address", owner]]) }, "latest"]);
21
+ return { owner, counted_volume: word(raw, 10).toString(), counterparties: Number(word(raw, 8)),
22
+ last_fraud_at: Number(word(raw, 9)), registered_at: Number(word(raw, 5)) };
23
+ }
24
+
25
+ async function ledgerEvents(network) {
26
+ if (!network.probe_ledger) return [];
27
+ const head = BigInt(await rpcCall(network.rpc_urls, "eth_blockNumber", []));
28
+ const events = [];
29
+ for (let from = head > LOOKBACK_BLOCKS ? head - LOOKBACK_BLOCKS : 0n; from <= head; from += LOG_CHUNK) {
30
+ const to = from + LOG_CHUNK - 1n < head ? from + LOG_CHUNK - 1n : head;
31
+ for (const log of await rpcCall(network.rpc_urls, "eth_getLogs", [{ address: network.probe_ledger, topics: [PROBE_RECORDED],
32
+ fromBlock: `0x${from.toString(16)}`, toBlock: `0x${to.toString(16)}` }])) {
33
+ events.push({ provider: address(log.topics[1]), relay: address(log.topics[2]), key: log.topics[3],
34
+ evidence: `0x${log.data.slice(2, 66)}`, verdict: Number(BigInt(`0x${log.data.slice(66, 130)}`)) === 1 ? "pass" : "wrong" });
35
+ }
36
+ }
37
+ return events;
38
+ }
39
+
40
+ /**
41
+ * Per Provider owner: on-chain reputation plus probe verdicts. Passes are counted as reported;
42
+ * failures count only after this machine re-grades the published evidence, so no Relay can frame anyone.
43
+ */
44
+ export async function reputation(consumer, descriptors, relaysByOwner) {
45
+ const network = consumer.network;
46
+ const records = {};
47
+ for (const descriptor of descriptors) {
48
+ try { records[descriptor.provider_signer] = await providerRecord(network, descriptor.provider_signer); } catch {}
49
+ }
50
+ let events = [];
51
+ try { events = await ledgerEvents(network); } catch {}
52
+ const byOwner = {};
53
+ for (const record of Object.values(records)) byOwner[record.owner] = { ...record, probes_passed: 0, probes_failed_verified: 0 };
54
+ let verified = 0;
55
+ for (const event of events) {
56
+ const entry = byOwner[event.provider];
57
+ if (!entry) continue;
58
+ if (event.verdict === "pass") { entry.probes_passed += 1; continue; }
59
+ const relay = relaysByOwner[event.relay];
60
+ if (!relay || verified >= MAX_VERIFIED) continue;
61
+ verified += 1;
62
+ try {
63
+ const { status, body } = await consumer.fetchJson(`${relay.url}/v11/evidence/${event.evidence}`,
64
+ { ca: network.tls_ca, pin: relay.pin, timeoutMs: 8_000 });
65
+ if (status === 200 && evidenceHash(body) === event.evidence && verifyProbeEvidence(body, consumer.deployment).verdict === "wrong") {
66
+ entry.probes_failed_verified += 1;
67
+ }
68
+ } catch {}
69
+ }
70
+ return Object.fromEntries(Object.entries(records).map(([signer, record]) => [signer, byOwner[record.owner]]));
71
+ }
72
+
73
+ /** Lower is better: recent confirmed fraud last, then verified probe failure rate, then reputation, then price. */
74
+ export function rankKey(descriptor, record, now = Math.floor(Date.now() / 1000)) {
75
+ const fraud = record && record.last_fraud_at && now < record.last_fraud_at + FRAUD_COOLDOWN ? 1 : 0;
76
+ const probes = record ? record.probes_passed + record.probes_failed_verified : 0;
77
+ const failure = record && probes ? Math.round((10 * record.probes_failed_verified) / probes) : 0;
78
+ const volume = record ? -Number(BigInt(record.counted_volume) / 1000n) : 0;
79
+ const price = descriptor.prices.output_per_1k * 1e6 + descriptor.prices.input_per_1k;
80
+ return [fraud, failure, volume, price];
81
+ }
82
+
83
+ export function compareRank(a, b) {
84
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return a[i] - b[i];
85
+ return 0;
86
+ }
@@ -0,0 +1,69 @@
1
+ // Multi-tenant accounts: one owner deposit, one payment key per tenant with an on-chain budget.
2
+ // A custodial service (or any team) gives each end user an API key; the chain caps what each key can spend.
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { addressOf, encodeCall } from "./eip712.mjs";
7
+ import { rpcCall, sendTransaction } from "./chain.mjs";
8
+
9
+ const NAME = /^[a-zA-Z0-9_.-]{1,64}$/;
10
+ const file = (dir) => join(dir, "tenants.json");
11
+ export const hashApiKey = (apiKey) => createHash("sha256").update(String(apiKey)).digest("hex");
12
+
13
+ export function loadTenants(dir) {
14
+ return existsSync(file(dir)) ? JSON.parse(readFileSync(file(dir), "utf8")).tenants : {};
15
+ }
16
+
17
+ function saveTenants(dir, tenants) {
18
+ writeFileSync(file(dir), `${JSON.stringify({ schema: "mycomesh.v11.tenants.v1", tenants }, null, 2)}\n`, { mode: 0o600 });
19
+ chmodSync(file(dir), 0o600);
20
+ }
21
+
22
+ export function tenantKey(dir, name) {
23
+ const value = readFileSync(join(dir, "tenants", `${name}.key`), "utf8").trim();
24
+ return value.startsWith("0x") ? value : `0x${value}`;
25
+ }
26
+
27
+ /** Create a tenant: a fresh payment key granted on-chain with a per-request cap and a total budget. */
28
+ export async function addTenant(network, dir, ownerPrivate, name, { maxPerRequest, budget = 0n }) {
29
+ if (!NAME.test(name)) throw new Error("tenant names use letters, digits, '.', '_' and '-'");
30
+ const tenants = loadTenants(dir);
31
+ if (tenants[name]) throw new Error(`tenant ${name} already exists`);
32
+ mkdirSync(join(dir, "tenants"), { recursive: true, mode: 0o700 });
33
+ const keyPrivate = `0x${randomBytes(32).toString("hex")}`;
34
+ writeFileSync(join(dir, "tenants", `${name}.key`), `${keyPrivate}\n`, { mode: 0o600 });
35
+ const key = addressOf(keyPrivate);
36
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
37
+ data: encodeCall("registerKey(address,uint256,uint64)", [["address", key], ["uint", maxPerRequest], ["uint", 0]]) });
38
+ if (budget > 0n) await setBudget(network, ownerPrivate, key, budget);
39
+ const apiKey = `mcm_${randomBytes(24).toString("hex")}`;
40
+ tenants[name] = { key, max_per_request: String(maxPerRequest), api_key_sha256: hashApiKey(apiKey),
41
+ created_at: Math.floor(Date.now() / 1000), revoked: false };
42
+ saveTenants(dir, tenants);
43
+ return { name, key, api_key: apiKey };
44
+ }
45
+
46
+ export async function setBudget(network, ownerPrivate, key, budget) {
47
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
48
+ data: encodeCall("setKeyBudget(address,uint128)", [["address", key], ["uint", budget]]) });
49
+ }
50
+
51
+ export async function revokeTenant(network, dir, ownerPrivate, name) {
52
+ const tenants = loadTenants(dir);
53
+ if (!tenants[name]) throw new Error(`no tenant ${name}`);
54
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
55
+ data: encodeCall("revokeKey(address)", [["address", tenants[name].key]]) });
56
+ tenants[name].revoked = true;
57
+ saveTenants(dir, tenants);
58
+ }
59
+
60
+ /** Every tenant with its on-chain budget and spend. */
61
+ export async function tenantStatus(network, dir) {
62
+ const tenants = loadTenants(dir);
63
+ return Promise.all(Object.entries(tenants).map(async ([name, tenant]) => {
64
+ const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to: network.settlement,
65
+ data: encodeCall("keyBudgets(address)", [["address", tenant.key]]) }, "latest"])).slice(2);
66
+ return { name, key: tenant.key, max_per_request: tenant.max_per_request, revoked: tenant.revoked,
67
+ budget: BigInt(`0x${raw.slice(0, 64)}`).toString(), spent: BigInt(`0x${raw.slice(64, 128)}`).toString() };
68
+ }));
69
+ }
package/src/tlspin.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // TLS without certificate authorities: a Relay pins its self-signed certificate on-chain
2
+ // (https://IP:PORT#sha256=<hex> in the RelayDirectoryV11); mirrors mycomesh/tlspin.py.
3
+ import { createHash } from "node:crypto";
4
+ import { connect } from "node:tls";
5
+
6
+ const PIN_PREFIX = "#sha256=";
7
+ const trusted = new Map(); // pin -> PEM of the certificate that matched it
8
+
9
+ export function splitPin(endpoint) {
10
+ const index = String(endpoint).indexOf(PIN_PREFIX);
11
+ return index < 0 ? [String(endpoint), null] : [endpoint.slice(0, index), endpoint.slice(index + PIN_PREFIX.length).toLowerCase()];
12
+ }
13
+
14
+ /**
15
+ * Fetch the Relay's certificate once, check it against the pin, and return
16
+ * TLS options that trust exactly that certificate (no CA, no host name).
17
+ */
18
+ export async function pinnedOptions(url, pin) {
19
+ if (!trusted.has(pin)) {
20
+ const target = new URL(url);
21
+ const pem = await new Promise((resolve, reject) => {
22
+ const socket = connect({ host: target.hostname, port: Number(target.port || 443), rejectUnauthorized: false }, () => {
23
+ const der = socket.getPeerCertificate(true).raw;
24
+ socket.end();
25
+ if (!der || createHash("sha256").update(der).digest("hex") !== pin) {
26
+ return reject(new Error(`Relay ${target.host} certificate does not match its on-chain pin`));
27
+ }
28
+ resolve(`-----BEGIN CERTIFICATE-----\n${der.toString("base64").match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----\n`);
29
+ });
30
+ socket.setTimeout(10_000, () => socket.destroy(new Error("TLS handshake timed out")));
31
+ socket.on("error", reject);
32
+ });
33
+ trusted.set(pin, pem);
34
+ }
35
+ return { ca: [trusted.get(pin)], checkServerIdentity: () => undefined };
36
+ }
package/src/wallet.mjs CHANGED
@@ -76,11 +76,12 @@ export async function password(prompt, { confirm = false } = {}) {
76
76
 
77
77
  export const walletPath = (dir) => join(dir, "owner-wallet.json");
78
78
 
79
- export async function createWallet(dir) {
79
+ export async function createWallet(dir, secret) {
80
80
  const path = walletPath(dir);
81
81
  if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
82
+ if (secret !== undefined && String(secret).length < 8) throw new Error("use a wallet password of at least 8 characters");
82
83
  const keystore = encryptKeystore(`0x${randomBytes(32).toString("hex")}`,
83
- await password("New wallet password (protects your deposit): ", { confirm: true }));
84
+ secret ?? await password("New wallet password (protects your deposit): ", { confirm: true }));
84
85
  writeFileSync(path, `${JSON.stringify(keystore, null, 2)}\n`, { mode: 0o600 });
85
86
  chmodSync(path, 0o600);
86
87
  return keystore;
@@ -104,3 +105,10 @@ export function ownerAddress(file, dir) {
104
105
  if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return addressOf(text.startsWith("0x") ? text : `0x${text}`);
105
106
  return walletAddress(JSON.parse(text));
106
107
  }
108
+
109
+ /** Unlock this machine's wallet with a password given by the local console. */
110
+ export function unlockWallet(dir, secret) {
111
+ const path = walletPath(dir);
112
+ if (!existsSync(path)) throw new Error("no owner wallet yet");
113
+ return decryptKeystore(JSON.parse(readFileSync(path, "utf8")), String(secret ?? ""));
114
+ }