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/README.md +19 -13
- package/networks/mycomesh-v11-sepolia.json +1 -0
- package/package.json +2 -1
- package/src/account.mjs +93 -0
- package/src/cli.mjs +81 -53
- package/src/console.mjs +104 -0
- package/src/consumer.mjs +72 -18
- package/src/disputes.mjs +29 -1
- package/src/probes.mjs +70 -0
- package/src/reputation.mjs +86 -0
- package/src/tenants.mjs +69 -0
- package/src/tlspin.mjs +36 -0
- package/src/wallet.mjs +10 -2
- package/src/web/console.html +360 -0
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
url:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/tenants.mjs
ADDED
|
@@ -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
|
+
}
|