mycomesh-consumer 0.1.51 → 0.1.53
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 +23 -5
- package/networks/v10-controlled-test.ca.crt +25 -0
- package/networks/v10-controlled-test.json +119 -0
- package/networks/v10-dynamic-20260926.ca.crt +25 -0
- package/networks/v10-dynamic-20260926.json +154 -0
- package/package.json +16 -2
- package/src/cli.mjs +2 -1
- package/src/consumer-discovery.mjs +355 -0
- package/src/consumer-history.mjs +287 -0
- package/src/consumer-request-journal.mjs +189 -0
- package/src/consumer-reserved.mjs +112 -0
- package/src/consumer-runtime.mjs +2687 -154
- package/src/consumer-sessions.mjs +205 -0
- package/src/consumer.mjs +206 -10
- package/src/release.mjs +12 -0
package/src/consumer-runtime.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { spawn as defaultSpawn } from "node:child_process";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
+
import { rootCertificates } from "node:tls";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { join, dirname } from "node:path";
|
|
6
7
|
import {
|
|
7
|
-
appendFileSync,
|
|
8
8
|
chmodSync,
|
|
9
9
|
existsSync,
|
|
10
10
|
mkdirSync,
|
|
@@ -14,13 +14,20 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
|
|
16
16
|
import { secp256k1 } from "@noble/curves/secp256k1";
|
|
17
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
17
18
|
import { keccak_256 } from "@noble/hashes/sha3.js";
|
|
18
|
-
import { ProxyAgent } from "undici";
|
|
19
|
+
import { Agent, ProxyAgent } from "undici";
|
|
20
|
+
import { RESERVED_AUTH_SCHEMA, RESERVED_SIGNED_SCHEMA, buildReservedAuthorization, verifyReservedAuthorization, verifyReservedReceipt, decodeCapacityChannel, reservedSettlementKey } from "./consumer-reserved.mjs";
|
|
21
|
+
import { ConsumerSessions } from "./consumer-sessions.mjs";
|
|
22
|
+
import { ConsumerHistoryLedger } from "./consumer-history.mjs";
|
|
23
|
+
import { ConsumerRequestJournal, requestPayloadHash } from "./consumer-request-journal.mjs";
|
|
24
|
+
import { ConsumerRelayDiscovery, parseDiscoveryConfig } from "./consumer-discovery.mjs";
|
|
19
25
|
|
|
20
26
|
export const DEFAULT_BASE_URL = "http://127.0.0.1:8110/v1";
|
|
21
27
|
export const DEFAULT_RELAY_URL = "https://bridge.mycomesh.xyz";
|
|
22
28
|
export const DEFAULT_MAX_FEE_UNITS = 100000;
|
|
23
|
-
|
|
29
|
+
// Public API model slug; `codex-standard-v1` is only the settlement channel.
|
|
30
|
+
export const DEFAULT_MODEL = "gpt-5.5";
|
|
24
31
|
export const DEFAULT_CHAIN_ID = 11155111;
|
|
25
32
|
export const DEFAULT_SETTLEMENT = "0x6b543a0ff6fae02172c6f205759b1b9de8a6d218";
|
|
26
33
|
export const DEFAULT_CHANNEL_HASH =
|
|
@@ -40,8 +47,43 @@ export const DEFAULT_RPC_URLS = [
|
|
|
40
47
|
];
|
|
41
48
|
|
|
42
49
|
const ZERO_ADDRESS = "0x" + "0".repeat(40);
|
|
50
|
+
const ZERO_BYTES32 = "0x" + "0".repeat(64);
|
|
51
|
+
const DYNAMIC_PROVIDER_AI_COMMITTEE = "dynamic_provider_ai_v1";
|
|
52
|
+
const DYNAMIC_JURY_FORBIDDEN_FIELDS = Object.freeze([
|
|
53
|
+
"adjudicators",
|
|
54
|
+
"adjudicator_operators",
|
|
55
|
+
"independence_attested",
|
|
56
|
+
"jury_provider_evidence",
|
|
57
|
+
]);
|
|
58
|
+
const DYNAMIC_JURY_RANDOMNESS = "future_blockhash_v1";
|
|
59
|
+
const REPUTATION_HISTORY_FIELD = "reputation_history_import";
|
|
60
|
+
const REPUTATION_HISTORY_SCHEMA = "mycomesh.v10.reputation-history-import.v1";
|
|
61
|
+
const REPUTATION_HISTORY_FIELDS = Object.freeze([
|
|
62
|
+
"schema", "source_network_id", "source_protocol_version", "source_chain_id",
|
|
63
|
+
"source_genesis_hash", "source_settlement_contract", "source_runtime_code_hash",
|
|
64
|
+
"source_deployment_block", "source_deployment_block_hash",
|
|
65
|
+
"source_history_through_block", "source_history_through_block_hash",
|
|
66
|
+
"confirmations", "artifact_sha256", "artifact_root",
|
|
67
|
+
]);
|
|
68
|
+
const DYNAMIC_JURY_RANDOMNESS_HASH = `0x${bytesToHex(keccak_256(Buffer.from(DYNAMIC_JURY_RANDOMNESS, "utf8")))}`;
|
|
69
|
+
const MAX_DYNAMIC_PROVIDERS = 64;
|
|
70
|
+
const MAX_DYNAMIC_JURY_SIZE = 7;
|
|
71
|
+
const DISPUTE_CONFIRMATIONS = 6n;
|
|
72
|
+
const JURY_EVIDENCE_REFERENCE_SCHEMA = "mycomesh.v10.provider-jury-evidence-reference.v1";
|
|
43
73
|
const AUTH_SCHEMA = "mycomesh.x402.myco-credit-v2";
|
|
44
74
|
const SIGNED_SCHEMA = "mycomesh.settlement.v8.signed.v1";
|
|
75
|
+
const V9_AUTH_SCHEMA = "mycomesh.x402.myco-credit-v3";
|
|
76
|
+
const V9_SIGNED_SCHEMA = "mycomesh.settlement.v9.signed.v1";
|
|
77
|
+
export const RESPONSE_PROOF_SCHEMA = "mycomesh.provider-response-proof.v1";
|
|
78
|
+
function protocolVersion(value = 8) {
|
|
79
|
+
if (value !== 8 && value !== 9 && value !== 10) throw new Error("unsupported settlement protocol version");
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function authorizationVersion(value) {
|
|
83
|
+
if (value?.schema === AUTH_SCHEMA) return 8;
|
|
84
|
+
if (value?.schema === V9_AUTH_SCHEMA) return 9;
|
|
85
|
+
throw new Error("unsupported payment authorization schema");
|
|
86
|
+
}
|
|
45
87
|
const DOMAIN_TYPE =
|
|
46
88
|
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
|
|
47
89
|
const AUTHORIZATION_TYPE =
|
|
@@ -50,6 +92,7 @@ const RECEIPT_TYPE =
|
|
|
50
92
|
"UsageReceipt(bytes32 authorizationHash,bytes32 responseHash,address provider,address providerSigner,address relay,address pool,uint256 inputTokens,uint256 outputTokens,uint256 actualFee)";
|
|
51
93
|
const RETRYABLE_RELAY_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
52
94
|
const MAX_AUTHORIZATION_TTL = 3600;
|
|
95
|
+
const V9_MAX_AUTHORIZATION_TTL = 10800;
|
|
53
96
|
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
54
97
|
const AUTHORIZATION_CLOCK_SKEW_SECONDS = 300;
|
|
55
98
|
const RESPONSES_REQUEST_OPTION_FIELDS = new Set([
|
|
@@ -85,12 +128,15 @@ const RESPONSES_LOCAL_OPTION_FIELDS = new Set(["stream", "stream_options"]);
|
|
|
85
128
|
const MAX_SHARE_MINUTES = 24 * 60;
|
|
86
129
|
const TUNNEL_START_TIMEOUT_MS = 30_000;
|
|
87
130
|
const TUNNEL_STOP_TIMEOUT_MS = 2_000;
|
|
88
|
-
const RELAY_HEALTH_CACHE_MS =
|
|
89
|
-
const RELAY_HEALTH_STALE_MS = 10 * 60_000;
|
|
131
|
+
const RELAY_HEALTH_CACHE_MS = 5_000;
|
|
90
132
|
const RELAY_HEALTH_RETRY_DELAY_MS = 250;
|
|
133
|
+
// Readiness is a liveness probe for local tooling and the dashboard. Keep it
|
|
134
|
+
// bounded even when an RPC provider stalls; inference keeps its own deadline.
|
|
135
|
+
const READINESS_TIMEOUT_MS = 5_000;
|
|
91
136
|
const WALLET_CHALLENGE_TTL_MS = 5 * 60_000;
|
|
92
137
|
|
|
93
138
|
const DEFAULT_NETWORK = Object.freeze({
|
|
139
|
+
protocol_version: 8,
|
|
94
140
|
chain_id: DEFAULT_CHAIN_ID,
|
|
95
141
|
network_name: "Sepolia testnet",
|
|
96
142
|
settlement_contract: DEFAULT_SETTLEMENT,
|
|
@@ -195,6 +241,228 @@ function stableStringify(value) {
|
|
|
195
241
|
throw new Error("request must contain canonical JSON data");
|
|
196
242
|
}
|
|
197
243
|
|
|
244
|
+
function normalizeJuryRelayPublicKeys(value) {
|
|
245
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
|
|
246
|
+
throw new Error("dynamic V10 network requires 1 to 4 jury Relay public keys");
|
|
247
|
+
}
|
|
248
|
+
const keys = value.map((item) => {
|
|
249
|
+
if (typeof item !== "string" || !/^[0-9a-f]{64}$/.test(item)) {
|
|
250
|
+
throw new Error("jury Relay public keys must be lowercase 32-byte Ed25519 hex");
|
|
251
|
+
}
|
|
252
|
+
return item;
|
|
253
|
+
});
|
|
254
|
+
if (new Set(keys).size !== keys.length) throw new Error("jury Relay public keys must be unique");
|
|
255
|
+
return keys;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function juryEvidenceReferenceAudience({ network, settlement_contract, reporter }) {
|
|
259
|
+
return `${JURY_EVIDENCE_REFERENCE_SCHEMA}:${network}:${settlement_contract}:${reporter}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function verifyJuryEvidenceReference(value, expected) {
|
|
263
|
+
const fields = new Set(["schema", "network", "chain_id", "settlement_contract",
|
|
264
|
+
"settlement_key", "request_id", "request_hash", "evidence_hash",
|
|
265
|
+
"predicted_report_id", "reporter", "origin_relay_public_key", "signature"]);
|
|
266
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
267
|
+
|| Object.keys(value).length !== fields.size
|
|
268
|
+
|| Object.keys(value).some((key) => !fields.has(key))) {
|
|
269
|
+
throw new Error("jury evidence reference has unknown or missing fields");
|
|
270
|
+
}
|
|
271
|
+
const chainId = Number(value.chain_id);
|
|
272
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0 || chainId !== Number(expected.chainId)) {
|
|
273
|
+
throw new Error("jury evidence reference chain mismatch");
|
|
274
|
+
}
|
|
275
|
+
const network = `eip155:${chainId}`;
|
|
276
|
+
const settlementContract = normalizeAddress(value.settlement_contract);
|
|
277
|
+
const settlementKey = normalizeBytes32(value.settlement_key, "jury settlement key");
|
|
278
|
+
const requestId = normalizeBytes32(value.request_id, "jury request ID");
|
|
279
|
+
const requestHash = normalizeBytes32(value.request_hash, "jury request hash");
|
|
280
|
+
const evidenceHash = normalizeBytes32(value.evidence_hash, "jury evidence hash");
|
|
281
|
+
const predictedReportId = normalizeBytes32(value.predicted_report_id, "jury report ID");
|
|
282
|
+
const reporter = nonzeroAddress(value.reporter, "jury evidence reporter");
|
|
283
|
+
const origin = String(value.origin_relay_public_key || "");
|
|
284
|
+
const pins = normalizeJuryRelayPublicKeys(expected.juryRelayPublicKeys);
|
|
285
|
+
if (value.schema !== JURY_EVIDENCE_REFERENCE_SCHEMA || value.network !== network
|
|
286
|
+
|| settlementContract !== normalizeAddress(expected.contract)
|
|
287
|
+
|| settlementKey !== normalizeBytes32(expected.settlementKey, "expected settlement key")
|
|
288
|
+
|| requestId !== normalizeBytes32(expected.requestId, "expected request ID")
|
|
289
|
+
|| requestHash !== normalizeBytes32(expected.requestHash, "expected request hash")
|
|
290
|
+
|| reporter !== nonzeroAddress(expected.reporter, "expected jury reporter")
|
|
291
|
+
|| !pins.includes(origin)) {
|
|
292
|
+
throw new Error("jury evidence reference differs from the paid request or pinned origin");
|
|
293
|
+
}
|
|
294
|
+
const calculatedReportId = keccakHex(Buffer.concat([
|
|
295
|
+
abiWord(settlementKey), abiWord(reporter), abiWord(evidenceHash),
|
|
296
|
+
]));
|
|
297
|
+
if (predictedReportId !== calculatedReportId) throw new Error("jury evidence report ID is not canonical");
|
|
298
|
+
const signature = value.signature;
|
|
299
|
+
const signatureFields = new Set(["nonce", "public_key", "purpose", "timestamp", "audience", "signature"]);
|
|
300
|
+
if (!signature || typeof signature !== "object" || Array.isArray(signature)
|
|
301
|
+
|| Object.keys(signature).length !== signatureFields.size
|
|
302
|
+
|| Object.keys(signature).some((key) => !signatureFields.has(key))
|
|
303
|
+
|| signature.public_key !== origin || signature.purpose !== JURY_EVIDENCE_REFERENCE_SCHEMA
|
|
304
|
+
|| signature.audience !== juryEvidenceReferenceAudience({ network, settlement_contract: settlementContract, reporter })
|
|
305
|
+
|| signature.nonce !== evidenceHash.slice(2, 34)
|
|
306
|
+
|| !Number.isSafeInteger(signature.timestamp) || signature.timestamp !== Number(expected.issuedAt)
|
|
307
|
+
|| !/^[0-9a-f]{128}$/.test(String(signature.signature || ""))) {
|
|
308
|
+
throw new Error("jury evidence reference signature domain is invalid");
|
|
309
|
+
}
|
|
310
|
+
const unsigned = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "signature"));
|
|
311
|
+
const signatureMetadata = Object.fromEntries(Object.entries(signature).filter(([key]) => key !== "signature"));
|
|
312
|
+
const message = Buffer.from(stableStringify({ document: unsigned, signature: signatureMetadata }), "utf8");
|
|
313
|
+
let verified = false;
|
|
314
|
+
try {
|
|
315
|
+
verified = ed25519.verify(
|
|
316
|
+
Buffer.from(signature.signature, "hex"), message, Buffer.from(origin, "hex"),
|
|
317
|
+
);
|
|
318
|
+
} catch {}
|
|
319
|
+
if (!verified) throw new Error("jury evidence reference signature is invalid");
|
|
320
|
+
return { ...unsigned, settlement_contract: settlementContract, settlement_key: settlementKey,
|
|
321
|
+
request_id: requestId, request_hash: requestHash, evidence_hash: evidenceHash,
|
|
322
|
+
predicted_report_id: predictedReportId, reporter, origin_relay_public_key: origin,
|
|
323
|
+
signature: { ...signature } };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function restoreJuryEvidenceReference(entry, network, expectedWallet) {
|
|
327
|
+
if (!entry || typeof entry !== "object") throw new Error("jury evidence history entry is missing");
|
|
328
|
+
const chainId = Number(entry.chain_id);
|
|
329
|
+
const contract = normalizeAddress(entry.settlement_contract);
|
|
330
|
+
const settlementKey = normalizeBytes32(entry.settlement_key, "jury settlement key");
|
|
331
|
+
const requestId = normalizeBytes32(entry.request_id, "jury request ID");
|
|
332
|
+
const requestHash = normalizeBytes32(entry.request_hash, "jury request hash");
|
|
333
|
+
const evidenceHash = normalizeBytes32(entry.jury_evidence_hash, "jury evidence hash");
|
|
334
|
+
const reportId = normalizeBytes32(entry.jury_report_id, "jury report ID");
|
|
335
|
+
const reporter = nonzeroAddress(entry.jury_reporter, "jury evidence reporter");
|
|
336
|
+
const origin = String(entry.jury_origin_relay_public_key || "");
|
|
337
|
+
const timestamp = Number(entry.jury_reference_timestamp);
|
|
338
|
+
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
|
|
339
|
+
throw new Error("jury evidence history timestamp is invalid");
|
|
340
|
+
}
|
|
341
|
+
if (expectedWallet && reporter !== normalizeAddress(expectedWallet)) {
|
|
342
|
+
throw new Error("jury evidence reporter is not the authenticated wallet");
|
|
343
|
+
}
|
|
344
|
+
const schema = JURY_EVIDENCE_REFERENCE_SCHEMA;
|
|
345
|
+
const value = {
|
|
346
|
+
schema,
|
|
347
|
+
network: `eip155:${chainId}`,
|
|
348
|
+
chain_id: chainId,
|
|
349
|
+
settlement_contract: contract,
|
|
350
|
+
settlement_key: settlementKey,
|
|
351
|
+
request_id: requestId,
|
|
352
|
+
request_hash: requestHash,
|
|
353
|
+
evidence_hash: evidenceHash,
|
|
354
|
+
predicted_report_id: reportId,
|
|
355
|
+
reporter,
|
|
356
|
+
origin_relay_public_key: origin,
|
|
357
|
+
signature: {
|
|
358
|
+
nonce: evidenceHash.slice(2, 34),
|
|
359
|
+
public_key: origin,
|
|
360
|
+
purpose: schema,
|
|
361
|
+
timestamp,
|
|
362
|
+
audience: juryEvidenceReferenceAudience({
|
|
363
|
+
network: `eip155:${chainId}`,
|
|
364
|
+
settlement_contract: contract,
|
|
365
|
+
reporter,
|
|
366
|
+
}),
|
|
367
|
+
signature: String(entry.jury_reference_signature || ""),
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
return verifyJuryEvidenceReference(value, {
|
|
371
|
+
chainId: Number(network.chain_id),
|
|
372
|
+
contract: network.settlement_contract,
|
|
373
|
+
settlementKey,
|
|
374
|
+
requestId,
|
|
375
|
+
requestHash,
|
|
376
|
+
reporter,
|
|
377
|
+
issuedAt: timestamp,
|
|
378
|
+
juryRelayPublicKeys: network.jury_relay_public_keys,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function decodeAbiTuple(value, words, label) {
|
|
383
|
+
const text = String(value || "").toLowerCase();
|
|
384
|
+
if (!new RegExp(`^0x[0-9a-f]{${words * 64}}$`).test(text)) {
|
|
385
|
+
throw new Error(`${label} returned non-canonical ABI data`);
|
|
386
|
+
}
|
|
387
|
+
return text.slice(2).match(/.{64}/g);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function decodeTupleAddress(word, label, { nonzero = true } = {}) {
|
|
391
|
+
if (!/^0{24}[0-9a-f]{40}$/.test(word)) throw new Error(`${label} returned a non-canonical address`);
|
|
392
|
+
const value = `0x${word.slice(-40)}`;
|
|
393
|
+
if (nonzero && value === ZERO_ADDRESS) throw new Error(`${label} returned the zero address`);
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function decodeV10Settlement(value) {
|
|
398
|
+
const words = decodeAbiTuple(value, 20, "Settlement V10 settlementInfo");
|
|
399
|
+
const status = BigInt(`0x${words[19]}`);
|
|
400
|
+
const settledAt = BigInt(`0x${words[17]}`);
|
|
401
|
+
const releaseAt = BigInt(`0x${words[18]}`);
|
|
402
|
+
if (status > 7n || settledAt >= (1n << 64n) || releaseAt >= (1n << 64n)) {
|
|
403
|
+
throw new Error("Settlement V10 record has out-of-range typed fields");
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
owner: decodeTupleAddress(words[0], "settlement owner"),
|
|
407
|
+
key: decodeTupleAddress(words[1], "settlement key address"),
|
|
408
|
+
provider: decodeTupleAddress(words[2], "settlement Provider"),
|
|
409
|
+
provider_signer: decodeTupleAddress(words[3], "settlement Provider signer"),
|
|
410
|
+
relay: decodeTupleAddress(words[4], "settlement Relay"),
|
|
411
|
+
relay_signer: decodeTupleAddress(words[5], "settlement Relay signer"),
|
|
412
|
+
pool: decodeTupleAddress(words[6], "settlement pool", { nonzero: false }),
|
|
413
|
+
treasury: decodeTupleAddress(words[7], "settlement treasury"),
|
|
414
|
+
request_id: `0x${words[8]}`,
|
|
415
|
+
request_hash: `0x${words[9]}`,
|
|
416
|
+
authorization_hash: `0x${words[10]}`,
|
|
417
|
+
response_hash: `0x${words[11]}`,
|
|
418
|
+
gross_fee: BigInt(`0x${words[12]}`),
|
|
419
|
+
settled_at: Number(settledAt),
|
|
420
|
+
release_at: Number(releaseAt),
|
|
421
|
+
status: Number(status),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function decodeV10Policy(value) {
|
|
426
|
+
const words = decodeAbiTuple(value, 13, "Settlement V10 policy");
|
|
427
|
+
const uint64Indexes = [0, 1, 2];
|
|
428
|
+
const uint16Indexes = [4, 6];
|
|
429
|
+
if (uint64Indexes.some((index) => BigInt(`0x${words[index]}`) >= (1n << 64n))
|
|
430
|
+
|| uint16Indexes.some((index) => BigInt(`0x${words[index]}`) >= (1n << 16n))) {
|
|
431
|
+
throw new Error("Settlement V10 policy has out-of-range typed fields");
|
|
432
|
+
}
|
|
433
|
+
decodeTupleAddress(words[12], "bond penalty recipient");
|
|
434
|
+
return { reporter_bond: BigInt(`0x${words[3]}`) };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function decodeV10Report(value) {
|
|
438
|
+
const words = decodeAbiTuple(value, 3, "Settlement V10 report");
|
|
439
|
+
const claimed = BigInt(`0x${words[2]}`);
|
|
440
|
+
if (claimed !== 0n && claimed !== 1n) throw new Error("Settlement V10 report returned a non-canonical bool");
|
|
441
|
+
return {
|
|
442
|
+
reporter: decodeTupleAddress(words[0], "dispute reporter"),
|
|
443
|
+
evidence_hash: `0x${words[1]}`,
|
|
444
|
+
bond_claimed: claimed === 1n,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function canonicalBlock(value, label = "canonical block") {
|
|
449
|
+
if (!value || typeof value !== "object"
|
|
450
|
+
|| !/^0x[0-9a-f]{64}$/i.test(String(value.hash || ""))
|
|
451
|
+
|| !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(value.number || ""))
|
|
452
|
+
|| !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(value.timestamp || ""))) {
|
|
453
|
+
throw new Error(`${label} is unavailable`);
|
|
454
|
+
}
|
|
455
|
+
const timestamp = BigInt(value.timestamp);
|
|
456
|
+
if (timestamp >= (1n << 64n) || timestamp > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
457
|
+
throw new Error(`${label} timestamp is invalid`);
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
hash: String(value.hash).toLowerCase(),
|
|
461
|
+
number: String(value.number).toLowerCase(),
|
|
462
|
+
timestamp: Number(timestamp),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
198
466
|
function abiWord(value) {
|
|
199
467
|
if (typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value)) {
|
|
200
468
|
return Buffer.concat([Buffer.alloc(12), Buffer.from(value.slice(2), "hex")]);
|
|
@@ -213,6 +481,30 @@ function abiWord(value) {
|
|
|
213
481
|
return output;
|
|
214
482
|
}
|
|
215
483
|
|
|
484
|
+
function decodeSingleAbiWord(value, label) {
|
|
485
|
+
const text = String(value || "").toLowerCase();
|
|
486
|
+
if (!/^0x[0-9a-f]{64}$/.test(text)) throw new Error(`${label} returned a non-canonical ABI word`);
|
|
487
|
+
return text;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function decodeAbiAddress(value, label) {
|
|
491
|
+
const word = decodeSingleAbiWord(value, label);
|
|
492
|
+
if (!/^0x0{24}/.test(word)) throw new Error(`${label} returned a non-canonical address`);
|
|
493
|
+
return nonzeroAddress(`0x${word.slice(-40)}`, label);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function decodeAbiUint(value, label, bits = 256) {
|
|
497
|
+
const result = BigInt(decodeSingleAbiWord(value, label));
|
|
498
|
+
if (result >= (1n << BigInt(bits))) throw new Error(`${label} exceeds uint${bits}`);
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function decodeAbiBool(value, label) {
|
|
503
|
+
const result = decodeAbiUint(value, label, 8);
|
|
504
|
+
if (result !== 0n && result !== 1n) throw new Error(`${label} returned a non-canonical bool`);
|
|
505
|
+
return result === 1n;
|
|
506
|
+
}
|
|
507
|
+
|
|
216
508
|
function hashText(value) {
|
|
217
509
|
return Uint8Array.from(keccak_256(Buffer.from(value, "utf8")));
|
|
218
510
|
}
|
|
@@ -282,12 +574,23 @@ export function walletMessageDigest(message) {
|
|
|
282
574
|
return Uint8Array.from(keccak_256(Buffer.concat([prefix, body])));
|
|
283
575
|
}
|
|
284
576
|
|
|
285
|
-
function
|
|
577
|
+
export function createReceiptStatusQuery(privateKey, { chainId, contract, requestId, channelId, issuedAt = Math.floor(Date.now() / 1000) }) {
|
|
578
|
+
const query = { chain_id: Number(chainId), settlement_contract: normalizeAddress(contract),
|
|
579
|
+
key: paymentKeyAddress(privateKey), request_id: normalizeBytes32(requestId, "request_id"), issued_at: Number(issuedAt) };
|
|
580
|
+
if (!Number.isSafeInteger(query.chain_id) || query.chain_id <= 0 || !Number.isSafeInteger(query.issued_at) || query.issued_at <= 0) {
|
|
581
|
+
throw new Error("invalid receipt status query scope");
|
|
582
|
+
}
|
|
583
|
+
if (channelId !== undefined) query.channel_id = normalizeBytes32(channelId, "channel_id");
|
|
584
|
+
const message = `MycoMesh receipt status v${channelId === undefined ? 1 : 2}\nchain_id:${query.chain_id}\nsettlement_contract:${query.settlement_contract}\nkey:${query.key}${channelId === undefined ? "" : `\nchannel_id:${query.channel_id}`}\nrequest_id:${query.request_id}\nissued_at:${query.issued_at}`;
|
|
585
|
+
return { ...query, signature: signDigest(privateKey, walletMessageDigest(message)) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function typedDigest(structHash, chainId, contract, version = 8) {
|
|
286
589
|
const domain = keccak_256(
|
|
287
590
|
Buffer.concat([
|
|
288
591
|
Buffer.from(hashText(DOMAIN_TYPE)),
|
|
289
592
|
Buffer.from(hashText("MycoMesh Settlement")),
|
|
290
|
-
Buffer.from(hashText(
|
|
593
|
+
Buffer.from(hashText(String(protocolVersion(version)))),
|
|
291
594
|
abiWord(chainId),
|
|
292
595
|
abiWord(normalizeAddress(contract)),
|
|
293
596
|
]),
|
|
@@ -419,6 +722,7 @@ function normalizeInferenceOptions(endpoint, options) {
|
|
|
419
722
|
}
|
|
420
723
|
|
|
421
724
|
export function buildAuthorization({
|
|
725
|
+
protocolVersion: requestedVersion = 8,
|
|
422
726
|
paymentKey,
|
|
423
727
|
chainId,
|
|
424
728
|
settlementContract,
|
|
@@ -432,7 +736,9 @@ export function buildAuthorization({
|
|
|
432
736
|
maxFee,
|
|
433
737
|
issuedAt = Math.floor(Date.now() / 1000),
|
|
434
738
|
deadline = issuedAt + 900,
|
|
739
|
+
maxAuthorizationTtlSeconds = MAX_AUTHORIZATION_TTL,
|
|
435
740
|
}) {
|
|
741
|
+
const version = protocolVersion(requestedVersion);
|
|
436
742
|
const key = paymentKeyAddress(paymentKey);
|
|
437
743
|
const authorization = {
|
|
438
744
|
request_id: normalizeBytes32(requestId, "request_id"),
|
|
@@ -453,13 +759,14 @@ export function buildAuthorization({
|
|
|
453
759
|
if (authorization.pricing_hash === `0x${"0".repeat(64)}`) throw new Error("V8 pricing_hash cannot be zero");
|
|
454
760
|
const issued = BigInt(authorization.issued_at);
|
|
455
761
|
const expires = BigInt(authorization.deadline);
|
|
456
|
-
if (
|
|
457
|
-
|
|
762
|
+
if (![3600, ...(version === 9 ? [V9_MAX_AUTHORIZATION_TTL] : [])].includes(maxAuthorizationTtlSeconds)
|
|
763
|
+
|| expires <= issued || expires - issued > BigInt(maxAuthorizationTtlSeconds)) {
|
|
764
|
+
throw new Error(`V${version} authorization exceeds the verified contract lifetime`);
|
|
458
765
|
}
|
|
459
766
|
const authorizationHash = authorizationStructHash(authorization);
|
|
460
|
-
const digest = typedDigest(authorizationHash, chainId, settlementContract);
|
|
767
|
+
const digest = typedDigest(authorizationHash, chainId, settlementContract, version);
|
|
461
768
|
return {
|
|
462
|
-
schema: AUTH_SCHEMA,
|
|
769
|
+
schema: version === 9 ? V9_AUTH_SCHEMA : AUTH_SCHEMA,
|
|
463
770
|
chain_id: jsonInteger(positiveBigInt(chainId, "chain_id")),
|
|
464
771
|
settlement_contract: nonzeroAddress(settlementContract, "settlement_contract"),
|
|
465
772
|
authorization,
|
|
@@ -470,9 +777,10 @@ export function buildAuthorization({
|
|
|
470
777
|
}
|
|
471
778
|
|
|
472
779
|
function verifyAuthorization(value, expected = {}) {
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
780
|
+
if (value?.schema === RESERVED_AUTH_SCHEMA) return verifyReservedAuthorization(value, expected);
|
|
781
|
+
const version = authorizationVersion(value);
|
|
782
|
+
if (!value.authorization) throw new Error("payment authorization is missing");
|
|
783
|
+
if (expected.protocolVersion !== undefined && version !== protocolVersion(expected.protocolVersion)) throw new Error("settlement protocol version mismatch");
|
|
476
784
|
const authorization = value.authorization;
|
|
477
785
|
const chainId = uintBigInt(value.chain_id, "chain_id");
|
|
478
786
|
const contract = nonzeroAddress(value.settlement_contract, "settlement_contract");
|
|
@@ -499,18 +807,25 @@ function verifyAuthorization(value, expected = {}) {
|
|
|
499
807
|
const issued = BigInt(normalized.issued_at);
|
|
500
808
|
const deadline = BigInt(normalized.deadline);
|
|
501
809
|
if (issued > BigInt(now + 30) || deadline < BigInt(now)) throw new Error("V8 payment authorization is outside its time window");
|
|
502
|
-
if (deadline <= issued || deadline - issued > BigInt(MAX_AUTHORIZATION_TTL)) throw new Error(
|
|
810
|
+
if (deadline <= issued || deadline - issued > BigInt(version === 9 ? V9_MAX_AUTHORIZATION_TTL : MAX_AUTHORIZATION_TTL)) throw new Error(`V${version} payment authorization lifetime is invalid`);
|
|
503
811
|
const structHash = authorizationStructHash(normalized);
|
|
504
812
|
if (normalizeBytes32(value.authorization_hash, "authorization_hash") !== structHash) throw new Error("V8 authorization hash mismatch");
|
|
505
|
-
const digest = typedDigest(structHash, chainId, contract);
|
|
813
|
+
const digest = typedDigest(structHash, chainId, contract, version);
|
|
506
814
|
if (normalizeBytes32(value.authorization_digest, "authorization_digest") !== `0x${bytesToHex(digest)}`) throw new Error("V8 authorization digest mismatch");
|
|
507
815
|
if (recoverAddress(digest, value.key_signature) !== normalized.key) throw new Error("V8 payment key signature mismatch");
|
|
508
816
|
return { ...value, chain_id: jsonInteger(chainId), settlement_contract: contract, authorization: normalized };
|
|
509
817
|
}
|
|
510
818
|
|
|
511
|
-
function verifySignedReceipt(value) {
|
|
512
|
-
if (
|
|
513
|
-
const
|
|
819
|
+
function verifySignedReceipt(value, expected = {}) {
|
|
820
|
+
if (value?.schema === RESERVED_SIGNED_SCHEMA) return verifyReservedReceipt(value, expected);
|
|
821
|
+
const version = value?.schema === V9_SIGNED_SCHEMA ? 9 : value?.schema === SIGNED_SCHEMA ? 8 : null;
|
|
822
|
+
if (version === null) throw new Error("unsupported signed receipt schema");
|
|
823
|
+
if (expected.protocolVersion !== undefined && expected.protocolVersion !== version) throw new Error("settlement protocol version mismatch");
|
|
824
|
+
const authorization = verifyAuthorization(value.authorization, { ...expected, protocolVersion: version });
|
|
825
|
+
if (version === 9 && (uintBigInt(value.chain_id, "chain_id") !== BigInt(authorization.chain_id)
|
|
826
|
+
|| nonzeroAddress(value.settlement_contract, "settlement_contract") !== authorization.settlement_contract)) {
|
|
827
|
+
throw new Error("V9 signed receipt deployment mismatch");
|
|
828
|
+
}
|
|
514
829
|
const receipt = value.receipt;
|
|
515
830
|
if (!receipt || typeof receipt !== "object") throw new Error("V8 signed receipt is missing");
|
|
516
831
|
const normalizedReceipt = {
|
|
@@ -527,25 +842,84 @@ function verifySignedReceipt(value) {
|
|
|
527
842
|
if (normalizedReceipt.authorization_hash !== authorization.authorization_hash) throw new Error("V8 signed receipt authorization mismatch");
|
|
528
843
|
if (normalizedReceipt.relay !== authorization.authorization.relay) throw new Error("V8 signed receipt Relay payout mismatch");
|
|
529
844
|
if (value.key_signature !== authorization.key_signature) throw new Error("V8 signed receipt payment signature mismatch");
|
|
530
|
-
|
|
845
|
+
if (BigInt(normalizedReceipt.actual_fee) > BigInt(authorization.authorization.max_fee)) throw new Error("receipt exceeds authorized max fee");
|
|
846
|
+
const digest = typedDigest(receiptStructHash(normalizedReceipt), authorization.chain_id, authorization.settlement_contract, version);
|
|
531
847
|
if (recoverAddress(digest, value.provider_signature) !== normalizedReceipt.provider_signer) throw new Error("V8 Provider signature mismatch");
|
|
532
848
|
if (recoverAddress(digest, value.relay_signature) !== authorization.authorization.relay_signer) throw new Error("V8 Relay signature mismatch");
|
|
533
849
|
return { authorization, receipt: normalizedReceipt };
|
|
534
850
|
}
|
|
535
851
|
|
|
852
|
+
const responseControls = new WeakMap();
|
|
853
|
+
|
|
854
|
+
function deadlineError() {
|
|
855
|
+
const error = new Error("request deadline exceeded");
|
|
856
|
+
error.name = "TimeoutError";
|
|
857
|
+
error.statusCode = 504;
|
|
858
|
+
return error;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function remainingMs(deadline) {
|
|
862
|
+
if (!Number.isFinite(deadline)) return Infinity;
|
|
863
|
+
const left = deadline - performance.now();
|
|
864
|
+
if (left <= 0) throw deadlineError();
|
|
865
|
+
return Math.max(1, Math.ceil(left));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function withinDeadline(promise, deadline) {
|
|
869
|
+
if (!Number.isFinite(deadline)) return promise;
|
|
870
|
+
let timer;
|
|
871
|
+
try {
|
|
872
|
+
const milliseconds = remainingMs(deadline);
|
|
873
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
874
|
+
timer = setTimeout(() => reject(deadlineError()), milliseconds);
|
|
875
|
+
timer.unref?.();
|
|
876
|
+
})]);
|
|
877
|
+
} finally { clearTimeout(timer); }
|
|
878
|
+
}
|
|
879
|
+
|
|
536
880
|
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
|
|
537
881
|
const controller = new AbortController();
|
|
538
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
882
|
+
const timer = setTimeout(() => controller.abort(deadlineError()), timeoutMs);
|
|
539
883
|
timer.unref?.();
|
|
884
|
+
const external = options.signal;
|
|
885
|
+
const abort = () => controller.abort(external.reason);
|
|
886
|
+
external?.addEventListener("abort", abort, { once: true });
|
|
887
|
+
if (external?.aborted) abort();
|
|
888
|
+
const cleanup = () => { clearTimeout(timer); external?.removeEventListener("abort", abort); };
|
|
540
889
|
try {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
890
|
+
const response = await fetch(url, { redirect: "error", ...options, signal: controller.signal });
|
|
891
|
+
// Keep the same deadline alive until the complete body is consumed.
|
|
892
|
+
responseControls.set(response, { controller, cleanup });
|
|
893
|
+
return response;
|
|
894
|
+
} catch (error) { cleanup(); throw error; }
|
|
545
895
|
}
|
|
546
896
|
|
|
547
897
|
async function readJsonResponse(response) {
|
|
548
|
-
const
|
|
898
|
+
const control = responseControls.get(response);
|
|
899
|
+
let text;
|
|
900
|
+
try {
|
|
901
|
+
const reader = response.body?.getReader();
|
|
902
|
+
if (!reader) text = "";
|
|
903
|
+
else {
|
|
904
|
+
let size = 0;
|
|
905
|
+
const chunks = [];
|
|
906
|
+
try {
|
|
907
|
+
while (true) {
|
|
908
|
+
const { done, value } = await reader.read();
|
|
909
|
+
if (done) break;
|
|
910
|
+
size += value.byteLength;
|
|
911
|
+
if (size > MAX_BODY_BYTES) {
|
|
912
|
+
const error = new Error("Relay response body exceeds the size limit");
|
|
913
|
+
control?.controller.abort(error);
|
|
914
|
+
await reader.cancel().catch(() => {});
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
917
|
+
chunks.push(Buffer.from(value));
|
|
918
|
+
}
|
|
919
|
+
text = Buffer.concat(chunks, size).toString("utf8");
|
|
920
|
+
} finally { reader.releaseLock(); }
|
|
921
|
+
}
|
|
922
|
+
} finally { control?.cleanup(); responseControls.delete(response); }
|
|
549
923
|
try {
|
|
550
924
|
return text ? JSON.parse(text) : {};
|
|
551
925
|
} catch {
|
|
@@ -557,16 +931,112 @@ function openaiError(message, type = "server_error", code = type) {
|
|
|
557
931
|
return { error: { message: String(message), type, param: null, code } };
|
|
558
932
|
}
|
|
559
933
|
|
|
560
|
-
function parseNetworkConfig(path) {
|
|
561
|
-
if (!path
|
|
934
|
+
export function parseNetworkConfig(path, { allowControlledTest = false } = {}) {
|
|
935
|
+
if (!path) return { ...DEFAULT_NETWORK };
|
|
936
|
+
if (!existsSync(path)) throw new Error("Configured settlement network manifest is missing");
|
|
562
937
|
try {
|
|
563
938
|
const network = JSON.parse(readFileSync(path, "utf8"));
|
|
564
939
|
const deploymentPath = network.deployment ? join(dirname(path), network.deployment) : path;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
940
|
+
if (network.deployment && !existsSync(deploymentPath)) throw new Error("referenced deployment manifest is missing");
|
|
941
|
+
const deployment = network.deployment ? JSON.parse(readFileSync(deploymentPath, "utf8")) : network;
|
|
942
|
+
if (network.relay_discovery !== undefined && ["network_id", "channel_id"].some((name) => deployment[name] !== network[name])) {
|
|
943
|
+
throw new Error("Discovery manifest and deployment disagree");
|
|
944
|
+
}
|
|
945
|
+
const version = protocolVersion(deployment.protocol_version);
|
|
946
|
+
if (version >= 9 && (deployment.eip712_name !== "MycoMesh Settlement" || deployment.eip712_version !== String(version))) throw new Error(`V${version} deployment domain is not explicit`);
|
|
947
|
+
const dynamicProviderJury = version === 10 && deployment.committee_mode === DYNAMIC_PROVIDER_AI_COMMITTEE;
|
|
948
|
+
if (dynamicProviderJury) {
|
|
949
|
+
for (const [label, manifest] of [["deployment", deployment], ["network", network]]) {
|
|
950
|
+
const forbidden = DYNAMIC_JURY_FORBIDDEN_FIELDS.filter((name) => Object.prototype.hasOwnProperty.call(manifest, name));
|
|
951
|
+
if (forbidden.length) throw new Error(`V10 dynamic Provider jury ${label} manifest contains forbidden static committee fields: ${forbidden.join(", ")}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (dynamicProviderJury) validateDynamicV10Deployment(deployment, { allowControlledTest });
|
|
955
|
+
else if (version >= 9) validateV9Deployment(deployment, { allowControlledTest });
|
|
956
|
+
if (dynamicProviderJury) {
|
|
957
|
+
if (network !== deployment && (
|
|
958
|
+
!Object.prototype.hasOwnProperty.call(network, REPUTATION_HISTORY_FIELD)
|
|
959
|
+
|| stableStringify(network[REPUTATION_HISTORY_FIELD])
|
|
960
|
+
!== stableStringify(deployment[REPUTATION_HISTORY_FIELD])
|
|
961
|
+
)) throw new Error("network reputation history lineage differs from deployment");
|
|
962
|
+
if (network !== deployment && [
|
|
963
|
+
"deployment_block", "deployment_block_hash",
|
|
964
|
+
"settlement_runtime_code_keccak256",
|
|
965
|
+
].some((name) => network[name] !== deployment[name])) {
|
|
966
|
+
throw new Error("network Settlement deployment boundary differs from deployment");
|
|
967
|
+
}
|
|
968
|
+
} else if (Object.prototype.hasOwnProperty.call(deployment, REPUTATION_HISTORY_FIELD)
|
|
969
|
+
|| (network !== deployment && Object.prototype.hasOwnProperty.call(network, REPUTATION_HISTORY_FIELD))) {
|
|
970
|
+
throw new Error("reputation_history_import requires a dynamic V10 deployment");
|
|
971
|
+
}
|
|
972
|
+
if (network !== deployment && deployment.jury_relay_public_keys !== undefined) {
|
|
973
|
+
throw new Error("jury_relay_public_keys belongs only in the network manifest");
|
|
974
|
+
}
|
|
975
|
+
const juryRelayPublicKeys = dynamicProviderJury
|
|
976
|
+
? normalizeJuryRelayPublicKeys(network.jury_relay_public_keys)
|
|
977
|
+
: [];
|
|
978
|
+
if (!dynamicProviderJury && network.jury_relay_public_keys !== undefined) {
|
|
979
|
+
throw new Error("jury Relay public keys require a dynamic V10 deployment");
|
|
980
|
+
}
|
|
981
|
+
if (version === 10 && (deployment.reservation_mode !== "provider_bound_channel" || String(deployment.chain_domain) !== "10" || deployment.max_authorization_ttl_seconds !== 10800
|
|
982
|
+
|| ![604800, 2592000].includes(deployment.max_channel_duration_seconds))) throw new Error("V10 requires explicit fixed-budget channel policy");
|
|
983
|
+
const capacityIds = network.capacity_channel_ids ?? deployment.capacity_channel_ids ?? [];
|
|
984
|
+
if (!Array.isArray(capacityIds) || capacityIds.length > 32 || new Set(capacityIds.map(id => normalizeBytes32(id))).size !== capacityIds.length) throw new Error("invalid capacity channels");
|
|
985
|
+
if (network !== deployment && network.capacity_channel_ids !== undefined && deployment.capacity_channel_ids !== undefined
|
|
986
|
+
&& JSON.stringify([...network.capacity_channel_ids].sort()) !== JSON.stringify([...deployment.capacity_channel_ids].sort())) throw new Error("network and deployment capacity channels disagree");
|
|
987
|
+
if (network.require_response_proof !== undefined && typeof network.require_response_proof !== "boolean") throw new Error("require_response_proof must be boolean");
|
|
988
|
+
const tlsCaFile = resolveTlsCaFile(network.tls_ca_file ?? deployment.tls_ca_file, path, { allowControlledTest });
|
|
989
|
+
if (network.relay_fallbacks !== undefined && (!Array.isArray(network.relay_fallbacks) || network.relay_fallbacks.length > 3)) throw new Error("relay_fallbacks must contain at most three pinned Relays");
|
|
990
|
+
const relayEntries = [network.relay, ...(network.relay_fallbacks || [])].filter((entry) => entry !== undefined);
|
|
991
|
+
const relayUrls = [...new Set(relayEntries.map((entry) => {
|
|
992
|
+
if (!entry || typeof entry.public_url !== "string") throw new Error("Relay manifest entry requires public_url");
|
|
993
|
+
const url = new URL(entry.public_url);
|
|
994
|
+
if ((url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))
|
|
995
|
+
|| url.username || url.password || url.search || url.hash) throw new Error("Relay manifest URLs require HTTPS without credentials/query/fragment");
|
|
996
|
+
return url.href.replace(/\/+$/, "");
|
|
997
|
+
}))];
|
|
998
|
+
const relayPins = {};
|
|
999
|
+
for (const entry of relayEntries) {
|
|
1000
|
+
if (entry.payment_address === undefined && entry.attestation_address === undefined) continue;
|
|
1001
|
+
const url = new URL(entry.public_url).href.replace(/\/+$/, "");
|
|
1002
|
+
const pin = { payment_address: nonzeroAddress(entry.payment_address, "Relay payment address"),
|
|
1003
|
+
attestation_address: nonzeroAddress(entry.attestation_address, "Relay attestation address") };
|
|
1004
|
+
if (relayPins[url] && (relayPins[url].payment_address !== pin.payment_address
|
|
1005
|
+
|| relayPins[url].attestation_address !== pin.attestation_address)) throw new Error("conflicting Relay identity pins");
|
|
1006
|
+
relayPins[url] = pin;
|
|
1007
|
+
}
|
|
1008
|
+
const parsed = {
|
|
1009
|
+
protocol_version: version,
|
|
1010
|
+
max_authorization_ttl_seconds: version >= 9 ? (deployment.max_authorization_ttl_seconds ?? 3600) : 3600,
|
|
1011
|
+
authorization_deadline_seconds: version >= 9 ? (deployment.authorization_deadline_seconds ?? 900) : 900,
|
|
1012
|
+
max_channel_duration_seconds: version === 10 ? deployment.max_channel_duration_seconds : undefined,
|
|
1013
|
+
require_response_proof: version >= 9 || network.require_response_proof === true,
|
|
1014
|
+
capacity_channel_ids: capacityIds.map(id => normalizeBytes32(id, "capacity channel ID")),
|
|
1015
|
+
reservation_mode: version === 10 ? "provider_bound_channel" : undefined,
|
|
1016
|
+
committee_mode: deployment.committee_mode,
|
|
1017
|
+
...(dynamicProviderJury ? {} : { independence_attested: deployment.independence_attested }),
|
|
1018
|
+
...(dynamicProviderJury ? {
|
|
1019
|
+
jury_registry: normalizeAddress(deployment.jury_registry),
|
|
1020
|
+
jury_registry_governance: normalizeAddress(deployment.jury_registry_governance),
|
|
1021
|
+
reputation_authority: normalizeAddress(deployment.reputation_authority),
|
|
1022
|
+
minimum_provider_reputation: deployment.minimum_provider_reputation,
|
|
1023
|
+
jury_size: deployment.jury_size,
|
|
1024
|
+
adjudication_threshold: deployment.adjudication_threshold,
|
|
1025
|
+
jury_selection_delay_blocks: deployment.jury_selection_delay_blocks,
|
|
1026
|
+
jury_randomness: deployment.jury_randomness,
|
|
1027
|
+
jury_decision_policy_hash: deployment.jury_decision_policy_hash,
|
|
1028
|
+
genesis_hash: deployment.genesis_hash,
|
|
1029
|
+
deployment_block: deployment.deployment_block,
|
|
1030
|
+
deployment_block_hash: deployment.deployment_block_hash,
|
|
1031
|
+
settlement_runtime_code_keccak256: deployment.settlement_runtime_code_keccak256,
|
|
1032
|
+
reputation_history_import: deployment.reputation_history_import,
|
|
1033
|
+
jury_relay_public_keys: juryRelayPublicKeys,
|
|
1034
|
+
bond_penalty_recipient: normalizeAddress(deployment.policy.bond_penalty_recipient),
|
|
1035
|
+
reporter_bond_units: String(deployment.policy.reporter_bond),
|
|
1036
|
+
} : {}),
|
|
1037
|
+
relay_urls: relayUrls,
|
|
1038
|
+
relay_pins: relayPins,
|
|
1039
|
+
tls_ca_file: tlsCaFile,
|
|
570
1040
|
chain_id: Number(deployment.chain_id),
|
|
571
1041
|
network_name: Number(deployment.chain_id) === DEFAULT_CHAIN_ID ? "Sepolia testnet" : "EVM network",
|
|
572
1042
|
settlement_contract: normalizeAddress(deployment.settlement),
|
|
@@ -576,11 +1046,168 @@ function parseNetworkConfig(path) {
|
|
|
576
1046
|
rpc_urls: (network.settlement_rpc_urls || [network.settlement_rpc_url]).filter(Boolean),
|
|
577
1047
|
explorer_url: Number(deployment.chain_id) === DEFAULT_CHAIN_ID ? "https://sepolia.etherscan.io" : "",
|
|
578
1048
|
};
|
|
579
|
-
|
|
580
|
-
return
|
|
1049
|
+
parsed.relay_discovery = parseDiscoveryConfig(network, parsed);
|
|
1050
|
+
return parsed;
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
throw new Error(`Invalid configured settlement network: ${error.message}`);
|
|
581
1053
|
}
|
|
582
1054
|
}
|
|
583
1055
|
|
|
1056
|
+
function validateV9CommonDeployment(value) {
|
|
1057
|
+
const maxTtl = value.max_authorization_ttl_seconds ?? 3600;
|
|
1058
|
+
const deadlineSeconds = value.authorization_deadline_seconds ?? 900;
|
|
1059
|
+
if (![3600, 10800].includes(maxTtl) || !Number.isSafeInteger(deadlineSeconds) || deadlineSeconds <= 0
|
|
1060
|
+
|| deadlineSeconds + AUTHORIZATION_CLOCK_SKEW_SECONDS > maxTtl) throw new Error("invalid V9 authorization timing policy");
|
|
1061
|
+
const required = ["chain_id", "deployer", "stablecoin", "settlement", "treasury", "governance", "channel", "channel_hash", "pricing_version", "pricing_hash", "reward_token", "policy", "network_id", "channel_id", "backend_policy"];
|
|
1062
|
+
if (required.some((name) => value[name] === undefined)) throw new Error("V9 deployment requires explicit network and dispute policy fields");
|
|
1063
|
+
const uint = (item, name, bits = 256) => {
|
|
1064
|
+
if (!(typeof item === "number" && Number.isSafeInteger(item)) && !(typeof item === "string" && /^[0-9]+$/.test(item))) throw new Error(`invalid V9 ${name}`);
|
|
1065
|
+
const result = BigInt(item);
|
|
1066
|
+
if (result < 0n || result >= (1n << BigInt(bits))) throw new Error(`invalid V9 ${name}`);
|
|
1067
|
+
return result;
|
|
1068
|
+
};
|
|
1069
|
+
if (!Number.isSafeInteger(value.chain_id) || value.chain_id <= 0 || uint(value.pricing_version, "pricing_version", 64) === 0n) throw new Error("invalid V9 chain/pricing version");
|
|
1070
|
+
for (const name of ["deployer", "stablecoin", "settlement", "treasury", "governance"]) nonzeroAddress(value[name], name);
|
|
1071
|
+
for (const name of ["channel_hash", "pricing_hash"]) if (normalizeBytes32(value[name], name) === `0x${"0".repeat(64)}`) throw new Error(`zero V9 ${name}`);
|
|
1072
|
+
for (const name of ["channel", "network_id", "channel_id", "backend_policy"]) if (typeof value[name] !== "string" || !value[name].trim()) throw new Error(`missing V9 ${name}`);
|
|
1073
|
+
const fields = ["dispute_window", "arbitration_timeout", "consumer_withdrawal_delay", "reporter_bond", "slash_bps", "slash_cap", "reporter_bounty_bps", "stable_bounty_cap", "token_reward", "token_reward_cap", "token_minimum_exposure", "token_minimum_penalty", "bond_penalty_recipient"];
|
|
1074
|
+
if (!value.policy || typeof value.policy !== "object" || Array.isArray(value.policy)
|
|
1075
|
+
|| Object.keys(value.policy).length !== fields.length || fields.some((name) => value.policy[name] === undefined)) throw new Error("V9 complete dispute policy required");
|
|
1076
|
+
const policy = Object.fromEntries(fields.slice(0, -1).map((name) => [name, uint(value.policy[name], name)]));
|
|
1077
|
+
const penalty = nonzeroAddress(value.policy.bond_penalty_recipient, "bond_penalty_recipient");
|
|
1078
|
+
for (const name of fields.slice(0, 3)) if (policy[name] === 0n || policy[name] > 2592000n) throw new Error(`invalid V9 ${name}`);
|
|
1079
|
+
if (policy.reporter_bond === 0n || policy.slash_bps === 0n || policy.slash_bps > 10000n || policy.slash_cap === 0n
|
|
1080
|
+
|| policy.reporter_bounty_bps === 0n || policy.reporter_bounty_bps >= 10000n || policy.stable_bounty_cap === 0n || policy.stable_bounty_cap > policy.slash_cap) throw new Error("invalid V9 slash/bounty policy");
|
|
1081
|
+
const reward = normalizeAddress(value.reward_token);
|
|
1082
|
+
if (reward === ZERO_ADDRESS) {
|
|
1083
|
+
if (["token_reward", "token_reward_cap", "token_minimum_exposure", "token_minimum_penalty"].some((name) => policy[name] !== 0n)) throw new Error("V9 disabled reward policy allocates tokens");
|
|
1084
|
+
} else if (reward === normalizeAddress(value.stablecoin) || policy.token_reward === 0n || policy.token_reward > policy.token_reward_cap
|
|
1085
|
+
|| policy.token_minimum_exposure === 0n || policy.token_minimum_penalty === 0n || policy.token_minimum_penalty > policy.slash_cap) throw new Error("invalid V9 token reward policy");
|
|
1086
|
+
return { uint, penalty, reward };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function validateV9Deployment(value, { allowControlledTest = false } = {}) {
|
|
1090
|
+
const { uint, penalty, reward } = validateV9CommonDeployment(value);
|
|
1091
|
+
const required = ["adjudicators", "adjudication_threshold", "adjudicator_operators", "independence_attested"];
|
|
1092
|
+
if (required.some((name) => value[name] === undefined)) throw new Error("V9 deployment requires explicit independent committee fields");
|
|
1093
|
+
if (!Array.isArray(value.adjudicators) || value.adjudicators.length < 2 || value.adjudicators.length > 16) throw new Error("V9 explicit independent committee required");
|
|
1094
|
+
const judges = value.adjudicators.map((judge) => nonzeroAddress(judge, "adjudicator"));
|
|
1095
|
+
const threshold = uint(value.adjudication_threshold, "adjudication_threshold", 16);
|
|
1096
|
+
if (new Set(judges).size !== judges.length || threshold < 2n || threshold <= BigInt(Math.floor(judges.length / 2)) || threshold > BigInt(judges.length)) throw new Error("V9 invalid committee quorum");
|
|
1097
|
+
const authorities = [value.settlement, value.governance, value.treasury].map((item) => normalizeAddress(item)).concat(penalty);
|
|
1098
|
+
if (normalizeAddress(value.settlement) === penalty || normalizeAddress(value.settlement) === normalizeAddress(value.treasury) || judges.some((judge) => authorities.includes(judge))) throw new Error("V9 committee conflicts with settlement authorities");
|
|
1099
|
+
const operators = value.adjudicator_operators;
|
|
1100
|
+
const controlled = value.committee_mode === "controlled_test";
|
|
1101
|
+
if (controlled && !(allowControlledTest === true && [11155111,31337,1337].includes(value.chain_id) && value.network_id.endsWith("-controlled-test") && value.independence_attested === false && reward === ZERO_ADDRESS)) throw new Error("controlled test requires explicit --controlled-test opt-in, test chain and disabled rewards");
|
|
1102
|
+
if (value.committee_mode !== undefined && !["controlled_test", "independent_users"].includes(value.committee_mode)) throw new Error("unknown committee mode");
|
|
1103
|
+
if (!operators || typeof operators !== "object" || Array.isArray(operators) || (!controlled && value.independence_attested !== true)) throw new Error("V9 independent operator attestation required");
|
|
1104
|
+
const entries = Object.entries(operators).map(([judge, operator]) => [nonzeroAddress(judge, "adjudicator operator"), operator]);
|
|
1105
|
+
if (entries.length !== judges.length || new Set(entries.map(([judge]) => judge)).size !== judges.length
|
|
1106
|
+
|| entries.some(([judge, operator]) => !judges.includes(judge) || typeof operator !== "string" || !operator.trim())
|
|
1107
|
+
|| new Set(entries.map(([, operator]) => operator.trim().toLowerCase())).size !== (controlled ? 1 : judges.length)) throw new Error("V9 distinct operator identities required");
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function validateDynamicV10Deployment(value, { allowControlledTest = false } = {}) {
|
|
1111
|
+
validateV9CommonDeployment(value);
|
|
1112
|
+
const required = ["jury_registry", "jury_registry_governance", "reputation_authority", "minimum_provider_reputation",
|
|
1113
|
+
"jury_size", "adjudication_threshold", "jury_selection_delay_blocks", "jury_randomness",
|
|
1114
|
+
"jury_decision_policy_hash", "genesis_hash", "deployment_block",
|
|
1115
|
+
"deployment_block_hash", "settlement_runtime_code_keccak256",
|
|
1116
|
+
REPUTATION_HISTORY_FIELD];
|
|
1117
|
+
if (required.some((name) => value[name] === undefined)) throw new Error("V10 dynamic Provider jury requires an explicit registry and reputation policy");
|
|
1118
|
+
const forbidden = DYNAMIC_JURY_FORBIDDEN_FIELDS.filter((name) => Object.prototype.hasOwnProperty.call(value, name));
|
|
1119
|
+
if (forbidden.length) throw new Error(`V10 dynamic Provider jury deployment contains forbidden static committee fields: ${forbidden.join(", ")}`);
|
|
1120
|
+
const registry = nonzeroAddress(value.jury_registry, "jury_registry");
|
|
1121
|
+
const governance = nonzeroAddress(value.jury_registry_governance, "jury_registry_governance");
|
|
1122
|
+
const authority = nonzeroAddress(value.reputation_authority, "reputation_authority");
|
|
1123
|
+
const settlement = nonzeroAddress(value.settlement, "settlement");
|
|
1124
|
+
if (governance !== normalizeAddress(value.governance)
|
|
1125
|
+
|| new Set([registry, governance, authority, settlement]).size !== 4) {
|
|
1126
|
+
throw new Error("V10 dynamic Provider jury authority bindings are unsafe");
|
|
1127
|
+
}
|
|
1128
|
+
const integer = (item, name, { minimum = 0, maximum = Number.MAX_SAFE_INTEGER } = {}) => {
|
|
1129
|
+
if (!Number.isSafeInteger(item) || item < minimum || item > maximum) throw new Error(`invalid V10 dynamic jury ${name}`);
|
|
1130
|
+
return item;
|
|
1131
|
+
};
|
|
1132
|
+
integer(value.minimum_provider_reputation, "minimum_provider_reputation", { minimum: 1 });
|
|
1133
|
+
const jurySize = integer(value.jury_size, "jury_size", { minimum: 3, maximum: MAX_DYNAMIC_JURY_SIZE });
|
|
1134
|
+
const threshold = integer(value.adjudication_threshold, "adjudication_threshold", { minimum: 2, maximum: jurySize });
|
|
1135
|
+
integer(value.jury_selection_delay_blocks, "jury_selection_delay_blocks", { minimum: 1, maximum: 64 });
|
|
1136
|
+
if (threshold <= Math.floor(jurySize / 2)) throw new Error("V10 dynamic Provider jury requires a strict-majority threshold");
|
|
1137
|
+
const decisionPolicyHash = normalizeBytes32(value.jury_decision_policy_hash, "jury_decision_policy_hash");
|
|
1138
|
+
if (value.jury_decision_policy_hash !== decisionPolicyHash || decisionPolicyHash === ZERO_BYTES32) {
|
|
1139
|
+
throw new Error("V10 dynamic Provider jury requires a canonical nonzero SHA-256 decision policy hash");
|
|
1140
|
+
}
|
|
1141
|
+
const deploymentBlockHash = normalizeBytes32(value.deployment_block_hash, "deployment_block_hash");
|
|
1142
|
+
const settlementRuntimeHash = normalizeBytes32(
|
|
1143
|
+
value.settlement_runtime_code_keccak256, "settlement_runtime_code_keccak256",
|
|
1144
|
+
);
|
|
1145
|
+
if (!Number.isSafeInteger(value.deployment_block) || value.deployment_block <= 0
|
|
1146
|
+
|| value.deployment_block_hash !== deploymentBlockHash
|
|
1147
|
+
|| deploymentBlockHash === ZERO_BYTES32
|
|
1148
|
+
|| value.settlement_runtime_code_keccak256 !== settlementRuntimeHash
|
|
1149
|
+
|| settlementRuntimeHash === ZERO_BYTES32) {
|
|
1150
|
+
throw new Error("V10 dynamic Settlement deployment boundary is invalid");
|
|
1151
|
+
}
|
|
1152
|
+
validateReputationHistoryImport(value[REPUTATION_HISTORY_FIELD], value);
|
|
1153
|
+
if (value.jury_randomness !== DYNAMIC_JURY_RANDOMNESS
|
|
1154
|
+
|| allowControlledTest !== true
|
|
1155
|
+
|| value.chain_id !== DEFAULT_CHAIN_ID
|
|
1156
|
+
|| typeof value.network_id !== "string" || !value.network_id.endsWith("-controlled-test")
|
|
1157
|
+
|| normalizeAddress(value.reward_token) !== ZERO_ADDRESS) {
|
|
1158
|
+
throw new Error("V10 future-block jury selection requires explicit Sepolia controlled-test opt-in and disabled rewards");
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function validateReputationHistoryImport(history, deployment) {
|
|
1163
|
+
if (!history || typeof history !== "object" || Array.isArray(history)
|
|
1164
|
+
|| Object.keys(history).length !== REPUTATION_HISTORY_FIELDS.length
|
|
1165
|
+
|| REPUTATION_HISTORY_FIELDS.some((name) => !Object.prototype.hasOwnProperty.call(history, name))) {
|
|
1166
|
+
throw new Error("V10 reputation_history_import must be a non-null exact lineage object");
|
|
1167
|
+
}
|
|
1168
|
+
const genesisHash = normalizeBytes32(deployment.genesis_hash, "genesis_hash");
|
|
1169
|
+
const sourceGenesis = normalizeBytes32(history.source_genesis_hash, "source_genesis_hash");
|
|
1170
|
+
const sourceRuntime = normalizeBytes32(history.source_runtime_code_hash, "source_runtime_code_hash");
|
|
1171
|
+
const sourceDeploymentBlockHash = normalizeBytes32(
|
|
1172
|
+
history.source_deployment_block_hash, "source_deployment_block_hash",
|
|
1173
|
+
);
|
|
1174
|
+
const sourceHistoryThroughBlockHash = normalizeBytes32(
|
|
1175
|
+
history.source_history_through_block_hash, "source_history_through_block_hash",
|
|
1176
|
+
);
|
|
1177
|
+
const artifactRoot = normalizeBytes32(history.artifact_root, "artifact_root");
|
|
1178
|
+
const sourceSettlement = normalizeAddress(history.source_settlement_contract, "source_settlement_contract");
|
|
1179
|
+
if (deployment.genesis_hash !== genesisHash || genesisHash === ZERO_BYTES32
|
|
1180
|
+
|| history.schema !== REPUTATION_HISTORY_SCHEMA
|
|
1181
|
+
|| typeof history.source_network_id !== "string" || !history.source_network_id
|
|
1182
|
+
|| history.source_network_id !== history.source_network_id.trim()
|
|
1183
|
+
|| history.source_network_id.length > 160
|
|
1184
|
+
|| history.source_network_id === deployment.network_id
|
|
1185
|
+
|| ![9, 10].includes(history.source_protocol_version)
|
|
1186
|
+
|| !Number.isSafeInteger(history.source_chain_id)
|
|
1187
|
+
|| history.source_chain_id !== deployment.chain_id
|
|
1188
|
+
|| history.source_genesis_hash !== sourceGenesis || sourceGenesis !== genesisHash
|
|
1189
|
+
|| history.source_settlement_contract !== sourceSettlement
|
|
1190
|
+
|| sourceSettlement === ZERO_ADDRESS || sourceSettlement === normalizeAddress(deployment.settlement)
|
|
1191
|
+
|| history.source_runtime_code_hash !== sourceRuntime || sourceRuntime === ZERO_BYTES32
|
|
1192
|
+
|| !Number.isSafeInteger(history.source_deployment_block)
|
|
1193
|
+
|| history.source_deployment_block <= 0
|
|
1194
|
+
|| history.source_deployment_block_hash !== sourceDeploymentBlockHash
|
|
1195
|
+
|| sourceDeploymentBlockHash === ZERO_BYTES32
|
|
1196
|
+
|| !Number.isSafeInteger(history.source_history_through_block)
|
|
1197
|
+
|| history.source_history_through_block < history.source_deployment_block
|
|
1198
|
+
|| history.source_history_through_block_hash !== sourceHistoryThroughBlockHash
|
|
1199
|
+
|| sourceHistoryThroughBlockHash === ZERO_BYTES32
|
|
1200
|
+
|| !Number.isSafeInteger(history.confirmations)
|
|
1201
|
+
|| history.confirmations < 2 || history.confirmations > 256
|
|
1202
|
+
|| typeof history.artifact_sha256 !== "string"
|
|
1203
|
+
|| !/^[0-9a-f]{64}$/.test(history.artifact_sha256)
|
|
1204
|
+
|| history.artifact_sha256 === "0".repeat(64)
|
|
1205
|
+
|| history.artifact_root !== artifactRoot || artifactRoot === ZERO_BYTES32) {
|
|
1206
|
+
throw new Error("V10 reputation history lineage is invalid or not a prior same-chain deployment");
|
|
1207
|
+
}
|
|
1208
|
+
return history;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
584
1211
|
function defaultDataDir() {
|
|
585
1212
|
return join(homedir(), ".mycomesh", "consumer");
|
|
586
1213
|
}
|
|
@@ -605,6 +1232,24 @@ function parseRelayUrls(env, explicit) {
|
|
|
605
1232
|
return urls;
|
|
606
1233
|
}
|
|
607
1234
|
|
|
1235
|
+
function resolveTlsCaFile(configured, manifestPath, { allowControlledTest = false } = {}) {
|
|
1236
|
+
if (configured === undefined || configured === null || configured === "") return null;
|
|
1237
|
+
if (!allowControlledTest) {
|
|
1238
|
+
throw new Error("custom Relay TLS CA is only allowed for an explicit controlled test (--controlled-test)");
|
|
1239
|
+
}
|
|
1240
|
+
if (typeof configured !== "string" || configured.length > 1024 || configured.includes("\0")) {
|
|
1241
|
+
throw new Error("tls_ca_file must be a valid path");
|
|
1242
|
+
}
|
|
1243
|
+
const resolved = configured.startsWith("/") ? configured : join(dirname(manifestPath), configured);
|
|
1244
|
+
if (!existsSync(resolved)) throw new Error("configured Relay TLS CA file is missing");
|
|
1245
|
+
let certificate;
|
|
1246
|
+
try { certificate = readFileSync(resolved); } catch { throw new Error("configured Relay TLS CA file is unreadable"); }
|
|
1247
|
+
if (!certificate.toString("ascii").includes("BEGIN CERTIFICATE")) {
|
|
1248
|
+
throw new Error("configured Relay TLS CA file does not contain a PEM certificate");
|
|
1249
|
+
}
|
|
1250
|
+
return resolved;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
608
1253
|
function sameSecret(first, second) {
|
|
609
1254
|
const a = Buffer.from(String(first || ""));
|
|
610
1255
|
const b = Buffer.from(String(second || ""));
|
|
@@ -618,23 +1263,79 @@ export class NativeConsumerState {
|
|
|
618
1263
|
mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
|
|
619
1264
|
try { chmodSync(this.dataDir, 0o700); } catch {}
|
|
620
1265
|
this.baseUrl = resolveBaseUrl(options.baseUrl || env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL || DEFAULT_BASE_URL);
|
|
621
|
-
this.relayUrls = parseRelayUrls(env, options.relayUrls);
|
|
622
1266
|
this.maxFeeUnits = options.maxFeeUnits || Number(env.MYCOMESH_V8_MAX_FEE_UNITS || DEFAULT_MAX_FEE_UNITS);
|
|
623
1267
|
if (!Number.isSafeInteger(this.maxFeeUnits) || this.maxFeeUnits <= 0) throw new Error("max fee must be a positive integer");
|
|
624
1268
|
this.timeoutMs = options.timeoutMs ?? Number(env.MYCOMESH_V8_REQUEST_TIMEOUT_SECONDS || 300) * 1000;
|
|
625
1269
|
this.healthTimeoutMs = options.healthTimeoutMs ?? Number(env.MYCOMESH_V8_HEALTH_TIMEOUT_SECONDS || 10) * 1000;
|
|
626
|
-
this.network = parseNetworkConfig(options.networkConfig || env.MYCOMESH_CONSUMER_NETWORK_CONFIG);
|
|
1270
|
+
this.network = parseNetworkConfig(options.networkConfig || env.MYCOMESH_CONSUMER_NETWORK_CONFIG, { allowControlledTest: options.allowControlledTest === true });
|
|
1271
|
+
this.relayUrls = parseRelayUrls(env, options.relayUrls || env.MYCOMESH_V8_RELAY_URLS
|
|
1272
|
+
|| env.MYCOMESH_CONSUMER_RELAY_URL || this.network.relay_urls?.join(","));
|
|
1273
|
+
this.staticRelayUrls = [...this.relayUrls];
|
|
627
1274
|
if (env.MYCOMESH_CONSUMER_SETTLEMENT_RPC_URLS) {
|
|
628
1275
|
this.network.rpc_urls = String(env.MYCOMESH_CONSUMER_SETTLEMENT_RPC_URLS).split(",").map((item) => item.trim()).filter(Boolean);
|
|
629
1276
|
}
|
|
1277
|
+
// A controlled IP testnet may use a private CA. The manifest path is
|
|
1278
|
+
// already trusted and resolves relative CA paths; an explicit env/option
|
|
1279
|
+
// is supported for generated manifests whose CA is staged separately.
|
|
1280
|
+
const caFile = options.caFile || env.MYCOMESH_CONSUMER_CA_FILE || this.network.tls_ca_file;
|
|
1281
|
+
if (caFile && options.allowControlledTest !== true) {
|
|
1282
|
+
throw new Error("custom Relay TLS CA is only allowed for an explicit controlled test (--controlled-test)");
|
|
1283
|
+
}
|
|
1284
|
+
this.tlsCaFile = caFile ? (String(caFile).startsWith("/") ? String(caFile) : caFile) : null;
|
|
1285
|
+
this.tlsCa = null;
|
|
1286
|
+
this.tlsCaBundle = rootCertificates;
|
|
1287
|
+
if (this.tlsCaFile) {
|
|
1288
|
+
if (!existsSync(this.tlsCaFile)) throw new Error("configured Relay TLS CA file is missing");
|
|
1289
|
+
try { this.tlsCa = readFileSync(this.tlsCaFile); } catch { throw new Error("configured Relay TLS CA file is unreadable"); }
|
|
1290
|
+
if (!this.tlsCa.toString("ascii").includes("BEGIN CERTIFICATE")) throw new Error("configured Relay TLS CA file does not contain a PEM certificate");
|
|
1291
|
+
// `ca` replaces (rather than augments) Node's defaults. Preserve public
|
|
1292
|
+
// trust roots so a manifest may safely mix private test Relays and
|
|
1293
|
+
// publicly certified fallback Relays.
|
|
1294
|
+
this.tlsCaBundle = [...rootCertificates, this.tlsCa.toString("utf8")];
|
|
1295
|
+
}
|
|
630
1296
|
this.proxy = options.proxy || env.MYCOMESH_CONSUMER_PROXY || "";
|
|
631
|
-
|
|
1297
|
+
// Public testnet routes can need more than Undici's 10-second handshake
|
|
1298
|
+
// default. Each operation still has its own, often shorter, total deadline.
|
|
1299
|
+
this.dispatcher = this.proxy
|
|
1300
|
+
? new ProxyAgent({ uri: this.proxy, proxyTls: { timeout: 30000 }, requestTls: { timeout: 30000, ...(this.tlsCa ? { ca: this.tlsCaBundle } : {}) } })
|
|
1301
|
+
: new Agent({ connect: { timeout: 30000, ...(this.tlsCa ? { ca: this.tlsCaBundle } : {}) } });
|
|
1302
|
+
this.relayDiscovery = this.network.relay_discovery ? new ConsumerRelayDiscovery({
|
|
1303
|
+
config: this.network.relay_discovery, cachePath: join(this.dataDir, "relay-discovery-cache.json"), dispatcher: this.dispatcher,
|
|
1304
|
+
}) : null;
|
|
1305
|
+
this.syncDiscoveredRelays();
|
|
632
1306
|
this.healthCache = new Map();
|
|
1307
|
+
this.healthRequests = new Map();
|
|
1308
|
+
// `/ready` is a bounded Relay/model liveness probe used by local tooling.
|
|
1309
|
+
// It never claims paid readiness; `/paid-ready` and inference perform the
|
|
1310
|
+
// wallet, network, and chain-backed budget checks.
|
|
1311
|
+
this.readinessCache = null;
|
|
1312
|
+
this.readinessRequest = null;
|
|
1313
|
+
this.preferredRpcUrl = null;
|
|
1314
|
+
this.capacityChannelsRequest = null;
|
|
1315
|
+
this.relayFailures = new Map();
|
|
1316
|
+
this.sessions = new ConsumerSessions();
|
|
1317
|
+
this.sessionRoutes = new Map();
|
|
1318
|
+
this.sessionSelections = new Map();
|
|
1319
|
+
this.relayInFlight = new Map();
|
|
1320
|
+
this.relayReservations = new Map();
|
|
1321
|
+
this.relaySelectionCursor = 0;
|
|
1322
|
+
this.activeInferenceRequests = 0;
|
|
633
1323
|
this.historyPath = join(this.dataDir, "receipt-history.jsonl");
|
|
634
1324
|
this.pendingKeyPath = join(this.dataDir, "pending-payment-key");
|
|
635
1325
|
this.paymentKeyFromEnv = Boolean(String(env.MYCOMESH_V8_PAYMENT_KEY || "").trim());
|
|
636
1326
|
this.paymentKey = this.loadPaymentKey(env.MYCOMESH_V8_PAYMENT_KEY);
|
|
637
1327
|
this.paymentAddress = paymentKeyAddress(this.paymentKey);
|
|
1328
|
+
this.historyDir = options.historyDir || env.MYCOMESH_CONSUMER_HISTORY_DIR;
|
|
1329
|
+
this.historyLedger = new ConsumerHistoryLedger({
|
|
1330
|
+
localPath: this.historyPath,
|
|
1331
|
+
sharedDir: this.historyDir,
|
|
1332
|
+
chainId: this.network.chain_id,
|
|
1333
|
+
contract: this.network.settlement_contract,
|
|
1334
|
+
keyAddress: this.paymentAddress,
|
|
1335
|
+
});
|
|
1336
|
+
this.historySync = null;
|
|
1337
|
+
this.historySyncAt = 0;
|
|
1338
|
+
this.historySyncError = null;
|
|
638
1339
|
this.tunnelCommand = options.tunnelCommand || env.MYCOMESH_CONSUMER_TUNNEL_COMMAND || "cloudflared";
|
|
639
1340
|
this.tunnelSpawn = options.tunnelSpawn || defaultSpawn;
|
|
640
1341
|
this.share = null;
|
|
@@ -666,19 +1367,32 @@ export class NativeConsumerState {
|
|
|
666
1367
|
return `export OPENAI_BASE_URL=${shellQuote(this.baseUrl)}\nexport OPENAI_API_KEY=${shellQuote(this.paymentKey)}`;
|
|
667
1368
|
}
|
|
668
1369
|
|
|
1370
|
+
capabilities(health) {
|
|
1371
|
+
const version = protocolVersion(this.network.protocol_version);
|
|
1372
|
+
const capabilities = health?.[`v${version}`];
|
|
1373
|
+
if (!capabilities || typeof capabilities !== "object") throw new Error(`Relay has no Settlement V${version} capabilities`);
|
|
1374
|
+
return capabilities;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
669
1377
|
healthPayload() {
|
|
1378
|
+
this.syncDiscoveredRelays();
|
|
670
1379
|
return {
|
|
671
1380
|
ok: true,
|
|
672
|
-
protocol:
|
|
1381
|
+
protocol: `mycomesh-consumer/v${this.network.protocol_version}`,
|
|
673
1382
|
runtime: "node-native",
|
|
674
1383
|
docker: false,
|
|
675
1384
|
browser_app_ready: true,
|
|
676
1385
|
gateway_dependency: false,
|
|
677
|
-
routing_mode:
|
|
1386
|
+
routing_mode: `relay-scheduled-payment-key-v${this.network.protocol_version}`,
|
|
678
1387
|
relay_urls: this.relayUrls,
|
|
1388
|
+
relay_discovery: this.relayDiscovery ? { enabled: true, active: this.relayDiscovery.active().length,
|
|
1389
|
+
error: this.relayDiscovery.lastError } : { enabled: false },
|
|
679
1390
|
payment_key_address: this.paymentAddress,
|
|
680
1391
|
payment_key_persisted: !this.paymentKeyFromEnv,
|
|
681
1392
|
wallet_unlocked: this.paymentUnlocked,
|
|
1393
|
+
// This reports trust configuration only; server-certificate verification
|
|
1394
|
+
// is completed on each HTTPS request and is never claimed in advance.
|
|
1395
|
+
relay_tls: { mode: this.tlsCa ? "controlled_test_ca" : "system_trust_store", configured: Boolean(this.tlsCa) },
|
|
682
1396
|
responses_transports: ["http", "sse"],
|
|
683
1397
|
};
|
|
684
1398
|
}
|
|
@@ -718,13 +1432,36 @@ export class NativeConsumerState {
|
|
|
718
1432
|
this.walletChallenge = null;
|
|
719
1433
|
const recovered = recoverAddress(walletMessageDigest(challenge.message), raw?.signature);
|
|
720
1434
|
if (recovered !== wallet) throw new Error("wallet signature does not match the selected account");
|
|
721
|
-
|
|
1435
|
+
let grant;
|
|
1436
|
+
try { grant = await this.keyGrant(this.paymentAddress); }
|
|
1437
|
+
catch {
|
|
1438
|
+
throw Object.assign(new Error("Cannot verify payment-key ownership on-chain. Please retry when the connection recovers."), {
|
|
1439
|
+
code: "wallet_verification_unavailable", selected_wallet: wallet, payment_key_address: this.paymentAddress,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
722
1442
|
if (grant.owner !== ZERO_ADDRESS && grant.owner !== wallet) {
|
|
723
|
-
throw new Error(
|
|
1443
|
+
throw Object.assign(new Error(`This local payment key belongs to a different wallet. Select ${grant.owner} in your wallet extension, or use a separate Consumer data directory for ${wallet}. Keep the existing key and billing history.`), {
|
|
1444
|
+
code: "payment_key_owner_mismatch", selected_wallet: wallet, expected_owner: grant.owner, payment_key_address: this.paymentAddress,
|
|
1445
|
+
});
|
|
724
1446
|
}
|
|
1447
|
+
let paymentUnlocked = grant.active && grant.owner === wallet;
|
|
1448
|
+
if (this.network.protocol_version === 10 && !paymentUnlocked) {
|
|
1449
|
+
// Revocation prevents new channels; existing owner-approved snapshots
|
|
1450
|
+
// keep their bounded authorization until their original expiry.
|
|
1451
|
+
try {
|
|
1452
|
+
paymentUnlocked = (await this.capacityChannels()).some(c => !c.closed && c.consumer_owner === wallet
|
|
1453
|
+
&& c.consumer_key === this.paymentAddress && Number(c.admit_until) >= Math.floor(Date.now()/1000));
|
|
1454
|
+
} catch {
|
|
1455
|
+
throw Object.assign(new Error("Cannot verify existing payment channels on-chain. Please retry when the connection recovers."), {
|
|
1456
|
+
code: "wallet_verification_unavailable", selected_wallet: wallet, payment_key_address: this.paymentAddress,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
// Commit the session only after every chain check succeeds. A failed login
|
|
1461
|
+
// must neither create a partial session nor replace an existing owner session.
|
|
725
1462
|
this.unlockedWallet = wallet;
|
|
726
1463
|
this.managementToken = `myco_local_${randomBytes(32).toString("base64url")}`;
|
|
727
|
-
this.paymentUnlocked =
|
|
1464
|
+
this.paymentUnlocked = paymentUnlocked;
|
|
728
1465
|
return { ok: true, token: this.managementToken, auth: this.walletAuthPayload(), grant };
|
|
729
1466
|
}
|
|
730
1467
|
|
|
@@ -760,61 +1497,164 @@ export class NativeConsumerState {
|
|
|
760
1497
|
}
|
|
761
1498
|
|
|
762
1499
|
history(limit = 100) {
|
|
763
|
-
|
|
764
|
-
const lines = readFileSync(this.historyPath, "utf8").split("\n").filter(Boolean);
|
|
765
|
-
const selected = limit <= 0 ? lines : lines.slice(-Math.min(Math.max(limit, 1), 500));
|
|
766
|
-
return selected.map((line) => {
|
|
767
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
768
|
-
}).filter((value) => value && typeof value === "object").reverse();
|
|
1500
|
+
return this.historyLedger.history(limit);
|
|
769
1501
|
}
|
|
770
1502
|
|
|
771
|
-
|
|
1503
|
+
// Keep recovery state explicit for the local dashboard. The history ledger
|
|
1504
|
+
// is durable, so a restarted Consumer must show the operator whether a
|
|
1505
|
+
// request is still open, needs reconciliation, or is terminally failed;
|
|
1506
|
+
// callers must never infer this from a missing/zero fee.
|
|
1507
|
+
recoveryPayload(allHistory, authenticated = false) {
|
|
1508
|
+
if (!authenticated) {
|
|
1509
|
+
return {
|
|
1510
|
+
status: "locked",
|
|
1511
|
+
open_count: 0,
|
|
1512
|
+
uncertain_count: 0,
|
|
1513
|
+
failed_count: 0,
|
|
1514
|
+
requires_reconcile: false,
|
|
1515
|
+
last_checked_at: 0,
|
|
1516
|
+
error: null,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
const rows = Array.isArray(allHistory) ? allHistory : [];
|
|
1520
|
+
const openStatuses = new Set(["dispatching", "outcome_unknown", "queued", "pending", "submitted", "broadcast_unknown"]);
|
|
1521
|
+
const uncertainStatuses = new Set(["dispatching", "outcome_unknown", "submitted", "broadcast_unknown"]);
|
|
1522
|
+
const failedStatuses = new Set(["failed", "rejected"]);
|
|
1523
|
+
const open = rows.filter((item) => openStatuses.has(item.status));
|
|
1524
|
+
const uncertain = rows.filter((item) => uncertainStatuses.has(item.status)
|
|
1525
|
+
|| ["broadcast_unknown", "confirmation_timeout"].includes(item.error_code));
|
|
1526
|
+
const failed = rows.filter((item) => failedStatuses.has(item.status));
|
|
1527
|
+
const error = this.historySyncError || null;
|
|
1528
|
+
const status = this.historySync ? "syncing"
|
|
1529
|
+
: error ? "degraded"
|
|
1530
|
+
: uncertain.length ? "needs_reconcile"
|
|
1531
|
+
: open.length ? "pending"
|
|
1532
|
+
: failed.length ? "failed" : "clear";
|
|
1533
|
+
return {
|
|
1534
|
+
status,
|
|
1535
|
+
open_count: open.length,
|
|
1536
|
+
uncertain_count: uncertain.length,
|
|
1537
|
+
failed_count: failed.length,
|
|
1538
|
+
requires_reconcile: Boolean(uncertain.length || error),
|
|
1539
|
+
last_checked_at: Number(this.historySyncAt || 0),
|
|
1540
|
+
error,
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
recordDispatch(relayUrl, endpoint, payment, sessionId, status = "dispatching") {
|
|
1545
|
+
const auth = payment.payment.authorization;
|
|
1546
|
+
this.historyLedger.append({
|
|
1547
|
+
timestamp: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
|
|
1548
|
+
request_id: auth.request_id, request_hash: auth.request_hash,
|
|
1549
|
+
key_address: this.paymentAddress, owner: this.unlockedWallet || undefined,
|
|
1550
|
+
chain_id: this.network.chain_id, settlement_contract: this.network.settlement_contract,
|
|
1551
|
+
relay_url: relayUrl, relay: payment.channel?.relay || auth.relay, capacity_channel_id: auth.channel_id, endpoint, model: payment.model,
|
|
1552
|
+
route_model: payment.model, session_id: sessionId, source: "local-dispatch",
|
|
1553
|
+
max_fee_units: Number(auth.max_fee), authorization_deadline: Number(auth.deadline),
|
|
1554
|
+
accepted: false, status,
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
recordReceipt(relayUrl, endpoint, model, settlement, routeModel = model, sessionId = null, contentVerification = "receipt-only") {
|
|
772
1559
|
const signed = settlement?.signed_receipt;
|
|
773
1560
|
const receipt = signed?.receipt;
|
|
774
1561
|
const auth = signed?.authorization?.authorization;
|
|
775
1562
|
if (!receipt || !auth) return;
|
|
1563
|
+
const jury = settlement?.jury_evidence_reference;
|
|
776
1564
|
const entry = {
|
|
777
1565
|
timestamp: Math.floor(Date.now() / 1000),
|
|
778
1566
|
request_id: String(auth.request_id || ""),
|
|
1567
|
+
request_hash: auth.request_hash, capacity_channel_id: auth.channel_id, max_fee_units: Number(auth.max_fee),
|
|
779
1568
|
settlement_key: String(settlement.settlement_key || ""),
|
|
780
|
-
|
|
1569
|
+
// The unsigned Relay envelope cannot assert escrow, payout or refund.
|
|
1570
|
+
status: ["queued", "pending", "submitted", "broadcast_unknown", "failed", "rejected"].includes(settlement.status) ? settlement.status : "pending",
|
|
781
1571
|
accepted: Boolean(settlement.accepted),
|
|
782
1572
|
endpoint,
|
|
783
1573
|
model,
|
|
784
1574
|
route_model: routeModel,
|
|
785
1575
|
relay_url: relayUrl,
|
|
786
1576
|
provider: String(receipt.provider || ""),
|
|
1577
|
+
provider_signer: String(receipt.provider_signer || ""),
|
|
1578
|
+
response_hash: String(receipt.response_hash || ""),
|
|
1579
|
+
content_verification: contentVerification,
|
|
1580
|
+
owner: String(this.unlockedWallet || ""),
|
|
1581
|
+
key_address: this.paymentAddress,
|
|
1582
|
+
chain_id: this.network.chain_id,
|
|
1583
|
+
settlement_contract: this.network.settlement_contract,
|
|
1584
|
+
source: "local-receipt",
|
|
1585
|
+
session_id: sessionId,
|
|
787
1586
|
input_tokens: Number(receipt.input_tokens || 0),
|
|
788
1587
|
output_tokens: Number(receipt.output_tokens || 0),
|
|
789
1588
|
actual_fee_units: Number(receipt.actual_fee || 0),
|
|
1589
|
+
authorization_deadline: Number(auth.deadline || 0),
|
|
1590
|
+
...(jury ? {
|
|
1591
|
+
jury_evidence_hash: jury.evidence_hash,
|
|
1592
|
+
jury_report_id: jury.predicted_report_id,
|
|
1593
|
+
jury_reporter: jury.reporter,
|
|
1594
|
+
jury_origin_relay_public_key: jury.origin_relay_public_key,
|
|
1595
|
+
jury_reference_signature: jury.signature.signature,
|
|
1596
|
+
jury_reference_timestamp: jury.signature.timestamp,
|
|
1597
|
+
} : {}),
|
|
790
1598
|
};
|
|
791
|
-
|
|
792
|
-
try { chmodSync(this.historyPath, 0o600); } catch {}
|
|
1599
|
+
this.historyLedger.append(entry);
|
|
793
1600
|
}
|
|
794
1601
|
|
|
795
1602
|
async rpcValue(callback) {
|
|
796
1603
|
const errors = [];
|
|
797
|
-
|
|
798
|
-
|
|
1604
|
+
let candidates = [...new Set(this.network.rpc_urls)];
|
|
1605
|
+
if (candidates.includes(this.preferredRpcUrl)) {
|
|
1606
|
+
candidates = [this.preferredRpcUrl, ...candidates.filter((url) => url !== this.preferredRpcUrl)];
|
|
1607
|
+
}
|
|
1608
|
+
// Each callback is a complete read against one endpoint. Restart the whole
|
|
1609
|
+
// verification on failover; retry transient read failures at most once.
|
|
1610
|
+
for (let attempt = 0; attempt < 2 && candidates.length; attempt += 1) {
|
|
1611
|
+
const retry = [];
|
|
1612
|
+
for (const rpcUrl of candidates) {
|
|
1613
|
+
try {
|
|
1614
|
+
const value = await callback(rpcUrl);
|
|
1615
|
+
this.preferredRpcUrl = rpcUrl;
|
|
1616
|
+
return value;
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
errors.push(error.message);
|
|
1619
|
+
if (error.rpcRetryable === true) retry.push(rpcUrl);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
candidates = retry;
|
|
799
1623
|
}
|
|
800
|
-
|
|
1624
|
+
const error = new Error(`all configured Settlement V${this.network.protocol_version} RPC endpoints failed: ${errors.join("; ")}`);
|
|
1625
|
+
error.code = "rpc_unavailable";
|
|
1626
|
+
error.statusCode = 503;
|
|
1627
|
+
throw error;
|
|
801
1628
|
}
|
|
802
1629
|
|
|
803
1630
|
async callRpc(rpcUrl, method, params) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1631
|
+
try {
|
|
1632
|
+
const response = await fetchWithTimeout(rpcUrl, {
|
|
1633
|
+
method: "POST",
|
|
1634
|
+
headers: { "content-type": "application/json" },
|
|
1635
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
1636
|
+
dispatcher: this.dispatcher,
|
|
1637
|
+
}, this.healthTimeoutMs);
|
|
1638
|
+
const payload = await readJsonResponse(response);
|
|
1639
|
+
if (!response.ok || payload.error) {
|
|
1640
|
+
const error = new Error(payload.error?.message || `RPC ${response.status}`);
|
|
1641
|
+
error.rpcRetryable = [408, 429, 500, 502, 503, 504].includes(response.status)
|
|
1642
|
+
|| [-32002, -32005].includes(payload.error?.code);
|
|
1643
|
+
throw error;
|
|
1644
|
+
}
|
|
1645
|
+
return payload.result;
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
if (error.name === "TimeoutError" || error.name === "AbortError"
|
|
1648
|
+
|| (error instanceof TypeError && ["fetch failed", "terminated"].includes(error.message))) {
|
|
1649
|
+
error.rpcRetryable = true;
|
|
1650
|
+
}
|
|
1651
|
+
throw error;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async contractCall(rpcUrl, contract, signature, args, blockTag = "latest") {
|
|
816
1656
|
const data = `0x${bytesToHex(keccak_256(Buffer.from(signature, "ascii")).slice(0, 4))}${args.map((arg) => abiWord(arg).toString("hex")).join("")}`;
|
|
817
|
-
return this.callRpc(rpcUrl, "eth_call", [{ to: normalizeAddress(contract), data },
|
|
1657
|
+
return this.callRpc(rpcUrl, "eth_call", [{ to: normalizeAddress(contract), data }, blockTag]);
|
|
818
1658
|
}
|
|
819
1659
|
|
|
820
1660
|
async keyGrant(address) {
|
|
@@ -829,18 +1669,216 @@ export class NativeConsumerState {
|
|
|
829
1669
|
};
|
|
830
1670
|
}
|
|
831
1671
|
|
|
1672
|
+
async dynamicJuryNetworkReady(rpc) {
|
|
1673
|
+
if (this.network.committee_mode !== DYNAMIC_PROVIDER_AI_COMMITTEE) return true;
|
|
1674
|
+
const settlement = this.network.settlement_contract;
|
|
1675
|
+
const registry = this.network.jury_registry;
|
|
1676
|
+
const history = this.network.reputation_history_import;
|
|
1677
|
+
const settlementDeploymentBlock = await this.callRpc(
|
|
1678
|
+
rpc, "eth_getBlockByNumber", [`0x${this.network.deployment_block.toString(16)}`, false],
|
|
1679
|
+
);
|
|
1680
|
+
if (!settlementDeploymentBlock
|
|
1681
|
+
|| BigInt(settlementDeploymentBlock.number) !== BigInt(this.network.deployment_block)
|
|
1682
|
+
|| normalizeBytes32(
|
|
1683
|
+
settlementDeploymentBlock.hash, "Settlement deployment block hash",
|
|
1684
|
+
) !== this.network.deployment_block_hash) {
|
|
1685
|
+
throw new Error("Settlement deployment block is not canonical");
|
|
1686
|
+
}
|
|
1687
|
+
const settlementRuntime = String(await this.callRpc(
|
|
1688
|
+
rpc, "eth_getCode", [settlement, {
|
|
1689
|
+
blockHash: this.network.deployment_block_hash, requireCanonical: true,
|
|
1690
|
+
}],
|
|
1691
|
+
) || "");
|
|
1692
|
+
if (!/^0x[0-9a-f]+$/.test(settlementRuntime)
|
|
1693
|
+
|| settlementRuntime === "0x" || /^0x0+$/.test(settlementRuntime)) {
|
|
1694
|
+
throw new Error("Settlement runtime is unavailable at its deployment boundary");
|
|
1695
|
+
}
|
|
1696
|
+
const settlementRuntimeHash = `0x${bytesToHex(keccak_256(hexToBytes(
|
|
1697
|
+
settlementRuntime, "Settlement deployment runtime",
|
|
1698
|
+
)))}`;
|
|
1699
|
+
if (settlementRuntimeHash !== this.network.settlement_runtime_code_keccak256) {
|
|
1700
|
+
throw new Error("Settlement runtime differs from its deployment manifest pin");
|
|
1701
|
+
}
|
|
1702
|
+
const genesis = await this.callRpc(rpc, "eth_getBlockByNumber", ["0x0", false]);
|
|
1703
|
+
if (!genesis || normalizeBytes32(genesis.hash, "RPC genesis hash") !== this.network.genesis_hash) {
|
|
1704
|
+
throw new Error("Settlement RPC genesis differs from reputation history lineage");
|
|
1705
|
+
}
|
|
1706
|
+
const sourceDeploymentNumber = BigInt(history.source_deployment_block);
|
|
1707
|
+
const sourceThroughNumber = BigInt(history.source_history_through_block);
|
|
1708
|
+
const [sourceDeploymentBlock, sourceThroughBlock, sourcePredeploymentBlock, sourceHeadRaw] = await Promise.all([
|
|
1709
|
+
this.callRpc(rpc, "eth_getBlockByNumber", [`0x${sourceDeploymentNumber.toString(16)}`, false]),
|
|
1710
|
+
this.callRpc(rpc, "eth_getBlockByNumber", [`0x${sourceThroughNumber.toString(16)}`, false]),
|
|
1711
|
+
this.callRpc(rpc, "eth_getBlockByNumber", [`0x${(sourceDeploymentNumber - 1n).toString(16)}`, false]),
|
|
1712
|
+
this.callRpc(rpc, "eth_blockNumber", []),
|
|
1713
|
+
]);
|
|
1714
|
+
const sourceHead = BigInt(sourceHeadRaw);
|
|
1715
|
+
const sourceBlocks = [
|
|
1716
|
+
[sourceDeploymentBlock, sourceDeploymentNumber, history.source_deployment_block_hash, "deployment"],
|
|
1717
|
+
[sourceThroughBlock, sourceThroughNumber, history.source_history_through_block_hash, "history cutoff"],
|
|
1718
|
+
];
|
|
1719
|
+
for (const [block, expectedNumber, expectedHash, label] of sourceBlocks) {
|
|
1720
|
+
if (!block || BigInt(block.number) !== expectedNumber
|
|
1721
|
+
|| normalizeBytes32(block.hash, `source ${label} block hash`) !== expectedHash) {
|
|
1722
|
+
throw new Error(`reputation history source ${label} block is not canonical`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (!sourcePredeploymentBlock
|
|
1726
|
+
|| BigInt(sourcePredeploymentBlock.number) !== sourceDeploymentNumber - 1n) {
|
|
1727
|
+
throw new Error("reputation history source predeployment block is unavailable");
|
|
1728
|
+
}
|
|
1729
|
+
if (sourceHead - sourceThroughNumber + 1n < BigInt(history.confirmations)) {
|
|
1730
|
+
throw new Error("reputation history cutoff does not have the required confirmations");
|
|
1731
|
+
}
|
|
1732
|
+
const predeploymentHash = normalizeBytes32(
|
|
1733
|
+
sourcePredeploymentBlock.hash, "source predeployment block hash",
|
|
1734
|
+
);
|
|
1735
|
+
const predeploymentCode = String(await this.callRpc(
|
|
1736
|
+
rpc, "eth_getCode", [history.source_settlement_contract, {
|
|
1737
|
+
blockHash: predeploymentHash, requireCanonical: true,
|
|
1738
|
+
}],
|
|
1739
|
+
) || "");
|
|
1740
|
+
if (predeploymentCode !== "0x") {
|
|
1741
|
+
throw new Error("historical Settlement existed before its pinned deployment block");
|
|
1742
|
+
}
|
|
1743
|
+
const historicalCode = String(await this.callRpc(
|
|
1744
|
+
rpc, "eth_getCode", [history.source_settlement_contract, {
|
|
1745
|
+
blockHash: history.source_deployment_block_hash, requireCanonical: true,
|
|
1746
|
+
}],
|
|
1747
|
+
) || "");
|
|
1748
|
+
if (!/^0x[0-9a-f]+$/.test(historicalCode) || historicalCode === "0x" || /^0x0+$/.test(historicalCode)) {
|
|
1749
|
+
throw new Error("historical Settlement contract is unavailable on the configured network");
|
|
1750
|
+
}
|
|
1751
|
+
const historicalRuntimeHash = `0x${bytesToHex(keccak_256(hexToBytes(
|
|
1752
|
+
historicalCode, "historical Settlement runtime",
|
|
1753
|
+
)))}`;
|
|
1754
|
+
if (historicalRuntimeHash !== history.source_runtime_code_hash) {
|
|
1755
|
+
throw new Error("historical Settlement runtime differs from reputation history lineage");
|
|
1756
|
+
}
|
|
1757
|
+
const registryCode = String(await this.callRpc(rpc, "eth_getCode", [registry, "latest"]) || "");
|
|
1758
|
+
if (!/^0x[0-9a-f]+$/i.test(registryCode) || registryCode === "0x" || /^0x0+$/i.test(registryCode)) {
|
|
1759
|
+
throw new Error("jury registry contract is unavailable on the configured network");
|
|
1760
|
+
}
|
|
1761
|
+
const [settlementRegistry, settlementThreshold, registrySettlement, registryGovernance, reputationAuthority,
|
|
1762
|
+
bondPenaltyRecipient, minimumReputation, jurySize, registryThreshold, selectionDelay, randomnessMode,
|
|
1763
|
+
rosterVersion, providerCount, canFormJury] = await Promise.all([
|
|
1764
|
+
this.contractCall(rpc, settlement, "juryRegistry()", []),
|
|
1765
|
+
this.contractCall(rpc, settlement, "adjudicationThreshold()", []),
|
|
1766
|
+
this.contractCall(rpc, registry, "settlement()", []),
|
|
1767
|
+
this.contractCall(rpc, registry, "governance()", []),
|
|
1768
|
+
this.contractCall(rpc, registry, "reputationAuthority()", []),
|
|
1769
|
+
this.contractCall(rpc, registry, "bondPenaltyRecipient()", []),
|
|
1770
|
+
this.contractCall(rpc, registry, "minimumReputation()", []),
|
|
1771
|
+
this.contractCall(rpc, registry, "jurySize()", []),
|
|
1772
|
+
this.contractCall(rpc, registry, "threshold()", []),
|
|
1773
|
+
this.contractCall(rpc, registry, "selectionDelayBlocks()", []),
|
|
1774
|
+
this.contractCall(rpc, registry, "RANDOMNESS_MODE_HASH()", []),
|
|
1775
|
+
this.contractCall(rpc, registry, "rosterVersion()", []),
|
|
1776
|
+
this.contractCall(rpc, registry, "providerCount()", []),
|
|
1777
|
+
this.contractCall(rpc, registry, "canFormJury()", []),
|
|
1778
|
+
]);
|
|
1779
|
+
const checks = [
|
|
1780
|
+
[decodeAbiAddress(settlementRegistry, "Settlement juryRegistry"), registry, "Settlement juryRegistry"],
|
|
1781
|
+
[decodeAbiUint(settlementThreshold, "Settlement adjudicationThreshold", 16), BigInt(this.network.adjudication_threshold), "Settlement adjudicationThreshold"],
|
|
1782
|
+
[decodeAbiAddress(registrySettlement, "jury registry settlement"), settlement, "jury registry settlement"],
|
|
1783
|
+
[decodeAbiAddress(registryGovernance, "jury registry governance"), this.network.jury_registry_governance, "jury registry governance"],
|
|
1784
|
+
[decodeAbiAddress(reputationAuthority, "jury registry reputationAuthority"), this.network.reputation_authority, "jury registry reputationAuthority"],
|
|
1785
|
+
[decodeAbiAddress(bondPenaltyRecipient, "jury registry bondPenaltyRecipient"), this.network.bond_penalty_recipient, "jury registry bondPenaltyRecipient"],
|
|
1786
|
+
[decodeAbiUint(minimumReputation, "jury registry minimumReputation", 64), BigInt(this.network.minimum_provider_reputation), "jury registry minimumReputation"],
|
|
1787
|
+
[decodeAbiUint(jurySize, "jury registry jurySize", 16), BigInt(this.network.jury_size), "jury registry jurySize"],
|
|
1788
|
+
[decodeAbiUint(registryThreshold, "jury registry threshold", 16), BigInt(this.network.adjudication_threshold), "jury registry threshold"],
|
|
1789
|
+
[decodeAbiUint(selectionDelay, "jury registry selectionDelayBlocks", 16), BigInt(this.network.jury_selection_delay_blocks), "jury registry selectionDelayBlocks"],
|
|
1790
|
+
[normalizeBytes32(randomnessMode, "jury registry RANDOMNESS_MODE_HASH"), DYNAMIC_JURY_RANDOMNESS_HASH, "jury registry RANDOMNESS_MODE_HASH"],
|
|
1791
|
+
];
|
|
1792
|
+
for (const [actual, expected, label] of checks) {
|
|
1793
|
+
if (actual !== expected) throw new Error(`${label} does not match the deployment manifest`);
|
|
1794
|
+
}
|
|
1795
|
+
const liveProviderCount = decodeAbiUint(providerCount, "jury registry providerCount");
|
|
1796
|
+
if (liveProviderCount < BigInt(this.network.jury_size)
|
|
1797
|
+
|| liveProviderCount > BigInt(MAX_DYNAMIC_PROVIDERS)) throw new Error("jury registry Provider pool is outside supported bounds");
|
|
1798
|
+
if (decodeAbiUint(rosterVersion, "jury registry rosterVersion", 64) === 0n) throw new Error("jury registry reputation pool is uninitialized");
|
|
1799
|
+
if (!decodeAbiBool(canFormJury, "jury registry canFormJury")) throw new Error("jury registry cannot form the declared jury");
|
|
1800
|
+
return true;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
async settlementNetworkReady() {
|
|
1804
|
+
return this.rpcValue(async (rpc) => {
|
|
1805
|
+
const chainId = await this.callRpc(rpc, "eth_chainId", []);
|
|
1806
|
+
if (BigInt(chainId) !== BigInt(this.network.chain_id)) {
|
|
1807
|
+
const error = new Error("Settlement RPC is connected to the wrong chain");
|
|
1808
|
+
error.code = "network_mismatch";
|
|
1809
|
+
throw error;
|
|
1810
|
+
}
|
|
1811
|
+
const code = String(await this.callRpc(rpc, "eth_getCode", [this.network.settlement_contract, "latest"]) || "");
|
|
1812
|
+
if (!/^0x[0-9a-f]+$/i.test(code) || code === "0x" || /^0x0+$/i.test(code)) {
|
|
1813
|
+
const error = new Error("Settlement contract is unavailable on the configured network");
|
|
1814
|
+
error.code = "settlement_unavailable";
|
|
1815
|
+
throw error;
|
|
1816
|
+
}
|
|
1817
|
+
await this.dynamicJuryNetworkReady(rpc);
|
|
1818
|
+
return true;
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
|
|
832
1822
|
async accountBalance(owner) {
|
|
833
1823
|
const output = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.settlement_contract, "availableBalance(address)", [normalizeAddress(owner)]));
|
|
834
1824
|
return BigInt(output || "0x0").toString();
|
|
835
1825
|
}
|
|
836
1826
|
|
|
1827
|
+
// These two reads are independent. Keep them in one helper so the local
|
|
1828
|
+
// dashboard can fetch the wallet snapshot in one RPC round instead of
|
|
1829
|
+
// serialising the token balance behind the allowance read. Both calls are
|
|
1830
|
+
// read-only and still use rpcValue's endpoint failover independently.
|
|
1831
|
+
async walletSnapshot(owner) {
|
|
1832
|
+
const [token, allowance] = await Promise.all([
|
|
1833
|
+
this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "balanceOf(address)", [normalizeAddress(owner)])),
|
|
1834
|
+
this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "allowance(address,address)", [normalizeAddress(owner), this.network.settlement_contract])),
|
|
1835
|
+
]);
|
|
1836
|
+
return {
|
|
1837
|
+
address: owner,
|
|
1838
|
+
token_balance_units: BigInt(token || "0x0").toString(),
|
|
1839
|
+
allowance_units: BigInt(allowance || "0x0").toString(),
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
|
|
837
1843
|
async dashboardPayload(managementAuthorized = false) {
|
|
838
1844
|
const authenticated = managementAuthorized && Boolean(this.unlockedWallet);
|
|
1845
|
+
if (authenticated) {
|
|
1846
|
+
await this.refreshDisputeTransactions().catch(() => {
|
|
1847
|
+
this.disputeSyncError = "申诉交易状态暂时无法核验";
|
|
1848
|
+
});
|
|
1849
|
+
void this.refreshReceiptStatuses().catch(() => { this.historySyncError = "暂时无法同步账单状态"; });
|
|
1850
|
+
}
|
|
839
1851
|
const allHistory = this.history(0);
|
|
1852
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1853
|
+
const dashboardHistory = authenticated ? allHistory.slice(0, 100).map((item) => {
|
|
1854
|
+
const hasReference = Boolean(item.jury_evidence_hash && item.jury_report_id
|
|
1855
|
+
&& item.jury_reporter === this.unlockedWallet && item.jury_reference_signature);
|
|
1856
|
+
const disputeActive = ["wallet_prompted", "submitted", "uncertain", "confirmed"].includes(item.dispute_stage);
|
|
1857
|
+
const disputeEligible = this.network.protocol_version === 10
|
|
1858
|
+
&& this.network.committee_mode === DYNAMIC_PROVIDER_AI_COMMITTEE
|
|
1859
|
+
&& item.status === "escrowed" && hasReference && !disputeActive
|
|
1860
|
+
&& Number(item.settlement_release_at || 0) > now;
|
|
1861
|
+
return {
|
|
1862
|
+
...item,
|
|
1863
|
+
dispute_eligible: disputeEligible,
|
|
1864
|
+
...(item.dispute_tx_hash && this.network.explorer_url ? {
|
|
1865
|
+
dispute_explorer_url: `${String(this.network.explorer_url).replace(/\/$/, "")}/tx/${item.dispute_tx_hash}`,
|
|
1866
|
+
} : {}),
|
|
1867
|
+
};
|
|
1868
|
+
}) : [];
|
|
1869
|
+
const unknownFees = authenticated ? allHistory.filter((item) => item.actual_fee_units == null
|
|
1870
|
+
&& !["not_dispatched", "failed", "rejected"].includes(item.status)) : [];
|
|
840
1871
|
const pending = authenticated ? this.pendingPaymentKey() : null;
|
|
841
1872
|
const payload = {
|
|
842
1873
|
ok: true,
|
|
843
|
-
|
|
1874
|
+
liveness_ready: true,
|
|
1875
|
+
paid_readiness_checked: authenticated,
|
|
1876
|
+
paid_ready: false,
|
|
1877
|
+
wallet_ready: false,
|
|
1878
|
+
network_ready: false,
|
|
1879
|
+
budget_ready: false,
|
|
1880
|
+
models_ready: false,
|
|
1881
|
+
protocol_version: this.network.protocol_version,
|
|
844
1882
|
runtime: "node-native",
|
|
845
1883
|
auth: authenticated ? this.walletAuthPayload() : { authenticated: false, wallet: null, key_ready: false },
|
|
846
1884
|
credentials: authenticated && this.paymentUnlocked
|
|
@@ -852,37 +1890,240 @@ export class NativeConsumerState {
|
|
|
852
1890
|
pending: pending ? { payment_key_address: pending.payment_key_address } : null,
|
|
853
1891
|
},
|
|
854
1892
|
settlement: this.network,
|
|
855
|
-
history:
|
|
1893
|
+
history: dashboardHistory,
|
|
1894
|
+
history_scope: "current-payment-key-on-this-device",
|
|
1895
|
+
history_sync: authenticated ? { running: Boolean(this.historySync), last_checked_at: this.historySyncAt, error: this.historySyncError } : null,
|
|
1896
|
+
recovery: this.recoveryPayload(allHistory, authenticated),
|
|
1897
|
+
dispute_sync: authenticated ? { running: Boolean(this.disputeSync), last_checked_at: this.disputeSyncAt || 0, error: this.disputeSyncError || null } : null,
|
|
856
1898
|
usage: {
|
|
857
1899
|
request_count: authenticated ? allHistory.length : 0,
|
|
858
1900
|
total_spent_units: authenticated ? allHistory.reduce((total, item) => total + Number(item.actual_fee_units || 0), 0) : 0,
|
|
1901
|
+
settled_units: authenticated ? allHistory.filter((item) => ["confirmed", "released", "dismissed", "timed_out"].includes(item.status)).reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
|
|
1902
|
+
pending_units: authenticated ? allHistory.filter((item) => !["confirmed", "released", "refunded", "dismissed", "timed_out", "failed", "rejected"].includes(item.status)).reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
|
|
1903
|
+
unknown_fee_count: unknownFees.length,
|
|
1904
|
+
unknown_fee_authorized_maximum_units: unknownFees.reduce((sum, item) => sum + Number(item.max_fee_units || 0), 0),
|
|
1905
|
+
refunded_units: authenticated ? allHistory.filter((item) => item.status === "refunded").reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
|
|
1906
|
+
failed_units: authenticated ? allHistory.filter((item) => ["failed", "rejected"].includes(item.status)).reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
|
|
859
1907
|
input_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.input_tokens || 0), 0) : 0,
|
|
860
1908
|
output_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.output_tokens || 0), 0) : 0,
|
|
861
1909
|
},
|
|
862
1910
|
share: authenticated ? this.sharePayload() : { active: false },
|
|
863
1911
|
};
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1912
|
+
let channelSnapshot = null;
|
|
1913
|
+
let dashboardGrant = null;
|
|
1914
|
+
// Chain/code identity, key ownership, and fixed-budget channel state are
|
|
1915
|
+
// independent reads. Preserve successful snapshots when one RPC fails.
|
|
1916
|
+
const [networkResult, grantResult, channelsResult] = await Promise.allSettled([
|
|
1917
|
+
this.settlementNetworkReady(),
|
|
1918
|
+
this.keyGrant(this.paymentAddress),
|
|
1919
|
+
authenticated && this.network.protocol_version === 10 ? this.capacityChannels() : Promise.resolve(null),
|
|
1920
|
+
]);
|
|
1921
|
+
payload.network_ready = networkResult.status === "fulfilled" && networkResult.value === true;
|
|
1922
|
+
if (grantResult.status === "fulfilled") {
|
|
1923
|
+
dashboardGrant = grantResult.value;
|
|
1924
|
+
payload.key.grant = dashboardGrant;
|
|
1925
|
+
}
|
|
1926
|
+
if (channelsResult.status === "fulfilled") channelSnapshot = channelsResult.value;
|
|
1927
|
+
else if (authenticated && this.network.protocol_version === 10) {
|
|
1928
|
+
// Capacity is chain state, not an empty budget. If it cannot be read,
|
|
1929
|
+
// fail the network dimension instead of telling the user to fund again.
|
|
1930
|
+
payload.network_ready = false;
|
|
1931
|
+
}
|
|
1932
|
+
const chainErrors = [networkResult, grantResult, channelsResult]
|
|
1933
|
+
.filter((result) => result.status === "rejected")
|
|
1934
|
+
.map((result) => result.reason?.message || String(result.reason));
|
|
1935
|
+
if (chainErrors.length) payload.chain_error = chainErrors.join("; ");
|
|
1936
|
+
if (authenticated && this.network.protocol_version === 10 && channelSnapshot) {
|
|
1937
|
+
const now = Math.floor(Date.now()/1000);
|
|
1938
|
+
payload.capacity_channels = channelSnapshot.filter(c => c.consumer_owner === this.unlockedWallet)
|
|
1939
|
+
.map(c => ({ ...c, budget: this.capacityBudget(c, allHistory, now) }));
|
|
1940
|
+
payload.budget_locked_units = payload.capacity_channels.filter(c=>!c.closed).reduce((n,c)=>n+BigInt(c.credit_remaining),0n).toString();
|
|
1941
|
+
payload.budget_available_units = payload.capacity_channels.filter(c => c.budget.ready)
|
|
1942
|
+
.reduce((total,c) => total+BigInt(c.budget.remaining_units),0n).toString();
|
|
1943
|
+
payload.budget_note = "固定预算在结算窗口结束前不会撤回;每次请求只占用授权上限。结算按 2 小时或 100 笔先到触发。撤销 Key 只会停止新请求,不会改变已有账单。";
|
|
1944
|
+
}
|
|
1945
|
+
if (authenticated && this.paymentUnlocked) {
|
|
1946
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1947
|
+
const activeGrant = Boolean(dashboardGrant
|
|
1948
|
+
&& dashboardGrant.owner === this.unlockedWallet && dashboardGrant.active === true
|
|
1949
|
+
&& Number(dashboardGrant.max_per_request) >= this.maxFeeUnits
|
|
1950
|
+
&& (Number(dashboardGrant.valid_until) === 0 || Number(dashboardGrant.valid_until) > now + Math.ceil(this.timeoutMs / 1000)));
|
|
1951
|
+
const activeChannel = this.network.protocol_version === 10 && Array.isArray(channelSnapshot)
|
|
1952
|
+
&& channelSnapshot.some((channel) => !channel.closed && channel.consumer_owner === this.unlockedWallet
|
|
1953
|
+
&& channel.consumer_key === this.paymentAddress && Number(channel.admit_until) > now);
|
|
1954
|
+
payload.wallet_ready = activeGrant || activeChannel;
|
|
872
1955
|
}
|
|
873
1956
|
if (authenticated) {
|
|
1957
|
+
if (dashboardGrant && (dashboardGrant.owner === this.unlockedWallet
|
|
1958
|
+
|| (this.network.protocol_version === 10 && dashboardGrant.owner === ZERO_ADDRESS))) {
|
|
1959
|
+
try {
|
|
1960
|
+
payload.account = { owner: this.unlockedWallet, available_balance_units: await this.accountBalance(this.unlockedWallet) };
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
payload.account_error = error.message;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
874
1965
|
try {
|
|
875
1966
|
const address = this.unlockedWallet;
|
|
876
|
-
|
|
877
|
-
const allowance = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "allowance(address,address)", [address, this.network.settlement_contract]));
|
|
878
|
-
payload.wallet = { address, token_balance_units: BigInt(token || "0x0").toString(), allowance_units: BigInt(allowance || "0x0").toString() };
|
|
1967
|
+
payload.wallet = await this.walletSnapshot(address);
|
|
879
1968
|
} catch (error) {
|
|
880
1969
|
payload.wallet_error = error.message;
|
|
881
1970
|
}
|
|
1971
|
+
try {
|
|
1972
|
+
const selected = await this.chooseRelay(new Set(), { checkCapacity: false });
|
|
1973
|
+
this.verifyRelayIdentity(selected.relayUrl, selected.health, { requirePin: true });
|
|
1974
|
+
payload.models_ready = true;
|
|
1975
|
+
} catch (error) {
|
|
1976
|
+
payload.models_ready = false;
|
|
1977
|
+
payload.inference_error = error.message;
|
|
1978
|
+
payload.inference_code = error.code || "models_unavailable";
|
|
1979
|
+
}
|
|
1980
|
+
if (this.network.protocol_version === 10) {
|
|
1981
|
+
const channels = payload.capacity_channels || [];
|
|
1982
|
+
const ready = channels.filter((channel) => channel.budget?.ready);
|
|
1983
|
+
if (!ready.length) {
|
|
1984
|
+
const availableAt = channels.map((channel) => channel.budget?.available_at).filter(Number.isFinite).sort((a, b) => a - b)[0];
|
|
1985
|
+
payload.budget_ready = false;
|
|
1986
|
+
payload.inference_code = availableAt ? "budget_not_started" : "budget_unavailable";
|
|
1987
|
+
payload.inference_error = availableAt
|
|
1988
|
+
? `Fixed budget becomes available at ${new Date(availableAt * 1000).toISOString()}`
|
|
1989
|
+
: "No active funded channel covers this model and Provider; renew the fixed budget";
|
|
1990
|
+
if (availableAt) payload.budget_available_at = availableAt;
|
|
1991
|
+
} else {
|
|
1992
|
+
try {
|
|
1993
|
+
// A locally usable channel is not enough: require a currently
|
|
1994
|
+
// healthy Relay/Provider route bound to that same channel.
|
|
1995
|
+
payload.budget_ready = Boolean(await this.chooseRelay());
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
payload.budget_ready = false;
|
|
1998
|
+
payload.inference_error = error.message;
|
|
1999
|
+
payload.inference_code = error.code || "budget_unavailable";
|
|
2000
|
+
if (error.code === "rpc_unavailable") payload.network_ready = false;
|
|
2001
|
+
if (error.availableAt) payload.budget_available_at = error.availableAt;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
} else {
|
|
2005
|
+
payload.budget_ready = Boolean(payload.account
|
|
2006
|
+
&& BigInt(payload.account.available_balance_units) >= BigInt(this.maxFeeUnits));
|
|
2007
|
+
if (!payload.budget_ready && !payload.inference_code) payload.inference_code = "budget_unavailable";
|
|
2008
|
+
}
|
|
2009
|
+
payload.paid_ready = payload.wallet_ready && payload.network_ready && payload.budget_ready && payload.models_ready;
|
|
2010
|
+
payload.inference_ready = payload.paid_ready;
|
|
2011
|
+
if (!payload.paid_ready) {
|
|
2012
|
+
payload.inference_code = !payload.network_ready ? "network_unavailable"
|
|
2013
|
+
: !payload.wallet_ready ? "payment_key_not_ready"
|
|
2014
|
+
: !payload.models_ready ? "models_unavailable"
|
|
2015
|
+
: payload.inference_code === "budget_not_started" ? "budget_not_started" : "budget_unavailable";
|
|
2016
|
+
}
|
|
882
2017
|
}
|
|
883
2018
|
return payload;
|
|
884
2019
|
}
|
|
885
2020
|
|
|
2021
|
+
async queryReceiptStatus(entry, privateKey = this.paymentKey) {
|
|
2022
|
+
if (!this.relayUrls.includes(entry.relay_url)) return null;
|
|
2023
|
+
const query = createReceiptStatusQuery(privateKey, { chainId: this.network.chain_id,
|
|
2024
|
+
contract: this.network.settlement_contract, requestId: entry.request_id, channelId: entry.capacity_channel_id });
|
|
2025
|
+
const response = await fetchWithTimeout(`${entry.relay_url}/v1/mycomesh/receipts/status`, {
|
|
2026
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(query), dispatcher: this.dispatcher,
|
|
2027
|
+
}, 5000);
|
|
2028
|
+
const value = await readJsonResponse(response);
|
|
2029
|
+
if (response.status === 404) return null; // Old Relay or an unknown receipt; never assume settled/failed.
|
|
2030
|
+
if (!response.ok || value.request_id !== entry.request_id || (entry.capacity_channel_id && value.channel_id !== entry.capacity_channel_id)) throw new Error("invalid Relay receipt status response");
|
|
2031
|
+
if (!["pending", "submitted", "broadcast_unknown", "confirmed", "escrowed", "failed"].includes(value.status)) throw new Error("invalid receipt state");
|
|
2032
|
+
return value;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async refreshReceiptStatuses(force = false) {
|
|
2036
|
+
if (!this.unlockedWallet || !this.managementToken) return;
|
|
2037
|
+
if (this.historySync) return this.historySync;
|
|
2038
|
+
if (!force && Date.now() - this.historySyncAt < 30_000) return;
|
|
2039
|
+
this.historySyncAt = Date.now();
|
|
2040
|
+
const owner = this.unlockedWallet, key = this.paymentAddress, privateKey = this.paymentKey, ledger = this.historyLedger;
|
|
2041
|
+
const candidates = ledger.history(0).filter((entry) => (entry.accepted || ["dispatching", "outcome_unknown"].includes(entry.status)) && !["confirmed", "released", "refunded", "dismissed", "timed_out"].includes(entry.status)
|
|
2042
|
+
&& /^0x[0-9a-f]{64}$/i.test(entry.request_id));
|
|
2043
|
+
const live = candidates.filter((entry) => !["failed", "rejected"].includes(entry.status));
|
|
2044
|
+
const terminal = candidates.filter((entry) => ["failed", "rejected"].includes(entry.status));
|
|
2045
|
+
this.historySyncCursors ||= { live: 0, terminal: 0 };
|
|
2046
|
+
const take = (rows, count, group) => {
|
|
2047
|
+
if (!rows.length) return [];
|
|
2048
|
+
const start = this.historySyncCursors[group] % rows.length;
|
|
2049
|
+
const selected = Array.from({ length: Math.min(count, rows.length) }, (_, index) => rows[(start + index) % rows.length]);
|
|
2050
|
+
this.historySyncCursors[group] = (start + selected.length) % rows.length;
|
|
2051
|
+
return selected;
|
|
2052
|
+
};
|
|
2053
|
+
// Reserve a small share for rechecking failed records while rotating all
|
|
2054
|
+
// live work. Old pending receipts cannot be hidden by recent failures.
|
|
2055
|
+
const pending = take(live, terminal.length ? 16 : 20, "live");
|
|
2056
|
+
pending.push(...take(terminal, 20 - pending.length, "terminal"));
|
|
2057
|
+
this.historySyncError = null;
|
|
2058
|
+
const check = async () => {
|
|
2059
|
+
while (pending.length) {
|
|
2060
|
+
const entry = pending.shift();
|
|
2061
|
+
try {
|
|
2062
|
+
const settlementKey = this.network.protocol_version === 10 ? reservedSettlementKey(entry.capacity_channel_id, entry.request_id) : keccakHex(Buffer.concat([abiWord(owner), abiWord(key), abiWord(entry.request_id)]));
|
|
2063
|
+
if (this.network.protocol_version >= 9) {
|
|
2064
|
+
const snapshot = await this.rpcValue(async (rpc) => {
|
|
2065
|
+
const block = canonicalBlock(
|
|
2066
|
+
await this.callRpc(rpc, "eth_getBlockByNumber", ["safe", false]),
|
|
2067
|
+
"safe Settlement block",
|
|
2068
|
+
);
|
|
2069
|
+
const record = await this.contractCall(rpc, this.network.settlement_contract, "settlementInfo(bytes32)", [settlementKey],
|
|
2070
|
+
{ blockHash: block.hash, requireCanonical: true });
|
|
2071
|
+
return { record, block };
|
|
2072
|
+
});
|
|
2073
|
+
const result = snapshot.record;
|
|
2074
|
+
if (typeof result !== "string" || !/^0x[0-9a-f]{1280}$/i.test(result)) throw new Error("invalid V9 settlement record");
|
|
2075
|
+
const words = result.slice(2).match(/.{64}/g);
|
|
2076
|
+
if (words.slice(0, 8).some((word) => !/^0{24}/.test(word)) || BigInt(`0x${words[19]}`) > 6n
|
|
2077
|
+
|| BigInt(`0x${words[17]}`) >= (1n << 64n) || BigInt(`0x${words[18]}`) >= (1n << 64n)) throw new Error("noncanonical V9 settlement record");
|
|
2078
|
+
const status = [null, "escrowed", "disputed", "released", "refunded", "dismissed", "timed_out"][Number(BigInt(`0x${words[19]}`))];
|
|
2079
|
+
if (status) {
|
|
2080
|
+
if (`0x${words[0].slice(-40)}` !== owner.toLowerCase() || `0x${words[1].slice(-40)}` !== key.toLowerCase() || `0x${words[8]}` !== entry.request_id.toLowerCase()
|
|
2081
|
+
|| (entry.request_hash && `0x${words[9]}` !== entry.request_hash.toLowerCase())
|
|
2082
|
+
|| (entry.actual_fee_units != null && BigInt(`0x${words[12]}`) !== BigInt(entry.actual_fee_units))
|
|
2083
|
+
|| (entry.provider && `0x${words[2].slice(-40)}` !== entry.provider.toLowerCase())
|
|
2084
|
+
|| (entry.provider_signer && `0x${words[3].slice(-40)}` !== entry.provider_signer.toLowerCase())
|
|
2085
|
+
|| (entry.response_hash && `0x${words[11]}` !== entry.response_hash.toLowerCase())) throw new Error("V9 escrow scope mismatch");
|
|
2086
|
+
const releaseAt = Number(BigInt(`0x${words[18]}`));
|
|
2087
|
+
if (entry.status !== status || entry.settlement_release_at !== releaseAt) {
|
|
2088
|
+
ledger.append({ ...entry, owner, status, accepted: true,
|
|
2089
|
+
actual_fee_units: jsonInteger(BigInt(`0x${words[12]}`)),
|
|
2090
|
+
settlement_release_at: releaseAt,
|
|
2091
|
+
settlement_checked_at: snapshot.block.timestamp,
|
|
2092
|
+
updated_at: Math.floor(Date.now() / 1000) });
|
|
2093
|
+
}
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
// No safe-block escrow: a Relay cannot declare escrow or release.
|
|
2097
|
+
const remote = await this.queryReceiptStatus(entry, privateKey);
|
|
2098
|
+
if (remote && ["pending", "submitted", "broadcast_unknown", "failed"].includes(remote.status) && remote.status !== entry.status) ledger.append({ ...entry, accepted: true, status: remote.status, updated_at: Math.floor(Date.now() / 1000) });
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
const result = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.settlement_contract,
|
|
2102
|
+
"settled(bytes32)", [settlementKey]));
|
|
2103
|
+
if (BigInt(result || "0x0") === 1n) {
|
|
2104
|
+
ledger.append({ ...entry, owner, status: "confirmed", confirmed_at: Math.floor(Date.now() / 1000) });
|
|
2105
|
+
} else {
|
|
2106
|
+
const remote = await this.queryReceiptStatus(entry, privateKey);
|
|
2107
|
+
if (remote && remote.status !== "confirmed") {
|
|
2108
|
+
// A Relay cannot claim a final on-chain success without the RPC
|
|
2109
|
+
// check above. Its scoped authenticated status can report failure.
|
|
2110
|
+
const update = { status: remote.status, error_code: remote.error_code ?? (entry.error_code ? "" : undefined),
|
|
2111
|
+
tx_hash: remote.tx_hash, authorization_deadline: remote.authorization_deadline || entry.authorization_deadline };
|
|
2112
|
+
if (Object.entries(update).some(([field, value]) => value != null && value !== entry[field])) {
|
|
2113
|
+
ledger.append({ ...entry, ...update, accepted: true, updated_at: Math.floor(Date.now() / 1000) });
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
} catch {
|
|
2118
|
+
this.historySyncError = "暂时无法同步链上结算状态,已保留本地账单";
|
|
2119
|
+
pending.length = 0;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
this.historySync = Promise.all([check(), check(), check()]);
|
|
2124
|
+
try { await this.historySync; } finally { this.historySync = null; }
|
|
2125
|
+
}
|
|
2126
|
+
|
|
886
2127
|
sharePayload() {
|
|
887
2128
|
const share = this.activeShare();
|
|
888
2129
|
return share ? {
|
|
@@ -992,6 +2233,9 @@ export class NativeConsumerState {
|
|
|
992
2233
|
if (!pending) throw new Error("no pending payment key exists");
|
|
993
2234
|
const grant = await this.keyGrant(pending.payment_key_address);
|
|
994
2235
|
if (!grant.active || grant.owner !== owner) throw new Error("the pending payment key is not active for this wallet on-chain");
|
|
2236
|
+
if (this.activeInferenceRequests > 0) {
|
|
2237
|
+
throw new Error("wait for active inference requests before rotating the payment key");
|
|
2238
|
+
}
|
|
995
2239
|
const previous = this.paymentAddress;
|
|
996
2240
|
const destination = join(this.dataDir, "payment-key");
|
|
997
2241
|
writeFileSync(destination, `${pending.payment_key}\n`, { mode: 0o600 });
|
|
@@ -999,6 +2243,11 @@ export class NativeConsumerState {
|
|
|
999
2243
|
unlinkSync(this.pendingKeyPath);
|
|
1000
2244
|
this.paymentKey = pending.payment_key;
|
|
1001
2245
|
this.paymentAddress = pending.payment_key_address;
|
|
2246
|
+
this.sessions = new ConsumerSessions();
|
|
2247
|
+
this.sessionRoutes.clear();
|
|
2248
|
+
this.historyLedger = new ConsumerHistoryLedger({ localPath: this.historyPath, sharedDir: this.historyDir,
|
|
2249
|
+
chainId: this.network.chain_id, contract: this.network.settlement_contract, keyAddress: this.paymentAddress });
|
|
2250
|
+
this.historySyncAt = 0;
|
|
1002
2251
|
this.paymentUnlocked = true;
|
|
1003
2252
|
return { payment_key_address: this.paymentAddress, previous_key_address: previous };
|
|
1004
2253
|
}
|
|
@@ -1008,31 +2257,369 @@ export class NativeConsumerState {
|
|
|
1008
2257
|
const wallet = this.assertUnlockedWallet(raw?.wallet);
|
|
1009
2258
|
const settlement = this.network.settlement_contract;
|
|
1010
2259
|
const token = this.network.stablecoin;
|
|
2260
|
+
if (action === "open_dispute") {
|
|
2261
|
+
if (this.network.protocol_version !== 10
|
|
2262
|
+
|| this.network.committee_mode !== DYNAMIC_PROVIDER_AI_COMMITTEE) {
|
|
2263
|
+
throw new Error("open_dispute requires the dynamic Settlement V10 jury");
|
|
2264
|
+
}
|
|
2265
|
+
const allowed = new Set(["action", "wallet", "request_id", "channel_id"]);
|
|
2266
|
+
if (!raw || Object.keys(raw).some((name) => !allowed.has(name))) {
|
|
2267
|
+
throw new Error("open_dispute accepts only a local request and channel reference");
|
|
2268
|
+
}
|
|
2269
|
+
const { entry, reference, requestId, channelId, settlementKey } = this.disputeHistoryEntry(raw, wallet);
|
|
2270
|
+
if (["wallet_prompted", "submitted", "uncertain", "confirmed"].includes(entry.dispute_stage)) {
|
|
2271
|
+
throw new Error("this dispute is already awaiting a wallet or chain outcome");
|
|
2272
|
+
}
|
|
2273
|
+
const snapshot = await this.disputePlanningSnapshot(entry, reference, wallet);
|
|
2274
|
+
const transactions = [];
|
|
2275
|
+
if (snapshot.allowance < snapshot.bond) {
|
|
2276
|
+
transactions.push({
|
|
2277
|
+
kind: "approval",
|
|
2278
|
+
label: "Approve exact reporter bond",
|
|
2279
|
+
to: token,
|
|
2280
|
+
data: contractData("approve(address,uint256)", [settlement, snapshot.bond]),
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
transactions.push({
|
|
2284
|
+
kind: "open_dispute",
|
|
2285
|
+
label: "Open objective-fraud dispute",
|
|
2286
|
+
to: settlement,
|
|
2287
|
+
data: contractData("openDispute(bytes32,bytes32)", [settlementKey, reference.evidence_hash]),
|
|
2288
|
+
});
|
|
2289
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2290
|
+
this.historyLedger.append({
|
|
2291
|
+
...entry,
|
|
2292
|
+
dispute_stage: "planned",
|
|
2293
|
+
dispute_tx_kind: "open_dispute",
|
|
2294
|
+
dispute_bond_units: snapshot.bond.toString(),
|
|
2295
|
+
settlement_release_at: snapshot.record.release_at,
|
|
2296
|
+
settlement_checked_at: snapshot.block.timestamp,
|
|
2297
|
+
dispute_updated_at: now,
|
|
2298
|
+
dispute_error: "",
|
|
2299
|
+
updated_at: now,
|
|
2300
|
+
});
|
|
2301
|
+
return {
|
|
2302
|
+
action,
|
|
2303
|
+
request_id: requestId,
|
|
2304
|
+
channel_id: channelId,
|
|
2305
|
+
settlement_key: settlementKey,
|
|
2306
|
+
evidence_hash: reference.evidence_hash,
|
|
2307
|
+
report_id: reference.predicted_report_id,
|
|
2308
|
+
reporter: reference.reporter,
|
|
2309
|
+
bond_units: snapshot.bond.toString(),
|
|
2310
|
+
release_at: snapshot.record.release_at,
|
|
2311
|
+
chain_timestamp: snapshot.block.timestamp,
|
|
2312
|
+
warning: "Only report objective fraud or non-delivery supported by the signed evidence commitment.",
|
|
2313
|
+
transactions,
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
1011
2316
|
if (action === "top_up") {
|
|
1012
2317
|
const amount = parseUsdc(raw.amount_usdc);
|
|
1013
2318
|
const allowance = BigInt(await this.rpcValue((rpc) => this.contractCall(rpc, token, "allowance(address,address)", [wallet, settlement])) || "0x0");
|
|
1014
2319
|
const transactions = [];
|
|
1015
|
-
if (allowance < amount) transactions.push({ label: "Approve stablecoin", to: token, data: contractData("approve(address,uint256)", [settlement,
|
|
2320
|
+
if (allowance < amount) transactions.push({ label: "Approve stablecoin", to: token, data: contractData("approve(address,uint256)", [settlement, amount]) });
|
|
1016
2321
|
transactions.push({ label: "Deposit prepaid balance", to: settlement, data: contractData("deposit(uint256)", [amount]) });
|
|
1017
2322
|
return { action, amount_units: amount.toString(), transactions };
|
|
1018
2323
|
}
|
|
1019
2324
|
if (action === "register_key") {
|
|
1020
2325
|
const pending = this.pendingPaymentKey();
|
|
1021
2326
|
const keyAddress = pending?.payment_key_address || this.paymentAddress;
|
|
2327
|
+
const grant = await this.keyGrant(keyAddress);
|
|
2328
|
+
if (grant.owner === wallet && grant.active === true
|
|
2329
|
+
&& grant.max_per_request === this.maxFeeUnits && grant.valid_until === 0) {
|
|
2330
|
+
return { action, key_address: keyAddress, transactions: [] };
|
|
2331
|
+
}
|
|
1022
2332
|
return { action, key_address: keyAddress, transactions: [{ label: "Register payment key", to: settlement, data: contractData("registerKey(address,uint256,uint64)", [keyAddress, this.maxFeeUnits, 0]) }] };
|
|
1023
2333
|
}
|
|
1024
2334
|
if (action === "revoke_key") {
|
|
1025
2335
|
const keyAddress = normalizeAddress(raw.key_address, "key_address");
|
|
1026
2336
|
return { action, key_address: keyAddress, transactions: [{ label: "Revoke previous payment key", to: settlement, data: contractData("revokeKey(address)", [keyAddress]) }] };
|
|
1027
2337
|
}
|
|
2338
|
+
if (action === "close_capacity_channel" && this.network.protocol_version === 10) {
|
|
2339
|
+
const id = normalizeBytes32(raw.channel_id, "channel_id");
|
|
2340
|
+
const channel = (await this.capacityChannels()).find(c => c.channel_id === id && c.consumer_owner === wallet);
|
|
2341
|
+
if (!channel || channel.closed || Math.floor(Date.now()/1000) <= Number(channel.claim_until)) throw new Error("该预算尚未到可释放时间");
|
|
2342
|
+
return { action, channel_id: id, transactions: [{ label: "释放已到期的剩余预算", to: settlement, data: contractData("closeExpiredChannel(bytes32)", [id]) }] };
|
|
2343
|
+
}
|
|
1028
2344
|
throw new Error("unsupported transaction action");
|
|
1029
2345
|
}
|
|
1030
2346
|
|
|
2347
|
+
disputeHistoryEntry(raw, walletValue = this.unlockedWallet) {
|
|
2348
|
+
const wallet = normalizeAddress(walletValue, "wallet");
|
|
2349
|
+
const requestId = normalizeBytes32(raw?.request_id, "request_id");
|
|
2350
|
+
const channelId = normalizeBytes32(raw?.channel_id, "channel_id");
|
|
2351
|
+
const matches = this.historyLedger.history(0).filter((item) => (
|
|
2352
|
+
item.request_id === requestId && item.capacity_channel_id === channelId
|
|
2353
|
+
));
|
|
2354
|
+
if (matches.length !== 1) throw new Error("no unique locally verified receipt exists for this request and channel");
|
|
2355
|
+
const entry = matches[0];
|
|
2356
|
+
if (entry.owner !== wallet || entry.jury_reporter !== wallet) {
|
|
2357
|
+
throw new Error("only the authenticated settlement owner may open this dispute");
|
|
2358
|
+
}
|
|
2359
|
+
const settlementKey = reservedSettlementKey(channelId, requestId);
|
|
2360
|
+
if (entry.settlement_key !== settlementKey) throw new Error("history settlement key is not canonical");
|
|
2361
|
+
const reference = restoreJuryEvidenceReference(entry, this.network, wallet);
|
|
2362
|
+
if (reference.settlement_key !== settlementKey) throw new Error("jury evidence belongs to another settlement");
|
|
2363
|
+
return { entry, reference, requestId, channelId, settlementKey };
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
async disputePlanningSnapshot(entry, reference, wallet) {
|
|
2367
|
+
const settlement = this.network.settlement_contract;
|
|
2368
|
+
const token = this.network.stablecoin;
|
|
2369
|
+
const manifestBondText = String(this.network.reporter_bond_units || "");
|
|
2370
|
+
if (!/^[1-9][0-9]*$/.test(manifestBondText)) {
|
|
2371
|
+
throw new Error("dynamic jury reporter bond is not pinned by the network manifest");
|
|
2372
|
+
}
|
|
2373
|
+
const manifestBond = BigInt(manifestBondText);
|
|
2374
|
+
return this.rpcValue(async (rpc) => {
|
|
2375
|
+
const chainId = await this.callRpc(rpc, "eth_chainId", []);
|
|
2376
|
+
if (BigInt(chainId) !== BigInt(this.network.chain_id)) throw new Error("Settlement RPC is connected to the wrong chain");
|
|
2377
|
+
const block = canonicalBlock(
|
|
2378
|
+
await this.callRpc(rpc, "eth_getBlockByNumber", ["safe", false]),
|
|
2379
|
+
"safe Settlement V10 block",
|
|
2380
|
+
);
|
|
2381
|
+
const blockTag = { blockHash: block.hash, requireCanonical: true };
|
|
2382
|
+
const [rawRecord, rawPolicy, rawAllowance, rawBalance, rawRegistry] = await Promise.all([
|
|
2383
|
+
this.contractCall(rpc, settlement, "settlementInfo(bytes32)", [reference.settlement_key], blockTag),
|
|
2384
|
+
this.contractCall(rpc, settlement, "policy()", [], blockTag),
|
|
2385
|
+
this.contractCall(rpc, token, "allowance(address,address)", [wallet, settlement], blockTag),
|
|
2386
|
+
this.contractCall(rpc, token, "balanceOf(address)", [wallet], blockTag),
|
|
2387
|
+
this.contractCall(rpc, settlement, "juryRegistry()", [], blockTag),
|
|
2388
|
+
]);
|
|
2389
|
+
const record = decodeV10Settlement(rawRecord);
|
|
2390
|
+
const policy = decodeV10Policy(rawPolicy);
|
|
2391
|
+
const allowance = decodeAbiUint(rawAllowance, "reporter bond allowance");
|
|
2392
|
+
const balance = decodeAbiUint(rawBalance, "reporter bond balance");
|
|
2393
|
+
const registry = decodeAbiAddress(rawRegistry, "Settlement juryRegistry");
|
|
2394
|
+
if (registry !== this.network.jury_registry) throw new Error("Settlement jury Registry differs from the network manifest");
|
|
2395
|
+
if (policy.reporter_bond !== manifestBond) throw new Error("on-chain reporter bond differs from the network manifest");
|
|
2396
|
+
if (balance < policy.reporter_bond) throw new Error("wallet balance is below the required reporter bond");
|
|
2397
|
+
if (record.status !== 1) throw new Error("settlement is not pending and cannot be disputed");
|
|
2398
|
+
if (record.owner !== wallet || record.key !== this.paymentAddress
|
|
2399
|
+
|| record.request_id !== entry.request_id
|
|
2400
|
+
|| record.request_hash !== entry.request_hash
|
|
2401
|
+
|| record.response_hash !== entry.response_hash) {
|
|
2402
|
+
throw new Error("on-chain settlement differs from the locally verified receipt");
|
|
2403
|
+
}
|
|
2404
|
+
if (block.timestamp >= record.release_at) throw new Error("the settlement dispute window has closed");
|
|
2405
|
+
if (reference.reporter !== wallet
|
|
2406
|
+
|| reference.predicted_report_id !== keccakHex(Buffer.concat([
|
|
2407
|
+
abiWord(reference.settlement_key), abiWord(wallet), abiWord(reference.evidence_hash),
|
|
2408
|
+
]))) {
|
|
2409
|
+
throw new Error("jury evidence report binding is invalid");
|
|
2410
|
+
}
|
|
2411
|
+
return { block, record, policy, allowance, balance, bond: policy.reporter_bond };
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
recordDisputeProgress(raw) {
|
|
2416
|
+
const wallet = this.assertUnlockedWallet(raw?.wallet);
|
|
2417
|
+
const { entry } = this.disputeHistoryEntry(raw, wallet);
|
|
2418
|
+
const stage = String(raw?.stage || "");
|
|
2419
|
+
const kind = String(raw?.transaction_kind || entry.dispute_tx_kind || "");
|
|
2420
|
+
if (!["wallet_prompted", "submitted", "uncertain", "failed"].includes(stage)
|
|
2421
|
+
|| !["approval", "open_dispute"].includes(kind)) {
|
|
2422
|
+
throw new Error("invalid dispute transaction progress");
|
|
2423
|
+
}
|
|
2424
|
+
const previous = entry.dispute_stage;
|
|
2425
|
+
const allowed = (
|
|
2426
|
+
(stage === "wallet_prompted" && (previous === "planned"
|
|
2427
|
+
|| (["submitted", "uncertain"].includes(previous) && entry.dispute_tx_kind === "approval" && kind === "open_dispute")))
|
|
2428
|
+
|| (stage === "submitted" && previous === "wallet_prompted")
|
|
2429
|
+
|| (stage === "uncertain" && ["wallet_prompted", "submitted", "uncertain"].includes(previous))
|
|
2430
|
+
|| (stage === "failed" && ["planned", "wallet_prompted", "submitted", "uncertain"].includes(previous))
|
|
2431
|
+
);
|
|
2432
|
+
if (!allowed) throw new Error("invalid dispute transaction state transition");
|
|
2433
|
+
const txHash = raw?.tx_hash === undefined ? null : normalizeBytes32(raw.tx_hash, "transaction hash");
|
|
2434
|
+
if (stage === "submitted" && !txHash) throw new Error("submitted dispute progress requires a transaction hash");
|
|
2435
|
+
if (stage === "uncertain" && !txHash
|
|
2436
|
+
&& !entry.dispute_tx_hash && !entry.dispute_approval_tx_hash) {
|
|
2437
|
+
// The wallet may have accepted a transaction without returning its hash.
|
|
2438
|
+
// Persist uncertainty and block automatic retry instead of guessing.
|
|
2439
|
+
}
|
|
2440
|
+
const errors = {
|
|
2441
|
+
wallet_rejected: "The wallet rejected the transaction before submission.",
|
|
2442
|
+
wallet_unknown: "The wallet outcome is unknown; do not retry automatically.",
|
|
2443
|
+
confirmation_timeout: "The transaction was submitted but confirmation is still unknown.",
|
|
2444
|
+
transaction_failed: "The transaction failed on-chain.",
|
|
2445
|
+
};
|
|
2446
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2447
|
+
const update = {
|
|
2448
|
+
...entry,
|
|
2449
|
+
dispute_stage: stage,
|
|
2450
|
+
dispute_tx_kind: kind,
|
|
2451
|
+
dispute_updated_at: now,
|
|
2452
|
+
dispute_error: errors[String(raw?.error_code || "")] || "",
|
|
2453
|
+
updated_at: now,
|
|
2454
|
+
};
|
|
2455
|
+
if (txHash && kind === "approval") update.dispute_approval_tx_hash = txHash;
|
|
2456
|
+
if (txHash && kind === "open_dispute") update.dispute_tx_hash = txHash;
|
|
2457
|
+
this.historyLedger.append(update);
|
|
2458
|
+
return { ok: true, request_id: entry.request_id, channel_id: entry.capacity_channel_id,
|
|
2459
|
+
stage, transaction_kind: kind, tx_hash: txHash };
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
async reconcileDisputeEntry(entry) {
|
|
2463
|
+
const wallet = this.assertUnlockedWallet();
|
|
2464
|
+
const { entry: checked, reference } = this.disputeHistoryEntry({
|
|
2465
|
+
request_id: entry.request_id,
|
|
2466
|
+
channel_id: entry.capacity_channel_id,
|
|
2467
|
+
}, wallet);
|
|
2468
|
+
const kind = checked.dispute_tx_kind;
|
|
2469
|
+
const txHash = kind === "approval" ? checked.dispute_approval_tx_hash : checked.dispute_tx_hash;
|
|
2470
|
+
if (!txHash || !["approval", "open_dispute"].includes(kind)) return { state: "pending" };
|
|
2471
|
+
const bond = BigInt(String(checked.dispute_bond_units || ""));
|
|
2472
|
+
if (bond <= 0n) throw new Error("stored dispute reporter bond is invalid");
|
|
2473
|
+
const expectedTo = kind === "approval" ? this.network.stablecoin : this.network.settlement_contract;
|
|
2474
|
+
const expectedData = kind === "approval"
|
|
2475
|
+
? contractData("approve(address,uint256)", [this.network.settlement_contract, bond])
|
|
2476
|
+
: contractData("openDispute(bytes32,bytes32)", [reference.settlement_key, reference.evidence_hash]);
|
|
2477
|
+
const result = await this.rpcValue(async (rpc) => {
|
|
2478
|
+
const [receipt, transaction] = await Promise.all([
|
|
2479
|
+
this.callRpc(rpc, "eth_getTransactionReceipt", [txHash]),
|
|
2480
|
+
this.callRpc(rpc, "eth_getTransactionByHash", [txHash]),
|
|
2481
|
+
]);
|
|
2482
|
+
if (receipt == null) return { state: "pending" };
|
|
2483
|
+
if (!transaction || typeof transaction !== "object") throw new Error("submitted dispute transaction is unavailable");
|
|
2484
|
+
if (String(receipt.transactionHash || "").toLowerCase() !== txHash
|
|
2485
|
+
|| String(transaction.hash || "").toLowerCase() !== txHash
|
|
2486
|
+
|| String(receipt.blockHash || "").toLowerCase() !== String(transaction.blockHash || "").toLowerCase()
|
|
2487
|
+
|| String(receipt.blockNumber || "").toLowerCase() !== String(transaction.blockNumber || "").toLowerCase()
|
|
2488
|
+
|| normalizeAddress(receipt.from) !== wallet || normalizeAddress(transaction.from) !== wallet
|
|
2489
|
+
|| normalizeAddress(receipt.to) !== expectedTo || normalizeAddress(transaction.to) !== expectedTo
|
|
2490
|
+
|| String(transaction.input || "").toLowerCase() !== expectedData.toLowerCase()) {
|
|
2491
|
+
throw new Error("submitted transaction differs from the dispute plan");
|
|
2492
|
+
}
|
|
2493
|
+
if (!/^0x[0-9a-f]{64}$/i.test(String(receipt.blockHash || ""))
|
|
2494
|
+
|| !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(receipt.blockNumber || ""))
|
|
2495
|
+
|| !["0x0", "0x1"].includes(String(receipt.status || "").toLowerCase())) {
|
|
2496
|
+
throw new Error("submitted transaction receipt is non-canonical");
|
|
2497
|
+
}
|
|
2498
|
+
const [rawBlock, rawCanonicalBlock, rawHead] = await Promise.all([
|
|
2499
|
+
this.callRpc(rpc, "eth_getBlockByHash", [receipt.blockHash, false]),
|
|
2500
|
+
this.callRpc(rpc, "eth_getBlockByNumber", [receipt.blockNumber, false]),
|
|
2501
|
+
this.callRpc(rpc, "eth_blockNumber", []),
|
|
2502
|
+
]);
|
|
2503
|
+
const block = canonicalBlock(rawBlock, "dispute transaction block");
|
|
2504
|
+
const canonicalHeightBlock = canonicalBlock(rawCanonicalBlock, "canonical dispute transaction block");
|
|
2505
|
+
if (block.hash !== String(receipt.blockHash).toLowerCase()
|
|
2506
|
+
|| block.number !== String(receipt.blockNumber).toLowerCase()
|
|
2507
|
+
|| canonicalHeightBlock.hash !== block.hash
|
|
2508
|
+
|| canonicalHeightBlock.number !== block.number) {
|
|
2509
|
+
throw new Error("dispute transaction block is not canonical");
|
|
2510
|
+
}
|
|
2511
|
+
if (!/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(rawHead || ""))) {
|
|
2512
|
+
throw new Error("dispute confirmation head is non-canonical");
|
|
2513
|
+
}
|
|
2514
|
+
const head = BigInt(rawHead);
|
|
2515
|
+
const receiptHeight = BigInt(block.number);
|
|
2516
|
+
if (head < receiptHeight) throw new Error("dispute confirmation head predates the receipt");
|
|
2517
|
+
const confirmations = head - receiptHeight + 1n;
|
|
2518
|
+
if (confirmations < DISPUTE_CONFIRMATIONS) {
|
|
2519
|
+
return { state: "pending", confirmations: Number(confirmations) };
|
|
2520
|
+
}
|
|
2521
|
+
if (String(receipt.status).toLowerCase() === "0x0") return { state: "failed", block };
|
|
2522
|
+
if (kind === "approval") return { state: "approval_confirmed", block };
|
|
2523
|
+
const blockTag = { blockHash: block.hash, requireCanonical: true };
|
|
2524
|
+
const [rawRecord, rawReport] = await Promise.all([
|
|
2525
|
+
this.contractCall(rpc, this.network.settlement_contract, "settlementInfo(bytes32)", [reference.settlement_key], blockTag),
|
|
2526
|
+
this.contractCall(rpc, this.network.settlement_contract, "reports(bytes32,bytes32)",
|
|
2527
|
+
[reference.settlement_key, reference.predicted_report_id], blockTag),
|
|
2528
|
+
]);
|
|
2529
|
+
return { state: "confirmed", block, record: decodeV10Settlement(rawRecord), report: decodeV10Report(rawReport) };
|
|
2530
|
+
});
|
|
2531
|
+
if (result.state === "pending") return result;
|
|
2532
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2533
|
+
if (result.state === "failed") {
|
|
2534
|
+
this.historyLedger.append({ ...checked, dispute_stage: "failed", dispute_error: "The transaction failed on-chain.",
|
|
2535
|
+
dispute_updated_at: now, updated_at: now });
|
|
2536
|
+
return result;
|
|
2537
|
+
}
|
|
2538
|
+
if (result.state === "approval_confirmed") {
|
|
2539
|
+
this.historyLedger.append({ ...checked, dispute_stage: "failed",
|
|
2540
|
+
dispute_error: "Reporter-bond approval confirmed; submit the dispute transaction again.",
|
|
2541
|
+
dispute_updated_at: now, updated_at: now });
|
|
2542
|
+
return result;
|
|
2543
|
+
}
|
|
2544
|
+
const { record, report, block } = result;
|
|
2545
|
+
if (record.status !== 2 || record.owner !== wallet || record.key !== this.paymentAddress
|
|
2546
|
+
|| record.request_id !== checked.request_id || record.request_hash !== checked.request_hash
|
|
2547
|
+
|| record.response_hash !== checked.response_hash || block.timestamp >= record.release_at
|
|
2548
|
+
|| report.reporter !== wallet || report.evidence_hash !== reference.evidence_hash) {
|
|
2549
|
+
throw new Error("confirmed dispute transaction did not create the bound on-chain report");
|
|
2550
|
+
}
|
|
2551
|
+
this.historyLedger.append({ ...checked, status: "disputed", accepted: true,
|
|
2552
|
+
dispute_stage: "confirmed", dispute_error: "", dispute_confirmed_at: block.timestamp,
|
|
2553
|
+
dispute_updated_at: now, settlement_release_at: record.release_at,
|
|
2554
|
+
settlement_checked_at: block.timestamp, updated_at: now });
|
|
2555
|
+
return result;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
async refreshDisputeTransactions(force = false) {
|
|
2559
|
+
if (!this.unlockedWallet || !this.managementToken
|
|
2560
|
+
|| this.network.protocol_version !== 10
|
|
2561
|
+
|| this.network.committee_mode !== DYNAMIC_PROVIDER_AI_COMMITTEE) return;
|
|
2562
|
+
if (this.disputeSync) return this.disputeSync;
|
|
2563
|
+
if (!force && Date.now() - Number(this.disputeSyncAt || 0) < 10_000) return;
|
|
2564
|
+
const candidates = this.historyLedger.history(0).filter((entry) =>
|
|
2565
|
+
["submitted", "uncertain"].includes(entry.dispute_stage)
|
|
2566
|
+
&& (entry.dispute_tx_hash || entry.dispute_approval_tx_hash)).slice(0, 10);
|
|
2567
|
+
if (!candidates.length) return;
|
|
2568
|
+
this.disputeSyncAt = Date.now();
|
|
2569
|
+
this.disputeSyncError = null;
|
|
2570
|
+
const work = (async () => {
|
|
2571
|
+
for (const entry of candidates) {
|
|
2572
|
+
try {
|
|
2573
|
+
await this.reconcileDisputeEntry(entry);
|
|
2574
|
+
} catch {
|
|
2575
|
+
const current = this.historyLedger.history(0).find((item) =>
|
|
2576
|
+
item.request_id === entry.request_id && item.capacity_channel_id === entry.capacity_channel_id);
|
|
2577
|
+
if (current && current.dispute_stage !== "uncertain") {
|
|
2578
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2579
|
+
this.historyLedger.append({ ...current, dispute_stage: "uncertain",
|
|
2580
|
+
dispute_error: "The submitted transaction cannot yet be reconciled; do not retry.",
|
|
2581
|
+
dispute_updated_at: now, updated_at: now });
|
|
2582
|
+
}
|
|
2583
|
+
this.disputeSyncError = "申诉交易状态暂时无法核验";
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
})();
|
|
2587
|
+
this.disputeSync = work;
|
|
2588
|
+
try { await work; } finally { this.disputeSync = null; }
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
noteRelayFailure(relayUrl, retryAfter) {
|
|
2592
|
+
this.readinessCache = null;
|
|
2593
|
+
this.healthCache.delete(relayUrl);
|
|
2594
|
+
const count = Math.min(6, (this.relayFailures.get(relayUrl)?.count || 0) + 1);
|
|
2595
|
+
const seconds = Number(retryAfter);
|
|
2596
|
+
const cooldown = Math.max(Math.min(30_000, 1000 * 2 ** (count - 1)),
|
|
2597
|
+
Number.isFinite(seconds) && seconds > 0 ? Math.min(30_000, seconds * 1000) : 0);
|
|
2598
|
+
this.relayFailures.set(relayUrl, { count, until: Date.now() + cooldown });
|
|
2599
|
+
}
|
|
2600
|
+
|
|
1031
2601
|
async relayHealth(relayUrl, refresh = false) {
|
|
2602
|
+
const failed = this.relayFailures.get(relayUrl);
|
|
2603
|
+
if (failed && failed.until > Date.now()) throw new Error("Relay is cooling down after a failure");
|
|
2604
|
+
const running = this.healthRequests.get(relayUrl);
|
|
2605
|
+
if (running) return running;
|
|
2606
|
+
const pending = this.loadRelayHealth(relayUrl, refresh).catch((error) => {
|
|
2607
|
+
this.noteRelayFailure(relayUrl);
|
|
2608
|
+
throw error;
|
|
2609
|
+
});
|
|
2610
|
+
this.healthRequests.set(relayUrl, pending);
|
|
2611
|
+
try { return await pending; } finally { this.healthRequests.delete(relayUrl); }
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
async loadRelayHealth(relayUrl, refresh = false) {
|
|
1032
2615
|
const cached = this.healthCache.get(relayUrl);
|
|
1033
2616
|
const cacheAge = cached ? Date.now() - cached.at : Infinity;
|
|
1034
|
-
if (cached && !refresh && cacheAge < RELAY_HEALTH_CACHE_MS)
|
|
2617
|
+
if (cached && !refresh && cacheAge < RELAY_HEALTH_CACHE_MS) {
|
|
2618
|
+
this.verifyRelayIdentity(relayUrl, cached.payload);
|
|
2619
|
+
return cached.payload;
|
|
2620
|
+
}
|
|
1035
2621
|
const request = { dispatcher: this.dispatcher, headers: { accept: "application/json" } };
|
|
2622
|
+
if (this.relayDiscovery && !this.staticRelayUrls.includes(relayUrl)) await this.relayDiscovery.probe(relayUrl);
|
|
1036
2623
|
let payload;
|
|
1037
2624
|
let lastError;
|
|
1038
2625
|
for (let attempt = 0; attempt < 2 && !payload; attempt += 1) {
|
|
@@ -1041,35 +2628,556 @@ export class NativeConsumerState {
|
|
|
1041
2628
|
payload = await readJsonResponse(response);
|
|
1042
2629
|
if (!response.ok || payload?.ok !== true) throw new Error(`Relay health is invalid for ${relayUrl}`);
|
|
1043
2630
|
} catch (error) {
|
|
1044
|
-
|
|
2631
|
+
// Never fall back to an insecure transport when a private CA or host
|
|
2632
|
+
// certificate is wrong. Keep the route eligible for retry, but expose
|
|
2633
|
+
// an actionable error so operators can install the manifest CA or fix
|
|
2634
|
+
// the Relay certificate/SAN.
|
|
2635
|
+
const tlsCode = String(error?.code || error?.cause?.code || "");
|
|
2636
|
+
const tlsMessage = String(error?.message || error?.cause?.message || "");
|
|
2637
|
+
if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|ERR_TLS/i.test(`${tlsCode} ${tlsMessage}`)) {
|
|
2638
|
+
lastError = Object.assign(new Error(`Relay TLS verification failed for ${relayUrl}; install the network manifest CA or use a publicly trusted certificate`), {
|
|
2639
|
+
code: "relay_tls_untrusted", cause: error,
|
|
2640
|
+
});
|
|
2641
|
+
} else {
|
|
2642
|
+
lastError = error;
|
|
2643
|
+
}
|
|
1045
2644
|
payload = undefined;
|
|
1046
2645
|
if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, RELAY_HEALTH_RETRY_DELAY_MS));
|
|
1047
2646
|
}
|
|
1048
2647
|
}
|
|
1049
2648
|
if (!payload) {
|
|
1050
|
-
if (cached && cacheAge < RELAY_HEALTH_STALE_MS) return cached.payload;
|
|
1051
2649
|
throw lastError || new Error(`Relay health is unavailable for ${relayUrl}`);
|
|
1052
2650
|
}
|
|
1053
|
-
const v8 = payload
|
|
1054
|
-
|
|
2651
|
+
const v8 = this.capabilities(payload);
|
|
2652
|
+
this.verifyRelayIdentity(relayUrl, payload);
|
|
2653
|
+
if (!v8 || v8.enabled !== true || Number(v8.providers || 0) <= 0) throw new Error(`Relay has no live Settlement V${this.network.protocol_version} Provider: ${relayUrl}`);
|
|
2654
|
+
if (payload.inference_ready === false || payload.settlement_ready === false || v8.inference_ready === false || v8.settlement_ready === false
|
|
2655
|
+
|| payload.settlement_submitter?.ready === false || payload.settlement_submitter?.admission_ready === false) {
|
|
2656
|
+
throw new Error("Relay is not ready for paid inference or settlement");
|
|
2657
|
+
}
|
|
1055
2658
|
this.healthCache.set(relayUrl, { at: Date.now(), payload });
|
|
1056
2659
|
return payload;
|
|
1057
2660
|
}
|
|
1058
2661
|
|
|
1059
|
-
|
|
2662
|
+
verifyRelayIdentity(relayUrl, health, { requirePin = false } = {}) {
|
|
2663
|
+
const capabilities = this.capabilities(health);
|
|
2664
|
+
const announcement = this.relayDiscovery?.recordFor(relayUrl);
|
|
2665
|
+
if (this.relayDiscovery && !this.staticRelayUrls.includes(relayUrl) && !announcement) {
|
|
2666
|
+
throw new Error("Relay discovery announcement expired or unavailable");
|
|
2667
|
+
}
|
|
2668
|
+
const pins = [this.network.relay_pins?.[relayUrl], announcement].filter(Boolean);
|
|
2669
|
+
if (requirePin && pins.length === 0) {
|
|
2670
|
+
const error = new Error("Relay identity is not pinned by the Consumer network or a verified discovery announcement");
|
|
2671
|
+
error.code = "relay_identity_unpinned";
|
|
2672
|
+
throw error;
|
|
2673
|
+
}
|
|
2674
|
+
// Address pins authenticate the Relay identity, but deployment identity is
|
|
2675
|
+
// mandatory for every route. An unpinned custom/static Relay must never
|
|
2676
|
+
// make readiness look green for another chain or Settlement contract.
|
|
2677
|
+
if (Number(capabilities.chain_id) !== this.network.chain_id
|
|
2678
|
+
|| normalizeAddress(capabilities.settlement_contract) !== this.network.settlement_contract) {
|
|
2679
|
+
throw new Error("Relay deployment does not match the pinned Consumer network");
|
|
2680
|
+
}
|
|
2681
|
+
for (const pin of pins) {
|
|
2682
|
+
if (normalizeAddress(capabilities.relay_payment_address) !== pin.payment_address
|
|
2683
|
+
|| normalizeAddress(capabilities.relay_signer_address) !== pin.attestation_address) {
|
|
2684
|
+
throw new Error("Relay health identity differs from its pinned announcement");
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
syncDiscoveredRelays() {
|
|
2690
|
+
if (!this.relayDiscovery) return;
|
|
2691
|
+
this.relayUrls = [...new Set([...this.staticRelayUrls, ...this.relayDiscovery.active().map((record) => record.public_url)])];
|
|
2692
|
+
const current = new Set(this.relayUrls);
|
|
2693
|
+
for (const map of [this.healthCache, this.relayFailures]) {
|
|
2694
|
+
for (const url of map?.keys() || []) if (!current.has(url)) map.delete(url);
|
|
2695
|
+
}
|
|
2696
|
+
for (const map of [this.relayInFlight, this.relayReservations]) {
|
|
2697
|
+
for (const [url, count] of map?.entries() || []) if (!current.has(url) && !count) map.delete(url);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
async refreshRelayDiscovery() {
|
|
2702
|
+
if (!this.relayDiscovery) return;
|
|
2703
|
+
this.syncDiscoveredRelays();
|
|
2704
|
+
await this.relayDiscovery.refresh();
|
|
2705
|
+
this.syncDiscoveredRelays();
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
async modelCatalog() {
|
|
2709
|
+
await this.refreshRelayDiscovery();
|
|
2710
|
+
const catalog = new Map();
|
|
2711
|
+
await Promise.allSettled(this.relayUrls.map(async (relayUrl) => {
|
|
2712
|
+
const health = await this.relayHealth(relayUrl);
|
|
2713
|
+
const capabilities = this.capabilities(health);
|
|
2714
|
+
if (Number(capabilities.chain_id) !== this.network.chain_id
|
|
2715
|
+
|| normalizeAddress(capabilities.settlement_contract) !== this.network.settlement_contract) return;
|
|
2716
|
+
const models = Array.isArray(capabilities.models) && capabilities.models.length
|
|
2717
|
+
? capabilities.models : [capabilities.model || DEFAULT_MODEL];
|
|
2718
|
+
for (const id of models) {
|
|
2719
|
+
if (typeof id !== "string" || !id.trim() || id.length > 256) continue;
|
|
2720
|
+
if (!catalog.has(id)) catalog.set(id, new Set());
|
|
2721
|
+
catalog.get(id).add(relayUrl);
|
|
2722
|
+
}
|
|
2723
|
+
}));
|
|
2724
|
+
if (!catalog.size) throw new Error("No ready Relay advertises models for this network");
|
|
2725
|
+
return [...catalog.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, urls]) => ({
|
|
2726
|
+
id, object: "model", owned_by: "mycomesh", relays: [...urls].sort(), route_count: urls.size,
|
|
2727
|
+
// Keep model-specific redundancy visible to clients. A healthy Relay
|
|
2728
|
+
// list alone is misleading when (for example) gpt-5.6-sol is served by
|
|
2729
|
+
// only one route and has no automatic failover path.
|
|
2730
|
+
redundancy: urls.size > 1 ? "redundant" : "single_relay",
|
|
2731
|
+
route_warning: urls.size > 1 ? undefined : "single Relay route; failover unavailable for this model",
|
|
2732
|
+
}));
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
async readinessPayload() {
|
|
2736
|
+
const now = Date.now();
|
|
2737
|
+
if (this.readinessCache && now - this.readinessCache.at < 2_000) return this.readinessCache.payload;
|
|
2738
|
+
if (this.readinessRequest) return this.readinessRequest;
|
|
2739
|
+
this.readinessRequest = (async () => {
|
|
2740
|
+
const deadline = performance.now() + READINESS_TIMEOUT_MS;
|
|
2741
|
+
// `/ready` is a fast liveness probe. V10 capacity is checked by the
|
|
2742
|
+
// dashboard and immediately before inference; repeating that chain RPC
|
|
2743
|
+
// here makes a cold probe time out even when both Relays are healthy.
|
|
2744
|
+
const selected = await this.chooseRelay(new Set(), { deadline, checkCapacity: false });
|
|
2745
|
+
const payload = {
|
|
2746
|
+
ok: true,
|
|
2747
|
+
liveness_ready: true,
|
|
2748
|
+
paid_ready: null,
|
|
2749
|
+
paid_readiness_checked: false,
|
|
2750
|
+
relay: selected.relayUrl,
|
|
2751
|
+
model: this.capabilities(selected.health).model || DEFAULT_MODEL,
|
|
2752
|
+
};
|
|
2753
|
+
this.readinessCache = { at: Date.now(), payload };
|
|
2754
|
+
return payload;
|
|
2755
|
+
})();
|
|
2756
|
+
try { return await this.readinessRequest; }
|
|
2757
|
+
finally { this.readinessRequest = null; }
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
async paidReadinessPayload() {
|
|
2761
|
+
const payload = {
|
|
2762
|
+
ok: false,
|
|
2763
|
+
liveness_ready: true,
|
|
2764
|
+
paid_readiness_checked: true,
|
|
2765
|
+
paid_ready: false,
|
|
2766
|
+
wallet_ready: false,
|
|
2767
|
+
network_ready: false,
|
|
2768
|
+
budget_ready: false,
|
|
2769
|
+
models_ready: false,
|
|
2770
|
+
protocol_version: this.network.protocol_version,
|
|
2771
|
+
};
|
|
2772
|
+
const locallyUnlocked = Boolean(this.managementToken && this.unlockedWallet && this.paymentUnlocked);
|
|
2773
|
+
let grant;
|
|
2774
|
+
let capacityChannels = [];
|
|
2775
|
+
let budgetError;
|
|
2776
|
+
|
|
2777
|
+
try {
|
|
2778
|
+
await this.settlementNetworkReady();
|
|
2779
|
+
payload.network_ready = true;
|
|
2780
|
+
} catch {
|
|
2781
|
+
payload.network_ready = false;
|
|
2782
|
+
}
|
|
2783
|
+
if (payload.network_ready) {
|
|
2784
|
+
try {
|
|
2785
|
+
grant = await this.keyGrant(this.paymentAddress);
|
|
2786
|
+
} catch {
|
|
2787
|
+
// Keep a verified chain/contract distinct from an unavailable or
|
|
2788
|
+
// malformed payment-key grant.
|
|
2789
|
+
grant = undefined;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
if (locallyUnlocked && grant) {
|
|
2794
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2795
|
+
payload.wallet_ready = grant.owner === this.unlockedWallet
|
|
2796
|
+
&& grant.active === true
|
|
2797
|
+
&& Number(grant.max_per_request) >= this.maxFeeUnits
|
|
2798
|
+
&& (Number(grant.valid_until) === 0 || Number(grant.valid_until) > now + Math.ceil(this.timeoutMs / 1000));
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
try {
|
|
2802
|
+
const selected = await this.chooseRelay(new Set(), { deadline: performance.now() + READINESS_TIMEOUT_MS, checkCapacity: false });
|
|
2803
|
+
this.verifyRelayIdentity(selected.relayUrl, selected.health, { requirePin: true });
|
|
2804
|
+
payload.models_ready = true;
|
|
2805
|
+
} catch {
|
|
2806
|
+
payload.models_ready = false;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
if (this.network.protocol_version === 10 && payload.network_ready) {
|
|
2810
|
+
try {
|
|
2811
|
+
capacityChannels = await this.capacityChannels();
|
|
2812
|
+
// V10 deliberately permits an already owner-approved bounded channel
|
|
2813
|
+
// to finish after the registration itself was revoked. It is still
|
|
2814
|
+
// tied to the locally verified wallet and payment key.
|
|
2815
|
+
if (locallyUnlocked && !payload.wallet_ready) {
|
|
2816
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2817
|
+
payload.wallet_ready = capacityChannels.some((channel) => !channel.closed
|
|
2818
|
+
&& channel.consumer_owner === this.unlockedWallet
|
|
2819
|
+
&& channel.consumer_key === this.paymentAddress
|
|
2820
|
+
&& Number(channel.admit_until) > now);
|
|
2821
|
+
}
|
|
2822
|
+
await this.chooseRelay(new Set(), { deadline: performance.now() + READINESS_TIMEOUT_MS, checkCapacity: true });
|
|
2823
|
+
payload.budget_ready = true;
|
|
2824
|
+
} catch (error) {
|
|
2825
|
+
budgetError = error;
|
|
2826
|
+
payload.budget_ready = false;
|
|
2827
|
+
if (error?.code === "rpc_unavailable") payload.network_ready = false;
|
|
2828
|
+
}
|
|
2829
|
+
} else if (this.network.protocol_version !== 10 && payload.network_ready && payload.wallet_ready) {
|
|
2830
|
+
try {
|
|
2831
|
+
payload.budget_ready = BigInt(await this.accountBalance(this.unlockedWallet)) >= BigInt(this.maxFeeUnits);
|
|
2832
|
+
if (!payload.budget_ready) budgetError = Object.assign(new Error("Prepaid balance is below the request maximum"), { code: "budget_unavailable" });
|
|
2833
|
+
} catch (error) {
|
|
2834
|
+
budgetError = error;
|
|
2835
|
+
payload.budget_ready = false;
|
|
2836
|
+
payload.network_ready = false;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
payload.paid_ready = payload.wallet_ready && payload.network_ready && payload.budget_ready && payload.models_ready;
|
|
2841
|
+
payload.ok = payload.paid_ready;
|
|
2842
|
+
if (payload.paid_ready) {
|
|
2843
|
+
payload.code = "paid_ready";
|
|
2844
|
+
payload.action = "none";
|
|
2845
|
+
return payload;
|
|
2846
|
+
}
|
|
2847
|
+
if (!locallyUnlocked) {
|
|
2848
|
+
payload.code = this.unlockedWallet ? "payment_key_not_ready" : "wallet_locked";
|
|
2849
|
+
payload.action = this.unlockedWallet
|
|
2850
|
+
? "Activate or re-verify the payment key for the connected wallet."
|
|
2851
|
+
: "Open the local Consumer dashboard and verify the payment-key owner wallet.";
|
|
2852
|
+
return payload;
|
|
2853
|
+
}
|
|
2854
|
+
if (!payload.network_ready) {
|
|
2855
|
+
payload.code = "network_unavailable";
|
|
2856
|
+
payload.action = "Check the configured chain, Settlement contract, and RPC endpoints, then retry.";
|
|
2857
|
+
return payload;
|
|
2858
|
+
}
|
|
2859
|
+
if (!payload.wallet_ready) {
|
|
2860
|
+
payload.code = "payment_key_not_ready";
|
|
2861
|
+
payload.action = "Activate or re-verify the payment key for the connected wallet.";
|
|
2862
|
+
return payload;
|
|
2863
|
+
}
|
|
2864
|
+
if (!payload.models_ready) {
|
|
2865
|
+
payload.code = "models_unavailable";
|
|
2866
|
+
payload.action = "Check Relay health and ensure at least one model route is advertised for this network.";
|
|
2867
|
+
return payload;
|
|
2868
|
+
}
|
|
2869
|
+
if (!payload.budget_ready) {
|
|
2870
|
+
payload.code = ["budget_not_started", "budget_unavailable"].includes(budgetError?.code)
|
|
2871
|
+
? budgetError.code : "budget_unavailable";
|
|
2872
|
+
payload.action = payload.code === "budget_not_started"
|
|
2873
|
+
? "Wait until the fixed budget becomes active, then retry."
|
|
2874
|
+
: this.network.protocol_version === 10
|
|
2875
|
+
? "Open or renew a fixed budget channel covering an available Provider route."
|
|
2876
|
+
: "Top up enough prepaid balance for one request.";
|
|
2877
|
+
if (budgetError?.availableAt) payload.available_at = budgetError.availableAt;
|
|
2878
|
+
return payload;
|
|
2879
|
+
}
|
|
2880
|
+
payload.code = "paid_readiness_unknown";
|
|
2881
|
+
payload.action = "Retry the paid readiness check.";
|
|
2882
|
+
return payload;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
async chooseRelay(exclude = new Set(), { model, requireSessions = false, reserve = false, deadline = Infinity, providerSigner, discoveryRetry = true, checkCapacity = true } = {}) {
|
|
2886
|
+
const refresh = this.refreshRelayDiscovery();
|
|
2887
|
+
const candidates = [...this.relayUrls];
|
|
1060
2888
|
const errors = [];
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
2889
|
+
let timedOut = false;
|
|
2890
|
+
let rpcFailure;
|
|
2891
|
+
let budgetFailure;
|
|
2892
|
+
let modelUnavailable = false;
|
|
2893
|
+
const available = [];
|
|
2894
|
+
const checkRelay = async (relayUrl) => {
|
|
2895
|
+
try {
|
|
2896
|
+
const health = await withinDeadline(this.relayHealth(relayUrl), deadline);
|
|
2897
|
+
const models = this.capabilities(health).models;
|
|
2898
|
+
if (model && Array.isArray(models) && !models.includes(model)) {
|
|
2899
|
+
modelUnavailable = true;
|
|
2900
|
+
throw new Error("requested model is unavailable");
|
|
2901
|
+
}
|
|
2902
|
+
if (requireSessions && this.capabilities(health).scheduler?.session_affinity !== true) throw new Error("Relay upgrade required for session routing");
|
|
2903
|
+
if (providerSigner && !this.capabilities(health).provider_signers?.some((signer) => String(signer).toLowerCase() === providerSigner.toLowerCase())) {
|
|
2904
|
+
throw new Error("original Provider is not available on this Relay");
|
|
2905
|
+
}
|
|
2906
|
+
if (checkCapacity && this.network.protocol_version === 10) await withinDeadline(this.selectCapacityChannel(health, model, providerSigner), deadline);
|
|
2907
|
+
const item = { relayUrl, health };
|
|
2908
|
+
available.push(item);
|
|
2909
|
+
return item;
|
|
2910
|
+
} catch (error) {
|
|
2911
|
+
timedOut ||= error.statusCode === 504;
|
|
2912
|
+
if (error.code === "rpc_unavailable") rpcFailure = error;
|
|
2913
|
+
if (error.code === "budget_not_started" || error.code === "budget_unavailable") {
|
|
2914
|
+
if (!budgetFailure || (error.availableAt && (!budgetFailure.availableAt || error.availableAt < budgetFailure.availableAt))) budgetFailure = error;
|
|
2915
|
+
}
|
|
2916
|
+
errors.push(`${relayUrl}: ${error.message}`);
|
|
2917
|
+
throw error;
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
const checks = candidates.filter((url) => !exclude.has(url)).map(checkRelay);
|
|
2921
|
+
if (this.relayDiscovery && discoveryRetry) {
|
|
2922
|
+
checks.push(withinDeadline(refresh, deadline).then(() => {
|
|
2923
|
+
remainingMs(deadline);
|
|
2924
|
+
return Promise.any(this.relayUrls.filter((url) => !candidates.includes(url) && !exclude.has(url)).map(checkRelay));
|
|
2925
|
+
}));
|
|
2926
|
+
}
|
|
2927
|
+
if (checks.length) {
|
|
2928
|
+
const all = Promise.allSettled(checks);
|
|
2929
|
+
try {
|
|
2930
|
+
await Promise.any(checks);
|
|
2931
|
+
// Give other healthy replies a small comparison window, without making
|
|
2932
|
+
// a healthy route wait for an unreachable backup's full health timeout.
|
|
2933
|
+
await withinDeadline(Promise.race([all, new Promise((resolve) => setTimeout(resolve, 30))]), deadline);
|
|
2934
|
+
} catch {}
|
|
2935
|
+
}
|
|
2936
|
+
if (available.length) {
|
|
2937
|
+
remainingMs(deadline);
|
|
2938
|
+
const offset = this.relaySelectionCursor++ % this.relayUrls.length;
|
|
2939
|
+
const score = ({ relayUrl, health }) => {
|
|
2940
|
+
const scheduling = this.capabilities(health).scheduler;
|
|
2941
|
+
const slots = Math.max(1, Number(scheduling?.total_slots || this.capabilities(health).providers || 1));
|
|
2942
|
+
const localLoad = (this.relayInFlight.get(relayUrl) || 0) + (this.relayReservations.get(relayUrl) || 0);
|
|
2943
|
+
const outstanding = Math.max(Number(scheduling?.outstanding_jobs || 0), localLoad);
|
|
2944
|
+
const assigned = [...this.sessionRoutes.values()].filter((entry) => entry.relayUrl === relayUrl).length;
|
|
2945
|
+
return [outstanding / slots, assigned / slots, (this.relayUrls.indexOf(relayUrl) - offset + this.relayUrls.length) % this.relayUrls.length];
|
|
2946
|
+
};
|
|
2947
|
+
available.sort((a, b) => {
|
|
2948
|
+
const left = score(a), right = score(b);
|
|
2949
|
+
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
|
|
2950
|
+
});
|
|
2951
|
+
const selected = available[0];
|
|
2952
|
+
if (reserve) {
|
|
2953
|
+
this.relayReservations.set(selected.relayUrl, (this.relayReservations.get(selected.relayUrl) || 0) + 1);
|
|
2954
|
+
selected.reserved = true;
|
|
2955
|
+
}
|
|
2956
|
+
return selected;
|
|
2957
|
+
}
|
|
2958
|
+
if (timedOut) throw deadlineError();
|
|
2959
|
+
remainingMs(deadline);
|
|
2960
|
+
if (rpcFailure) throw rpcFailure;
|
|
2961
|
+
if (budgetFailure) throw budgetFailure;
|
|
2962
|
+
if (modelUnavailable && model) {
|
|
2963
|
+
throw Object.assign(new Error(`model ${model} has no healthy Relay route; inspect /models for redundancy and configure a backup Provider`), {
|
|
2964
|
+
code: "model_route_unavailable", model,
|
|
2965
|
+
});
|
|
2966
|
+
}
|
|
2967
|
+
throw new Error(`no healthy Settlement V${this.network.protocol_version} Relay is available: ${errors.join("; ") || "all relays excluded"}`);
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
ensureSessionRouteCapacity() {
|
|
2971
|
+
if (this.sessionRoutes.size + this.sessionSelections.size >= 4096) {
|
|
2972
|
+
const idle = [...this.sessionRoutes].filter(([id, entry]) => !entry.inFlight && !entry.dispatchPending && !entry.explicit && !this.sessionSelections.has(id))
|
|
2973
|
+
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
|
2974
|
+
for (const [id] of idle) {
|
|
2975
|
+
this.sessionRoutes.delete(id);
|
|
2976
|
+
if (this.sessionRoutes.size + this.sessionSelections.size < 4096) break;
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
if (this.sessionRoutes.size + this.sessionSelections.size >= 4096) throw new Error("session routing capacity reached; retry later");
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
async chooseSessionRelay(session, exclude, model, deadline = Infinity) {
|
|
2983
|
+
void this.refreshRelayDiscovery();
|
|
2984
|
+
const now = Date.now();
|
|
2985
|
+
for (const [id, entry] of this.sessionRoutes) {
|
|
2986
|
+
if (!entry.inFlight && !entry.dispatchPending && now - entry.lastUsed > 24 * 60 * 60_000) this.sessionRoutes.delete(id);
|
|
2987
|
+
}
|
|
2988
|
+
if (this.sessionSelections.has(session.id)) return withinDeadline(this.sessionSelections.get(session.id), deadline);
|
|
2989
|
+
let pinned = this.sessionRoutes.get(session.id);
|
|
2990
|
+
if (!pinned) {
|
|
2991
|
+
const savedRoutes = this.history(0).filter((entry) => entry.accepted && entry.session_id === session.id
|
|
2992
|
+
&& entry.relay_url && /^0x[0-9a-f]{40}$/i.test(entry.provider_signer || ""));
|
|
2993
|
+
if (new Set(savedRoutes.map((entry) => entry.provider_signer.toLowerCase())).size > 1) {
|
|
2994
|
+
const error = new Error("session history contains conflicting Provider routes; automatic migration is disabled");
|
|
2995
|
+
error.statusCode = 409;
|
|
2996
|
+
throw error;
|
|
2997
|
+
}
|
|
2998
|
+
const saved = savedRoutes[0];
|
|
2999
|
+
if (saved) {
|
|
3000
|
+
this.ensureSessionRouteCapacity();
|
|
3001
|
+
pinned = { relayUrl: saved.relay_url, providerSigner: saved.provider_signer, explicit: true, lastUsed: now, inFlight: 0 };
|
|
3002
|
+
this.sessionRoutes.set(session.id, pinned);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
if (pinned) {
|
|
3006
|
+
pinned.lastUsed = now;
|
|
3007
|
+
pinned.explicit ||= session.explicit || session.requiresOriginalRoute;
|
|
3008
|
+
try {
|
|
3009
|
+
if (exclude.has(pinned.relayUrl) || !this.relayUrls.includes(pinned.relayUrl)) throw new Error("original session Relay is unavailable");
|
|
3010
|
+
const health = await withinDeadline(this.relayHealth(pinned.relayUrl), deadline);
|
|
3011
|
+
if (this.capabilities(health).scheduler?.session_affinity !== true) throw new Error("Relay upgrade required to continue this session");
|
|
3012
|
+
if (pinned.providerSigner && Array.isArray(this.capabilities(health).provider_signers)
|
|
3013
|
+
&& !this.capabilities(health).provider_signers.some((signer) => String(signer).toLowerCase() === pinned.providerSigner.toLowerCase())) {
|
|
3014
|
+
throw new Error("original Provider has disconnected from this Relay");
|
|
3015
|
+
}
|
|
3016
|
+
return { relayUrl: pinned.relayUrl, health };
|
|
3017
|
+
} catch (error) {
|
|
3018
|
+
if (error.statusCode === 504 || !pinned.providerSigner || pinned.inFlight || pinned.dispatchPending) throw error;
|
|
3019
|
+
if (this.sessionSelections.has(session.id)) return withinDeadline(this.sessionSelections.get(session.id), deadline);
|
|
3020
|
+
// A Relay may change only when the SAME verified Provider identity is
|
|
3021
|
+
// advertised at another configured route. Never replay unknown work.
|
|
3022
|
+
const recovery = (async () => {
|
|
3023
|
+
const selected = await this.chooseRelay(new Set([...exclude, pinned.relayUrl]), {
|
|
3024
|
+
model, requireSessions: true, reserve: true, deadline, providerSigner: pinned.providerSigner,
|
|
3025
|
+
});
|
|
3026
|
+
pinned.relayUrl = selected.relayUrl;
|
|
3027
|
+
pinned.lastUsed = Date.now();
|
|
3028
|
+
pinned.dispatchPending = true;
|
|
3029
|
+
return selected;
|
|
3030
|
+
})();
|
|
3031
|
+
this.sessionSelections.set(session.id, recovery);
|
|
3032
|
+
try { return await recovery; } finally { this.sessionSelections.delete(session.id); }
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
if (session.requiresOriginalRoute) {
|
|
3036
|
+
const error = new Error("original session route expired or is unavailable; automatic migration is disabled");
|
|
3037
|
+
error.statusCode = 409;
|
|
3038
|
+
throw error;
|
|
3039
|
+
}
|
|
3040
|
+
this.ensureSessionRouteCapacity();
|
|
3041
|
+
const selection = (async () => {
|
|
3042
|
+
const selected = await this.chooseRelay(exclude, { model, requireSessions: session.explicit, reserve: true, deadline });
|
|
3043
|
+
// Keep the route leased across promise resolution until runRelayInference
|
|
3044
|
+
// synchronously transfers this reservation into its in-flight count.
|
|
3045
|
+
this.sessionRoutes.set(session.id, { relayUrl: selected.relayUrl, lastUsed: Date.now(), inFlight: 0, explicit: session.explicit, dispatchPending: true });
|
|
3046
|
+
return selected;
|
|
3047
|
+
})();
|
|
3048
|
+
this.sessionSelections.set(session.id, selection);
|
|
3049
|
+
try { return await selection; } finally { this.sessionSelections.delete(session.id); }
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
async capacityChannels() {
|
|
3053
|
+
if (this.network.protocol_version !== 10) return [];
|
|
3054
|
+
if (!this.network.capacity_channel_ids.length) return [];
|
|
3055
|
+
if (this.capacityChannelsRequest) return this.capacityChannelsRequest;
|
|
3056
|
+
// Share only an in-flight canonical snapshot. A later call always rereads
|
|
3057
|
+
// the chain, including after errors or a channel's expiry/closure.
|
|
3058
|
+
this.capacityChannelsRequest = this.rpcValue(async rpc => {
|
|
3059
|
+
if (BigInt(await this.callRpc(rpc, "eth_chainId", [])) !== BigInt(this.network.chain_id)) throw new Error("capacity RPC chain mismatch");
|
|
3060
|
+
const block = await this.callRpc(rpc, "eth_getBlockByNumber", ["latest", false]);
|
|
3061
|
+
const now = Math.floor(Date.now() / 1000);
|
|
3062
|
+
if (!block || !/^0x[0-9a-f]{64}$/i.test(block.hash) || !/^0x[0-9a-f]+$/i.test(block.timestamp || "")
|
|
3063
|
+
|| Math.abs(Number(BigInt(block.timestamp)) - now) > 300) throw new Error("capacity RPC clock mismatch");
|
|
3064
|
+
const height = BigInt(block.number);
|
|
3065
|
+
if (height < 6n) throw new Error("capacity RPC confirmation depth unavailable");
|
|
3066
|
+
const confirmed = await this.callRpc(rpc, "eth_getBlockByNumber", ['0x'+(height-6n).toString(16), false]);
|
|
3067
|
+
if (!confirmed || !/^0x[0-9a-f]{64}$/i.test(confirmed.hash)) throw new Error("confirmed capacity block unavailable");
|
|
3068
|
+
const tag = { blockHash: confirmed.hash, requireCanonical: true };
|
|
3069
|
+
const ttl = await this.contractCall(rpc, this.network.settlement_contract, "MAX_AUTHORIZATION_TTL()", [], tag);
|
|
3070
|
+
if (!/^0x[0-9a-f]{64}$/i.test(ttl) || BigInt(ttl) !== 10800n) throw new Error("capacity contract lifetime mismatch");
|
|
3071
|
+
const duration = await this.contractCall(rpc, this.network.settlement_contract, "MAX_CHANNEL_DURATION()", [], tag);
|
|
3072
|
+
if (!/^0x[0-9a-f]{64}$/i.test(duration) || BigInt(duration) !== BigInt(this.network.max_channel_duration_seconds)) throw new Error("capacity contract channel duration mismatch");
|
|
3073
|
+
const channels = await Promise.all(this.network.capacity_channel_ids.map(async id => decodeCapacityChannel(
|
|
3074
|
+
await this.contractCall(rpc, this.network.settlement_contract, "channelInfo(bytes32)", [id], tag),
|
|
3075
|
+
id, this.network.chain_id, this.network.settlement_contract)));
|
|
3076
|
+
const canonical = await this.callRpc(rpc, "eth_getBlockByNumber", [confirmed.number, false]);
|
|
3077
|
+
if (!canonical || canonical.hash !== confirmed.hash) throw new Error("capacity block reorged during verification");
|
|
3078
|
+
return channels;
|
|
3079
|
+
});
|
|
3080
|
+
try { return await this.capacityChannelsRequest; }
|
|
3081
|
+
finally { this.capacityChannelsRequest = null; }
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
capacityBudget(channel, history = this.history(0), now = Math.floor(Date.now()/1000)) {
|
|
3085
|
+
const maximum = BigInt(this.maxFeeUnits);
|
|
3086
|
+
// Local unknown requests retain their full authorization. Another device's
|
|
3087
|
+
// unsettled reservations still require the Provider's authoritative check.
|
|
3088
|
+
const local = history.filter(r => r.capacity_channel_id === channel.channel_id && r.status !== "not_dispatched")
|
|
3089
|
+
.reduce((sum,r) => sum+BigInt(r.max_fee_units ?? channel.max_fee_per_request),0n);
|
|
3090
|
+
const settled = BigInt(channel.settled_max_fee), used = local > settled ? local : settled;
|
|
3091
|
+
const capacity = BigInt(channel.capacity)-used;
|
|
3092
|
+
const remaining = [capacity, BigInt(channel.credit_remaining), BigInt(channel.stake_remaining)]
|
|
3093
|
+
.reduce((a,b) => a < b ? a : b);
|
|
3094
|
+
const remainingUnits = remaining > 0n ? remaining : 0n;
|
|
3095
|
+
let reason = "available";
|
|
3096
|
+
if (channel.closed) reason = "closed";
|
|
3097
|
+
else if (now > Number(channel.claim_until)) reason = "releasable";
|
|
3098
|
+
else if (channel.consumer_key !== this.paymentAddress) reason = "previous_key";
|
|
3099
|
+
else if (this.unlockedWallet && channel.consumer_owner !== this.unlockedWallet) reason = "different_owner";
|
|
3100
|
+
else if (now >= Number(channel.admit_until)) reason = "admission_closed";
|
|
3101
|
+
else if (now + Math.ceil(this.timeoutMs/1000) + 60 > Number(channel.admit_until)) reason = "admission_window_short";
|
|
3102
|
+
else if (now + this.network.authorization_deadline_seconds > Number(channel.claim_until)) reason = "claim_window_short";
|
|
3103
|
+
else if (BigInt(channel.max_fee_per_request) < maximum) reason = "request_limit";
|
|
3104
|
+
else if (capacity < maximum) reason = "capacity_exhausted";
|
|
3105
|
+
else if (BigInt(channel.credit_remaining) < maximum) reason = "credit_insufficient";
|
|
3106
|
+
else if (BigInt(channel.stake_remaining) < maximum) reason = "stake_insufficient";
|
|
3107
|
+
else if (Number(channel.valid_from) > now) reason = "not_started";
|
|
3108
|
+
return { ready: reason === "available", reason, remaining_units: remainingUnits.toString(),
|
|
3109
|
+
request_maximum_units: maximum.toString(), estimated_requests: reason === "available" && maximum > 0n ? (remainingUnits/maximum).toString() : "0",
|
|
3110
|
+
...(reason === "not_started" ? { available_at: Number(channel.valid_from) } : {}) };
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
async selectCapacityChannel(health, model, providerSigner) {
|
|
3114
|
+
const capability = this.capabilities(health);
|
|
3115
|
+
if (capability.reservation_mode !== "provider_bound_channel") throw new Error("Relay does not support fixed-budget channels");
|
|
3116
|
+
const now = Math.floor(Date.now()/1000), history = this.history(0);
|
|
3117
|
+
const channels = await this.capacityChannels();
|
|
3118
|
+
const routed = channels.filter(c => c.relay === String(capability.relay_payment_address).toLowerCase() && c.relay_signer === String(capability.relay_signer_address).toLowerCase()
|
|
3119
|
+
&& c.channel === capability.channel_hash && String(c.pricing_version) === String(capability.pricing_version) && c.pricing_hash === capability.pricing_hash
|
|
3120
|
+
&& (!providerSigner || c.provider_signer === providerSigner.toLowerCase())
|
|
3121
|
+
&& capability.provider_routes?.some(p => p.provider_signer === c.provider_signer && p.provider === c.provider_owner && (!model || p.models?.includes(model))));
|
|
3122
|
+
let availableAt;
|
|
3123
|
+
for (const channel of routed) {
|
|
3124
|
+
const budget = this.capacityBudget(channel, history, now);
|
|
3125
|
+
if (budget.ready) return channel;
|
|
3126
|
+
if (budget.reason === "not_started") availableAt = Math.min(availableAt ?? Infinity, budget.available_at);
|
|
1064
3127
|
}
|
|
1065
|
-
|
|
3128
|
+
const error = new Error(availableAt
|
|
3129
|
+
? `Fixed budget becomes available at ${new Date(availableAt * 1000).toISOString()}`
|
|
3130
|
+
: "No active funded channel covers this model and Provider; renew the fixed budget");
|
|
3131
|
+
error.code = availableAt ? "budget_not_started" : "budget_unavailable";
|
|
3132
|
+
if (availableAt) error.availableAt = availableAt;
|
|
3133
|
+
throw error;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
async authorizationTiming(health, body) {
|
|
3137
|
+
if (this.network.protocol_version === 10) {
|
|
3138
|
+
const channel = await this.selectCapacityChannel(health, body?.model, body?.metadata?.mycomesh_provider_signer);
|
|
3139
|
+
return { now: Math.floor(Date.now()/1000), seconds: this.network.authorization_deadline_seconds, maxTtl: 10800, channel };
|
|
3140
|
+
}
|
|
3141
|
+
const seconds = this.network.authorization_deadline_seconds ?? 900;
|
|
3142
|
+
const maxTtl = this.network.max_authorization_ttl_seconds ?? 3600;
|
|
3143
|
+
if (this.network.protocol_version !== 9 || seconds <= 900) return { now: Math.floor(Date.now() / 1000), seconds, maxTtl: 3600 };
|
|
3144
|
+
return await this.rpcValue(async (rpc) => {
|
|
3145
|
+
const chain = await this.callRpc(rpc, "eth_chainId", []);
|
|
3146
|
+
if (BigInt(chain) !== BigInt(this.network.chain_id)) throw new Error("authorization RPC chain mismatch");
|
|
3147
|
+
const block = await this.callRpc(rpc, "eth_getBlockByNumber", ["latest", false]);
|
|
3148
|
+
const now = Math.floor(Date.now() / 1000);
|
|
3149
|
+
if (!block || !/^0x[0-9a-f]{64}$/i.test(block.hash) || !/^0x[0-9a-f]+$/i.test(block.timestamp || "")
|
|
3150
|
+
|| Math.abs(Number(BigInt(block.timestamp)) - now) > AUTHORIZATION_CLOCK_SKEW_SECONDS) throw new Error("authorization RPC clock mismatch");
|
|
3151
|
+
const tag = { blockHash: block.hash, requireCanonical: true };
|
|
3152
|
+
const limit = await this.contractCall(rpc, this.network.settlement_contract, "MAX_AUTHORIZATION_TTL()", [], tag);
|
|
3153
|
+
if (!/^0x[0-9a-f]{64}$/i.test(limit) || BigInt(limit) !== BigInt(maxTtl)) throw new Error("contract authorization lifetime differs from manifest");
|
|
3154
|
+
const grant = await this.contractCall(rpc, this.network.settlement_contract, "keyGrants(address)", [this.paymentAddress], tag);
|
|
3155
|
+
if (!/^0x[0-9a-f]{256}$/i.test(grant)) throw new Error("invalid authorization key grant");
|
|
3156
|
+
const words = grant.slice(2).match(/.{64}/g);
|
|
3157
|
+
const validUntil = BigInt(`0x${words[2]}`);
|
|
3158
|
+
if (!/^0{24}/.test(words[0]) || BigInt(`0x${words[0]}`) === 0n || BigInt(`0x${words[3]}`) !== 1n
|
|
3159
|
+
|| BigInt(`0x${words[1]}`) < BigInt(this.maxFeeUnits)
|
|
3160
|
+
|| (validUntil !== 0n && validUntil < BigInt(now + seconds))) throw new Error("payment key does not cover the full settlement window");
|
|
3161
|
+
return { now, seconds, maxTtl };
|
|
3162
|
+
});
|
|
1066
3163
|
}
|
|
1067
3164
|
|
|
1068
|
-
buildRelayPayment(path, body, health, requestId) {
|
|
3165
|
+
buildRelayPayment(path, body, health, requestId, timing) {
|
|
1069
3166
|
const endpoint = path.endsWith("/chat/completions") ? "chat" : "responses";
|
|
1070
|
-
const v8 = health
|
|
3167
|
+
const v8 = this.capabilities(health);
|
|
1071
3168
|
if (!v8) throw new Error("Relay health has no V8 payment requirements");
|
|
1072
|
-
|
|
3169
|
+
if (Number(v8.chain_id) !== this.network.chain_id || normalizeAddress(v8.settlement_contract) !== this.network.settlement_contract) {
|
|
3170
|
+
throw new Error("Relay deployment does not match the pinned Consumer network");
|
|
3171
|
+
}
|
|
3172
|
+
if ((this.network.protocol_version >= 9 || this.network.require_response_proof)
|
|
3173
|
+
&& v8.response_proof !== RESPONSE_PROOF_SCHEMA) throw new Error("Relay upgrade required for independently verified response content");
|
|
3174
|
+
const hasCatalog = Array.isArray(v8.models) && v8.models.length > 0;
|
|
3175
|
+
const advertised = hasCatalog ? v8.models.map(String) : [String(v8.model || DEFAULT_MODEL)];
|
|
3176
|
+
const requested = String(body.model || "").trim();
|
|
3177
|
+
const model = hasCatalog
|
|
3178
|
+
? (requested || String(v8.model || advertised[0] || DEFAULT_MODEL))
|
|
3179
|
+
: String(v8.model || requested || DEFAULT_MODEL);
|
|
3180
|
+
if (hasCatalog && !advertised.includes(model)) throw new Error(`model is not advertised by the selected Relay: ${model}`);
|
|
1073
3181
|
const maxOutput = body.max_output_tokens ?? body.max_tokens ?? v8.maxOutputTokens ?? 2000;
|
|
1074
3182
|
const options = {};
|
|
1075
3183
|
for (const field of [...RESPONSES_REQUEST_OPTION_FIELDS, ...RESPONSES_LOCAL_OPTION_FIELDS]) {
|
|
@@ -1084,8 +3192,18 @@ export class NativeConsumerState {
|
|
|
1084
3192
|
maxOutputTokens: maxOutput,
|
|
1085
3193
|
options: normalizedOptions || undefined,
|
|
1086
3194
|
});
|
|
1087
|
-
|
|
3195
|
+
if ((this.network.authorization_deadline_seconds ?? 900) > 900 && !timing) throw new Error("long authorization requires verified on-chain timing");
|
|
3196
|
+
const now = timing?.now ?? Math.floor(Date.now() / 1000);
|
|
3197
|
+
if (this.network.protocol_version === 10) {
|
|
3198
|
+
if (!timing?.channel) throw new Error("verified capacity channel required before dispatch");
|
|
3199
|
+
const channel = timing.channel;
|
|
3200
|
+
return { payment: buildReservedAuthorization({ paymentKey: this.paymentKey, chainId: this.network.chain_id,
|
|
3201
|
+
settlementContract: this.network.settlement_contract, channelId: channel.channel_id, requestId, requestHash,
|
|
3202
|
+
maxFee: this.maxFeeUnits, issuedAt: Math.max(Number(channel.valid_from), now-300),
|
|
3203
|
+
executeBy: now+Math.ceil(this.timeoutMs/1000)+60, deadline: now+timing.seconds }), channel, request_id: requestId, model };
|
|
3204
|
+
}
|
|
1088
3205
|
const payment = buildAuthorization({
|
|
3206
|
+
protocolVersion: this.network.protocol_version,
|
|
1089
3207
|
paymentKey: this.paymentKey,
|
|
1090
3208
|
chainId: Number(v8.chain_id),
|
|
1091
3209
|
settlementContract: v8.settlement_contract,
|
|
@@ -1098,25 +3216,107 @@ export class NativeConsumerState {
|
|
|
1098
3216
|
pricingHash: v8.pricing_hash,
|
|
1099
3217
|
maxFee: this.maxFeeUnits,
|
|
1100
3218
|
issuedAt: now - AUTHORIZATION_CLOCK_SKEW_SECONDS,
|
|
1101
|
-
deadline: now + 900,
|
|
3219
|
+
deadline: now + (timing?.seconds ?? 900),
|
|
3220
|
+
maxAuthorizationTtlSeconds: timing?.maxTtl ?? 3600,
|
|
1102
3221
|
});
|
|
1103
3222
|
return { payment, request_id: requestId, model };
|
|
1104
3223
|
}
|
|
1105
3224
|
|
|
1106
|
-
async relayInference(path, body) {
|
|
1107
|
-
|
|
3225
|
+
async relayInference(path, body, requestHeaders = {}) {
|
|
3226
|
+
this.activeInferenceRequests += 1;
|
|
3227
|
+
let journal, claim;
|
|
3228
|
+
try {
|
|
3229
|
+
if (this.network.protocol_version === 10) {
|
|
3230
|
+
const headers = Object.fromEntries(Object.entries(requestHeaders).map(([k,v]) => [k.toLowerCase(),v]));
|
|
3231
|
+
const idempotencyKey = headers["idempotency-key"];
|
|
3232
|
+
if (!idempotencyKey && Number(headers["x-stainless-retry-count"] || 0) > 0) {
|
|
3233
|
+
return { status: 422, headers: { "x-should-retry": "false" },
|
|
3234
|
+
payload: { error: { message: "Automatic retry has no stable Idempotency-Key. Check the original request before submitting again.",
|
|
3235
|
+
type: "idempotency_key_required", code: "idempotency_key_required", retryable: false } } };
|
|
3236
|
+
}
|
|
3237
|
+
if (idempotencyKey !== undefined) {
|
|
3238
|
+
journal = new ConsumerRequestJournal({ directory: join(this.dataDir, "request-journal"), chainId: this.network.chain_id,
|
|
3239
|
+
contract: this.network.settlement_contract, paymentKeyAddress: this.paymentAddress });
|
|
3240
|
+
claim = journal.claim({ idempotencyKey, payloadHash: requestPayloadHash({ path, body,
|
|
3241
|
+
session: headers["x-mycomesh-session-id"] || "" }) });
|
|
3242
|
+
if (claim.action === "replay") return claim.result;
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
const result = await this.runRelayInference(path, body, requestHeaders, claim?.requestId);
|
|
3246
|
+
if (claim && journal) {
|
|
3247
|
+
if (result.payload?.error?.execution_status === "unknown") journal.markOutcomeUnknown(claim);
|
|
3248
|
+
else {
|
|
3249
|
+
try { return journal.complete(claim, result); }
|
|
3250
|
+
catch {
|
|
3251
|
+
journal.markOutcomeUnknown(claim);
|
|
3252
|
+
result.headers = { ...result.headers, "x-should-retry": "false", "x-mycomesh-history-status": "response-cache-unavailable" };
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return result;
|
|
3257
|
+
} catch (error) {
|
|
3258
|
+
if (claim?.action === "execute") { try { journal.markOutcomeUnknown(claim); } catch {} }
|
|
3259
|
+
if (this.network.protocol_version === 10) return { status: 422, headers: { "x-should-retry": "false",
|
|
3260
|
+
...(error.requestId || claim?.requestId ? { "x-mycomesh-request-id": error.requestId || claim.requestId } : {}) },
|
|
3261
|
+
payload: { error: { message: error.message, type: error.code || "request_tracking_unavailable", code: error.code || "request_tracking_unavailable", retryable: false } } };
|
|
3262
|
+
throw error;
|
|
3263
|
+
}
|
|
3264
|
+
finally { this.activeInferenceRequests -= 1; }
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
async runRelayInference(path, body, requestHeaders = {}, stableRequestId) {
|
|
3268
|
+
const deadline = performance.now() + this.timeoutMs;
|
|
3269
|
+
let session;
|
|
3270
|
+
try { session = this.sessions.resolve(body, requestHeaders); }
|
|
3271
|
+
catch (error) { return { payload: openaiError(error.message, "invalid_session"), status: error.statusCode || 400, headers: {} }; }
|
|
3272
|
+
const clientBody = body;
|
|
3273
|
+
body = session.body;
|
|
3274
|
+
const keepSession = session.explicit || session.requiresOriginalRoute || this.sessionRoutes.has(session.id);
|
|
3275
|
+
const requestId = stableRequestId || `0x${bytesToHex(randomBytes(32))}`;
|
|
3276
|
+
const preDispatchFailure = (message, type, status = 503) => ({
|
|
3277
|
+
payload: { error: { ...openaiError(message, type).error, execution_status: "not_dispatched", request_id: requestId } },
|
|
3278
|
+
status,
|
|
3279
|
+
headers: { "Retry-After": "2", "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId },
|
|
3280
|
+
});
|
|
1108
3281
|
const used = new Set();
|
|
1109
3282
|
let lastError = "no Relay accepted the request";
|
|
1110
3283
|
let lastResponse;
|
|
1111
3284
|
while (used.size < this.relayUrls.length) {
|
|
1112
3285
|
let selected;
|
|
1113
|
-
try { selected = await this.
|
|
3286
|
+
try { selected = await this.chooseSessionRelay(session, used, body.model, deadline); }
|
|
3287
|
+
catch (error) {
|
|
3288
|
+
lastError = error.message;
|
|
3289
|
+
if (error.code === "rpc_unavailable") lastResponse = preDispatchFailure(lastError, "rpc_unavailable");
|
|
3290
|
+
if (error.code === "budget_not_started" || error.code === "budget_unavailable") lastResponse = preDispatchFailure(lastError, error.code, 402);
|
|
3291
|
+
if (error.statusCode === 409) lastResponse = preDispatchFailure(lastError, "session_continuation_unavailable", 409);
|
|
3292
|
+
if (error.statusCode === 504) lastResponse = preDispatchFailure(lastError, "request_timeout", 504);
|
|
3293
|
+
break;
|
|
3294
|
+
}
|
|
1114
3295
|
used.add(selected.relayUrl);
|
|
3296
|
+
if (selected.reserved) {
|
|
3297
|
+
this.relayReservations.set(selected.relayUrl, Math.max(0, (this.relayReservations.get(selected.relayUrl) || 1) - 1));
|
|
3298
|
+
selected.reserved = false;
|
|
3299
|
+
}
|
|
3300
|
+
const route = this.sessionRoutes.get(session.id);
|
|
3301
|
+
if (route) { route.inFlight += 1; route.dispatchPending = false; }
|
|
3302
|
+
this.relayInFlight.set(selected.relayUrl, (this.relayInFlight.get(selected.relayUrl) || 0) + 1);
|
|
3303
|
+
let successfulHttpResponse = false;
|
|
3304
|
+
let postStarted = false;
|
|
3305
|
+
let dispatchPayment;
|
|
1115
3306
|
try {
|
|
1116
3307
|
const requestBody = {
|
|
1117
3308
|
...body,
|
|
1118
|
-
model: selected.health.
|
|
3309
|
+
model: Array.isArray(this.capabilities(selected.health).models) && this.capabilities(selected.health).models.length
|
|
3310
|
+
? (body.model || this.capabilities(selected.health).model || DEFAULT_MODEL)
|
|
3311
|
+
: (this.capabilities(selected.health).model || body.model || DEFAULT_MODEL),
|
|
1119
3312
|
};
|
|
3313
|
+
if (route?.providerSigner) {
|
|
3314
|
+
const supplied = requestBody.metadata?.mycomesh_provider_signer;
|
|
3315
|
+
if (supplied && String(supplied).toLowerCase() !== route.providerSigner.toLowerCase()) {
|
|
3316
|
+
return { payload: openaiError("Provider hint conflicts with the original session", "invalid_session"), status: 409, headers: {} };
|
|
3317
|
+
}
|
|
3318
|
+
requestBody.metadata = { ...requestBody.metadata, mycomesh_provider_signer: route.providerSigner };
|
|
3319
|
+
}
|
|
1120
3320
|
if (!String(requestBody.prompt_cache_key || "").trim()) {
|
|
1121
3321
|
const cacheKey = derivePromptCacheKey({
|
|
1122
3322
|
endpoint: path.endsWith("/chat/completions") ? "chat" : "responses",
|
|
@@ -1127,54 +3327,217 @@ export class NativeConsumerState {
|
|
|
1127
3327
|
});
|
|
1128
3328
|
if (cacheKey) requestBody.prompt_cache_key = cacheKey;
|
|
1129
3329
|
}
|
|
1130
|
-
const
|
|
3330
|
+
const timing = await withinDeadline(this.authorizationTiming(selected.health, requestBody), deadline);
|
|
3331
|
+
if (timing.channel) requestBody.metadata = { ...requestBody.metadata, mycomesh_provider_signer: timing.channel.provider_signer };
|
|
3332
|
+
const payment = this.buildRelayPayment(path, requestBody, selected.health, requestId, timing);
|
|
3333
|
+
const proofRequired = this.network.protocol_version >= 9 || this.network.require_response_proof
|
|
3334
|
+
|| this.capabilities(selected.health).response_proof === RESPONSE_PROOF_SCHEMA;
|
|
1131
3335
|
requestBody.model = payment.model;
|
|
1132
3336
|
const encodedPayment = base64Url(Buffer.from(stableStringify(payment.payment), "utf8"));
|
|
3337
|
+
let milliseconds = remainingMs(deadline);
|
|
3338
|
+
this.verifyRelayIdentity(selected.relayUrl, selected.health);
|
|
3339
|
+
// Persist the request identity before any execution can occur. A crash
|
|
3340
|
+
// or lost response must leave a scoped record that can be reconciled.
|
|
3341
|
+
dispatchPayment = payment;
|
|
3342
|
+
try { this.recordDispatch(selected.relayUrl, path, payment, session.id); }
|
|
3343
|
+
catch { return { payload: openaiError("Cannot save request tracking; request was not dispatched", "request_tracking_unavailable"), status: 503, headers: { "x-mycomesh-request-id": requestId } }; }
|
|
3344
|
+
milliseconds = remainingMs(deadline);
|
|
3345
|
+
postStarted = true;
|
|
1133
3346
|
const response = await fetchWithTimeout(`${selected.relayUrl}${path}`, {
|
|
1134
3347
|
method: "POST",
|
|
1135
|
-
headers: { "content-type": "application/json", accept: "application/json", "PAYMENT-SIGNATURE": encodedPayment
|
|
3348
|
+
headers: { "content-type": "application/json", accept: "application/json", "PAYMENT-SIGNATURE": encodedPayment,
|
|
3349
|
+
...(proofRequired ? { "X-MycoMesh-Response-Proof": RESPONSE_PROOF_SCHEMA } : {}),
|
|
3350
|
+
"X-MycoMesh-Request-Timeout-Ms": String(milliseconds) },
|
|
1136
3351
|
body: JSON.stringify(requestBody),
|
|
1137
3352
|
dispatcher: this.dispatcher,
|
|
1138
|
-
},
|
|
1139
|
-
|
|
3353
|
+
}, milliseconds);
|
|
3354
|
+
successfulHttpResponse = response.status >= 200 && response.status < 300;
|
|
3355
|
+
const paymentResponse = response.headers.get("PAYMENT-RESPONSE");
|
|
3356
|
+
let payload, bodyError = null;
|
|
3357
|
+
try { payload = await readJsonResponse(response); } catch (error) { bodyError = error; }
|
|
3358
|
+
if (!bodyError && successfulHttpResponse && (!payload || typeof payload !== "object" || Array.isArray(payload)
|
|
3359
|
+
|| (payload.error && !(proofRequired && payload.schema === RESPONSE_PROOF_SCHEMA)))) {
|
|
3360
|
+
bodyError = new Error("Relay returned an invalid response body; request was not replayed");
|
|
3361
|
+
}
|
|
3362
|
+
if (bodyError && !paymentResponse) throw bodyError;
|
|
1140
3363
|
const retryAfter = response.headers.get("retry-after");
|
|
1141
|
-
const headers = retryAfter ? { "Retry-After": retryAfter } : {};
|
|
1142
|
-
if (
|
|
3364
|
+
const headers = { "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId, ...(retryAfter ? { "Retry-After": retryAfter } : {}) };
|
|
3365
|
+
if (this.network.protocol_version === 10 && !paymentResponse && response.status >= 400) {
|
|
3366
|
+
try { this.recordDispatch(selected.relayUrl, path, payment, session.id, "outcome_unknown"); } catch {}
|
|
3367
|
+
const error = openaiError("The request reached the Relay; execution and payment status must be reconciled before retrying. "
|
|
3368
|
+
+ String(payload?.error?.message || ""), "relay_outcome_unknown");
|
|
3369
|
+
Object.assign(error.error, { execution_status: "unknown", retryable: false, request_id: requestId });
|
|
3370
|
+
return { payload: error, status: 422, headers: { ...headers, "x-should-retry": "false" } };
|
|
3371
|
+
}
|
|
3372
|
+
if (this.network.protocol_version !== 10 && !paymentResponse && payload?.error?.execution_status === "not_dispatched") {
|
|
3373
|
+
try { this.recordDispatch(selected.relayUrl, path, payment, session.id, "not_dispatched"); } catch {}
|
|
3374
|
+
}
|
|
3375
|
+
if (!paymentResponse && RETRYABLE_RELAY_STATUS.has(response.status)) {
|
|
3376
|
+
this.noteRelayFailure(selected.relayUrl, retryAfter);
|
|
1143
3377
|
lastError = payload?.error?.message || `Relay returned HTTP ${response.status}`;
|
|
1144
3378
|
lastResponse = { payload, status: response.status, headers };
|
|
3379
|
+
if (this.network.protocol_version === 10 || keepSession || payload?.error?.execution_status !== "not_dispatched") break;
|
|
3380
|
+
this.sessionRoutes.delete(session.id);
|
|
1145
3381
|
continue;
|
|
1146
3382
|
}
|
|
1147
|
-
if (response.status >= 400) return { payload: normalizeOpenAiError(payload, "relay_error"), status: response.status, headers };
|
|
1148
|
-
|
|
3383
|
+
if (!paymentResponse && response.status >= 400) return { payload: normalizeOpenAiError(payload, "relay_error"), status: response.status, headers };
|
|
3384
|
+
this.relayFailures.delete(selected.relayUrl);
|
|
3385
|
+
if (!paymentResponse && proofRequired) throw new Error("Relay success is missing its signed payment receipt");
|
|
1149
3386
|
if (paymentResponse) {
|
|
1150
|
-
const settlement = decodePaymentResponse(paymentResponse
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
3387
|
+
const settlement = decodePaymentResponse(paymentResponse, {
|
|
3388
|
+
protocolVersion: this.network.protocol_version,
|
|
3389
|
+
chainId: this.network.chain_id, contract: this.network.settlement_contract,
|
|
3390
|
+
requestId, requestHash: payment.payment.authorization.request_hash,
|
|
3391
|
+
relay: payment.payment.authorization.relay, relaySigner: payment.payment.authorization.relay_signer,
|
|
3392
|
+
channel: payment.channel,
|
|
3393
|
+
requireJuryEvidence: this.network.committee_mode === DYNAMIC_PROVIDER_AI_COMMITTEE,
|
|
3394
|
+
juryRelayPublicKeys: this.network.jury_relay_public_keys,
|
|
3395
|
+
reporter: payment.channel?.consumer_owner,
|
|
3396
|
+
});
|
|
3397
|
+
const returnedAuthorization = settlement.signed_receipt.authorization;
|
|
3398
|
+
if (returnedAuthorization.authorization_hash !== payment.payment.authorization_hash || returnedAuthorization.key_signature !== payment.payment.key_signature) {
|
|
3399
|
+
throw new Error("Relay receipt changed the dispatched payment authorization");
|
|
3400
|
+
}
|
|
3401
|
+
const receipt = settlement.signed_receipt.receipt;
|
|
3402
|
+
const authorization = settlement.signed_receipt.authorization.authorization;
|
|
3403
|
+
if (authorization.request_id !== requestId || authorization.key.toLowerCase() !== this.paymentAddress
|
|
3404
|
+
|| (route?.providerSigner && receipt.provider_signer.toLowerCase() !== route.providerSigner.toLowerCase())) {
|
|
3405
|
+
throw new Error("Relay receipt does not match this request or its pinned Provider");
|
|
3406
|
+
}
|
|
3407
|
+
// A fully signed, request-bound receipt may settle regardless of an
|
|
3408
|
+
// unsigned Relay accepted=false label; keep it eligible for RPC sync.
|
|
3409
|
+
settlement.accepted = true;
|
|
3410
|
+
let contentError = bodyError
|
|
3411
|
+
|| (settlement.jury_evidence_error
|
|
3412
|
+
? new Error(`Relay returned no valid recoverable jury evidence: ${settlement.jury_evidence_error}`)
|
|
3413
|
+
: null)
|
|
3414
|
+
|| (!successfulHttpResponse ? new Error("Relay returned a payment receipt with an HTTP error") : null);
|
|
3415
|
+
headers["x-mycomesh-content-verification"] = contentError ? "failed" : "receipt-only";
|
|
3416
|
+
if (proofRequired && !contentError) {
|
|
3417
|
+
try {
|
|
3418
|
+
payload = verifyResponseProof(payload, receipt, { requestId,
|
|
3419
|
+
endpoint: path.endsWith("/chat/completions") ? "chat" : "responses", model: payment.model });
|
|
3420
|
+
headers["x-mycomesh-content-verification"] = "provider-signed";
|
|
3421
|
+
} catch (error) {
|
|
3422
|
+
contentError = error;
|
|
3423
|
+
headers["x-mycomesh-content-verification"] = "failed";
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
if (route) route.providerSigner = receipt.provider_signer;
|
|
3427
|
+
try {
|
|
3428
|
+
this.recordReceipt(selected.relayUrl, path, String(body.model || payment.model), settlement, payment.model, session.id,
|
|
3429
|
+
headers["x-mycomesh-content-verification"]);
|
|
3430
|
+
} catch {
|
|
3431
|
+
// A persistence failure must never replay a completed, billed inference.
|
|
3432
|
+
this.historySyncError = "付款回执已收到,但账单保存失败;请保留本次响应回执";
|
|
3433
|
+
headers["x-mycomesh-history-status"] = "persistence-error";
|
|
3434
|
+
}
|
|
1158
3435
|
headers["PAYMENT-RESPONSE"] = paymentResponse;
|
|
3436
|
+
if (contentError) {
|
|
3437
|
+
this.noteRelayFailure(selected.relayUrl);
|
|
3438
|
+
const result = { payload: openaiError("Response content verification failed; a valid payment receipt was received and may settle. Request was not replayed.", "content_verification_failed"), status: 502, headers };
|
|
3439
|
+
if (this.network.protocol_version === 10) {
|
|
3440
|
+
result.status = 422; result.headers["x-should-retry"] = "false";
|
|
3441
|
+
Object.assign(result.payload.error, { execution_status: "executed", retryable: false, request_id: requestId });
|
|
3442
|
+
}
|
|
3443
|
+
return result;
|
|
3444
|
+
}
|
|
1159
3445
|
}
|
|
1160
|
-
|
|
3446
|
+
this.sessions.remember(payload, session.id);
|
|
3447
|
+
return { payload: restoreClientResponse(payload, clientBody), status: 200, headers };
|
|
1161
3448
|
} catch (error) {
|
|
1162
3449
|
lastError = error.message;
|
|
3450
|
+
if (!postStarted && dispatchPayment) {
|
|
3451
|
+
try { this.recordDispatch(selected.relayUrl, path, dispatchPayment, session.id, "not_dispatched"); } catch {}
|
|
3452
|
+
}
|
|
3453
|
+
if (postStarted && dispatchPayment) {
|
|
3454
|
+
try { this.recordDispatch(selected.relayUrl, path, dispatchPayment, session.id, "outcome_unknown"); } catch {}
|
|
3455
|
+
}
|
|
3456
|
+
if (postStarted) this.noteRelayFailure(selected.relayUrl);
|
|
3457
|
+
if (postStarted && this.network.protocol_version === 10) {
|
|
3458
|
+
const payload = openaiError("Execution or payment status is unknown; this request was not replayed. Check its request ID before retrying.", "relay_outcome_unknown");
|
|
3459
|
+
Object.assign(payload.error, { execution_status: "unknown", retryable: false, request_id: requestId });
|
|
3460
|
+
const transportCode = error?.cause?.code ?? error?.code;
|
|
3461
|
+
if (["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"].includes(transportCode)) {
|
|
3462
|
+
payload.error.transport_code = transportCode;
|
|
3463
|
+
}
|
|
3464
|
+
return { payload, status: 422, headers: { "x-should-retry": "false", "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId } };
|
|
3465
|
+
}
|
|
3466
|
+
if (!postStarted && error.code === "rpc_unavailable") {
|
|
3467
|
+
lastResponse = preDispatchFailure(lastError, "rpc_unavailable");
|
|
3468
|
+
break;
|
|
3469
|
+
}
|
|
3470
|
+
if (!postStarted && error.statusCode === 504) {
|
|
3471
|
+
lastResponse = preDispatchFailure(lastError, "request_timeout", 504);
|
|
3472
|
+
break;
|
|
3473
|
+
}
|
|
3474
|
+
if (successfulHttpResponse) {
|
|
3475
|
+
lastResponse = { payload: openaiError(lastError, "invalid_relay_response"), status: 502,
|
|
3476
|
+
headers: { "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId } };
|
|
3477
|
+
break;
|
|
3478
|
+
}
|
|
3479
|
+
if (postStarted) {
|
|
3480
|
+
// A lost connection is not proof that a billed inference did not run.
|
|
3481
|
+
lastResponse = { payload: openaiError("Relay outcome is unknown; request was not replayed", "relay_outcome_unknown"), status: 502,
|
|
3482
|
+
headers: { "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId } };
|
|
3483
|
+
break;
|
|
3484
|
+
}
|
|
3485
|
+
if (keepSession) break;
|
|
3486
|
+
this.sessionRoutes.delete(session.id);
|
|
3487
|
+
} finally {
|
|
3488
|
+
this.relayInFlight.set(selected.relayUrl, Math.max(0, (this.relayInFlight.get(selected.relayUrl) || 1) - 1));
|
|
3489
|
+
if (route) { route.inFlight = Math.max(0, route.inFlight - 1); route.lastUsed = Date.now(); }
|
|
1163
3490
|
}
|
|
1164
3491
|
}
|
|
1165
3492
|
if (lastResponse) return lastResponse;
|
|
1166
|
-
return
|
|
3493
|
+
return preDispatchFailure(lastError, keepSession ? "session_unavailable" : "relay_unavailable");
|
|
1167
3494
|
}
|
|
1168
3495
|
}
|
|
1169
3496
|
|
|
1170
|
-
function decodePaymentResponse(value) {
|
|
3497
|
+
function decodePaymentResponse(value, expected = {}) {
|
|
1171
3498
|
let payload;
|
|
1172
3499
|
try { payload = JSON.parse(Buffer.from(String(value), "base64url").toString("utf8")); } catch { throw new Error("Relay returned an invalid PAYMENT-RESPONSE"); }
|
|
1173
3500
|
if (!payload || typeof payload !== "object" || !payload.signed_receipt) throw new Error("Relay PAYMENT-RESPONSE is missing its signed receipt");
|
|
1174
|
-
try {
|
|
3501
|
+
try {
|
|
3502
|
+
const verified = verifySignedReceipt(payload.signed_receipt, expected);
|
|
3503
|
+
if (expected.protocolVersion === 10) {
|
|
3504
|
+
payload.signed_receipt = { ...payload.signed_receipt, receipt: verified.receipt };
|
|
3505
|
+
if (expected.requireJuryEvidence) {
|
|
3506
|
+
try {
|
|
3507
|
+
payload.jury_evidence_reference = verifyJuryEvidenceReference(
|
|
3508
|
+
payload.jury_evidence_reference,
|
|
3509
|
+
{ ...expected, settlementKey: payload.settlement_key,
|
|
3510
|
+
issuedAt: verified.authorization.authorization.issued_at },
|
|
3511
|
+
);
|
|
3512
|
+
} catch (error) {
|
|
3513
|
+
delete payload.jury_evidence_reference;
|
|
3514
|
+
payload.jury_evidence_error = String(error?.message || "invalid jury evidence reference");
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
} catch (error) { throw new Error(`Relay returned an invalid signed receipt: ${error.message}`); }
|
|
1175
3519
|
return payload;
|
|
1176
3520
|
}
|
|
1177
3521
|
|
|
3522
|
+
export function verifyResponseProof(proof, receipt, { requestId, endpoint, model }) {
|
|
3523
|
+
if (!proof || proof.schema !== RESPONSE_PROOF_SCHEMA || typeof proof.commitment_b64 !== "string"
|
|
3524
|
+
|| proof.commitment_b64.length > MAX_BODY_BYTES) throw new Error("Relay response is missing its Provider content proof");
|
|
3525
|
+
const bytes = Buffer.from(proof.commitment_b64, "base64");
|
|
3526
|
+
if (bytes.toString("base64") !== proof.commitment_b64) throw new Error("Invalid Provider content proof encoding");
|
|
3527
|
+
if (`0x${createHash("sha256").update(bytes).digest("hex")}` !== receipt.response_hash) {
|
|
3528
|
+
throw new Error("Response content differs from its Provider-signed commitment");
|
|
3529
|
+
}
|
|
3530
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
3531
|
+
if (!value || value.schema !== "mycomesh.provider-response-commitment.v1"
|
|
3532
|
+
|| value.request_id !== requestId || value.endpoint !== endpoint || value.model !== model
|
|
3533
|
+
|| !value.raw || typeof value.raw !== "object" || Array.isArray(value.raw)) {
|
|
3534
|
+
throw new Error("Provider content proof does not match this request");
|
|
3535
|
+
}
|
|
3536
|
+
// Only authenticated bytes supply the API result. Ignore unauthenticated
|
|
3537
|
+
// sibling fields; verify before model/schema restoration or SSE generation.
|
|
3538
|
+
return value.raw;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
1178
3541
|
function normalizeOpenAiError(value, fallbackType) {
|
|
1179
3542
|
if (!value || typeof value !== "object") return openaiError(String(value || fallbackType), fallbackType);
|
|
1180
3543
|
if (!value.error || typeof value.error !== "object") return openaiError(value.detail || value.message || fallbackType, fallbackType);
|
|
@@ -1326,6 +3689,8 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
|
|
|
1326
3689
|
if (alphaSearch) {
|
|
1327
3690
|
body = {
|
|
1328
3691
|
model: body.model,
|
|
3692
|
+
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
3693
|
+
...(body.conversation !== undefined ? { conversation: body.conversation } : {}),
|
|
1329
3694
|
input: [{ type: "mycomesh_alpha_search_request", request: body, query: alphaSearchQuery }],
|
|
1330
3695
|
max_output_tokens: body.max_output_tokens ?? 2000,
|
|
1331
3696
|
};
|
|
@@ -1340,7 +3705,7 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
|
|
|
1340
3705
|
body.input = items;
|
|
1341
3706
|
}
|
|
1342
3707
|
try {
|
|
1343
|
-
const result = await state.relayInference(path, body);
|
|
3708
|
+
const result = await state.relayInference(path, body, request.headers);
|
|
1344
3709
|
if (result.status >= 400) {
|
|
1345
3710
|
writeJson(response, result.status, result.payload, result.headers);
|
|
1346
3711
|
return;
|
|
@@ -1356,7 +3721,7 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
|
|
|
1356
3721
|
return;
|
|
1357
3722
|
}
|
|
1358
3723
|
if (body.stream === true) {
|
|
1359
|
-
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive", "x-mycomesh-streaming-mode": "buffered" });
|
|
3724
|
+
response.writeHead(200, { ...result.headers, "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive", "x-mycomesh-streaming-mode": "buffered" });
|
|
1360
3725
|
const events = path.endsWith("/chat/completions")
|
|
1361
3726
|
? chatCompletionSse(result.payload, body.stream_options?.include_usage === true)
|
|
1362
3727
|
: responseSse(result.payload, path.endsWith("/responses/compact") || hasCompactionTrigger(body.input));
|
|
@@ -1377,9 +3742,9 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
|
|
|
1377
3742
|
const path = url.pathname.startsWith("/v1/v1/") ? url.pathname.slice(3) : url.pathname;
|
|
1378
3743
|
if (publicOnly) {
|
|
1379
3744
|
response.setHeader("Access-Control-Allow-Origin", "*");
|
|
1380
|
-
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
3745
|
+
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-MycoMesh-Session-Id, X-Session-Id, session_id");
|
|
1381
3746
|
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1382
|
-
response.setHeader("Access-Control-Expose-Headers", "PAYMENT-RESPONSE, Retry-After");
|
|
3747
|
+
response.setHeader("Access-Control-Expose-Headers", "PAYMENT-RESPONSE, Retry-After, X-MycoMesh-Session-Id, X-MycoMesh-History-Status, X-MycoMesh-Content-Verification");
|
|
1383
3748
|
if (request.method === "OPTIONS") { response.writeHead(204); response.end(); return; }
|
|
1384
3749
|
if (request.method === "GET" && (path === "/health" || path === "/v1/health")) {
|
|
1385
3750
|
if (!state.authorizeBearer(String(request.headers.authorization || ""), { shareOnly: true })) { writeJson(response, 401, openaiError("invalid temporary access key", "invalid_api_key")); return; }
|
|
@@ -1388,7 +3753,7 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
|
|
|
1388
3753
|
}
|
|
1389
3754
|
if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
|
|
1390
3755
|
if (!state.authorizeBearer(String(request.headers.authorization || ""), { shareOnly: true })) { writeJson(response, 401, openaiError("invalid temporary access key", "invalid_api_key")); return; }
|
|
1391
|
-
try { const selected = await state.chooseRelay(); writeJson(response, 200, { object: "list", data:
|
|
3756
|
+
try { const selected = await state.chooseRelay(); const models = Array.isArray(state.capabilities(selected.health).models) ? state.capabilities(selected.health).models : [state.capabilities(selected.health).model || DEFAULT_MODEL]; writeJson(response, 200, { object: "list", data: models.map((id) => ({ id: String(id), object: "model", owned_by: "mycomesh", relay: selected.relayUrl })) }); }
|
|
1392
3757
|
catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
|
|
1393
3758
|
return;
|
|
1394
3759
|
}
|
|
@@ -1402,6 +3767,24 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
|
|
|
1402
3767
|
writeJson(response, 404, openaiError("route not found", "invalid_request_error"));
|
|
1403
3768
|
return;
|
|
1404
3769
|
}
|
|
3770
|
+
if (path === "/" || path === "/credentials" || path === "/codex-env" || path.startsWith("/v1/mycomesh/local/")) {
|
|
3771
|
+
const remote = request.socket.remoteAddress || "";
|
|
3772
|
+
const authority = String(request.headers.host || "").toLowerCase();
|
|
3773
|
+
const localPort = server.address()?.port;
|
|
3774
|
+
const localAuthority = new Set([`127.0.0.1:${localPort}`, `localhost:${localPort}`, `[::1]:${localPort}`]);
|
|
3775
|
+
const origin = request.headers.origin;
|
|
3776
|
+
if (!(remote === "::1" || /^(?:::ffff:)?127\.\d+\.\d+\.\d+$/.test(remote))
|
|
3777
|
+
|| !localAuthority.has(authority)
|
|
3778
|
+
|| (origin !== undefined && origin !== `http://${authority}`)
|
|
3779
|
+
|| request.headers["sec-fetch-site"] === "cross-site") {
|
|
3780
|
+
writeJson(response, 403, { ok: false, error: "local management requires a same-origin loopback connection" });
|
|
3781
|
+
return;
|
|
3782
|
+
}
|
|
3783
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
3784
|
+
response.setHeader("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'none'; object-src 'none'");
|
|
3785
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
3786
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
3787
|
+
}
|
|
1405
3788
|
if (request.method === "GET" && path === "/") {
|
|
1406
3789
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1407
3790
|
response.end(consumerHtml());
|
|
@@ -1409,16 +3792,41 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
|
|
|
1409
3792
|
}
|
|
1410
3793
|
if (request.method === "GET" && path === "/health") { writeJson(response, 200, state.healthPayload()); return; }
|
|
1411
3794
|
if (request.method === "GET" && path === "/ready") {
|
|
1412
|
-
try {
|
|
1413
|
-
|
|
3795
|
+
try {
|
|
3796
|
+
writeJson(response, 200, await state.readinessPayload());
|
|
3797
|
+
}
|
|
3798
|
+
catch (error) {
|
|
3799
|
+
const timedOut = error?.statusCode === 504;
|
|
3800
|
+
const payload = { ok: false, liveness_ready: false, paid_ready: null, paid_readiness_checked: false,
|
|
3801
|
+
error: timedOut ? "readiness check timed out; retry shortly" : error.message,
|
|
3802
|
+
code: timedOut ? "readiness_timeout" : (error.code || "relay_unavailable") };
|
|
3803
|
+
if (error.availableAt) payload.available_at = error.availableAt;
|
|
3804
|
+
writeJson(response, 503, payload, timedOut
|
|
3805
|
+
? { "retry-after": "2" }
|
|
3806
|
+
: error.code === "budget_not_started" && error.availableAt
|
|
3807
|
+
? { "retry-after": String(Math.max(1, Math.ceil(error.availableAt - Date.now() / 1000))) } : {});
|
|
3808
|
+
}
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
if (request.method === "GET" && path === "/paid-ready") {
|
|
3812
|
+
const payload = await state.paidReadinessPayload();
|
|
3813
|
+
const status = payload.paid_ready ? 200
|
|
3814
|
+
: ["wallet_locked", "payment_key_not_ready"].includes(payload.code) ? 423
|
|
3815
|
+
: payload.code === "budget_unavailable" || payload.code === "budget_not_started" ? 402
|
|
3816
|
+
: 503;
|
|
3817
|
+
const headers = payload.code === "budget_not_started" && payload.available_at
|
|
3818
|
+
? { "retry-after": String(Math.max(1, Math.ceil(payload.available_at - Date.now() / 1000))) }
|
|
3819
|
+
: {};
|
|
3820
|
+
writeJson(response, status, payload, headers);
|
|
1414
3821
|
return;
|
|
1415
3822
|
}
|
|
1416
3823
|
if (request.method === "GET" && (path === "/credentials" || path === "/codex-env")) {
|
|
1417
3824
|
if (!state.paymentUnlocked) { writeJson(response, 423, { ok: false, error: "sign in with the payment-key owner wallet first" }); return; }
|
|
3825
|
+
if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
|
|
1418
3826
|
response.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); response.end(`${state.credentialsText()}\n`); return;
|
|
1419
3827
|
}
|
|
1420
3828
|
if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
|
|
1421
|
-
try {
|
|
3829
|
+
try { writeJson(response, 200, { object: "list", data: await state.modelCatalog() }); }
|
|
1422
3830
|
catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
|
|
1423
3831
|
return;
|
|
1424
3832
|
}
|
|
@@ -1429,12 +3837,30 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
|
|
|
1429
3837
|
}
|
|
1430
3838
|
if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/authenticate") {
|
|
1431
3839
|
try { writeJson(response, 200, await state.authenticateWallet(await decodeRequestBody(request))); }
|
|
1432
|
-
catch (error) {
|
|
3840
|
+
catch (error) {
|
|
3841
|
+
const payload = { ok: false, error: error.message };
|
|
3842
|
+
// Keep the legacy error string, and expose only explicit public fields.
|
|
3843
|
+
if (["payment_key_owner_mismatch", "wallet_verification_unavailable"].includes(error.code)) {
|
|
3844
|
+
payload.code = error.code;
|
|
3845
|
+
payload.selected_wallet = error.selected_wallet;
|
|
3846
|
+
payload.payment_key_address = error.payment_key_address;
|
|
3847
|
+
if (error.expected_owner) payload.expected_owner = error.expected_owner;
|
|
3848
|
+
}
|
|
3849
|
+
writeJson(response, error.code === "wallet_verification_unavailable" ? 503 : 401, payload);
|
|
3850
|
+
}
|
|
1433
3851
|
return;
|
|
1434
3852
|
}
|
|
1435
3853
|
if (request.method === "GET" && path === "/v1/mycomesh/local/dashboard") {
|
|
1436
3854
|
writeJson(response, 200, await state.dashboardPayload(state.authorizeManagement(String(request.headers.authorization || "")))); return;
|
|
1437
3855
|
}
|
|
3856
|
+
if (request.method === "POST" && path === "/v1/mycomesh/local/history/sync") {
|
|
3857
|
+
if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
|
|
3858
|
+
await state.refreshDisputeTransactions(true);
|
|
3859
|
+
await state.refreshReceiptStatuses(true);
|
|
3860
|
+
writeJson(response, 200, { ok: true, history_sync: {
|
|
3861
|
+
running: Boolean(state.historySync), last_checked_at: state.historySyncAt, error: state.historySyncError,
|
|
3862
|
+
} }); return;
|
|
3863
|
+
}
|
|
1438
3864
|
if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/activate") {
|
|
1439
3865
|
if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
|
|
1440
3866
|
try { writeJson(response, 200, await state.activateCurrentPaymentKey()); }
|
|
@@ -1451,6 +3877,12 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
|
|
|
1451
3877
|
catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
|
|
1452
3878
|
return;
|
|
1453
3879
|
}
|
|
3880
|
+
if (request.method === "POST" && path === "/v1/mycomesh/local/disputes/progress") {
|
|
3881
|
+
if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
|
|
3882
|
+
try { writeJson(response, 200, state.recordDisputeProgress(await decodeRequestBody(request))); }
|
|
3883
|
+
catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
|
|
3884
|
+
return;
|
|
3885
|
+
}
|
|
1454
3886
|
if (request.method === "POST" && path === "/v1/mycomesh/local/key/prepare") {
|
|
1455
3887
|
if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
|
|
1456
3888
|
try { writeJson(response, 200, state.preparePaymentKey()); } catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
|
|
@@ -1598,45 +4030,146 @@ function consumerHtml() {
|
|
|
1598
4030
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light">
|
|
1599
4031
|
<title>Consumer | MycoMesh</title>
|
|
1600
4032
|
<style>
|
|
1601
|
-
:root{--ink:#17211d;--muted:#68736e;--line:#d8dfdb;--soft:#f2f5f3;--paper:#fff;--green:#147553;--green-dark:#0d5b40;--amber:#9a6413;--red:#ae3d38;--blue:#365d86}*{box-sizing:border-box}[hidden]{display:none!important}html{background:#edf1ee}body{min-width:320px;margin:0;color:var(--ink);background:#edf1ee;font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}.shell{min-height:100vh}.topbar{position:sticky;z-index:10;top:0;display:flex;min-height:60px;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 max(18px,env(safe-area-inset-left))}.brand{display:flex;align-items:center;gap:10px;font-weight:780}.mark{display:grid;width:30px;height:30px;place-items:center;border-radius:6px;background:var(--ink);color:#fff;font-size:12px}.network{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#9ba49f}.dot.ok{background:var(--green)}.workspace{width:min(760px,100%);margin:0 auto;background:var(--paper);min-height:calc(100vh - 60px)}.locked{display:grid;min-height:calc(100vh - 60px);align-content:center;padding:42px 24px 88px}.locked-inner{width:min(420px,100%);margin:0 auto}.locked-badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:5px 10px;color:var(--muted);font-size:12px}.locked h1{margin:20px 0 9px;font-size:30px;line-height:1.16}.locked p{margin:0 0 26px;color:var(--muted)}.key-preview{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:14px 0;margin:0 0 22px}.label{display:block;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.key-preview .mono{display:block;margin-top:5px;font-size:12px}.button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:6px;background:var(--paper);padding:0 15px;color:var(--ink);font-weight:700}.button:hover{border-color:#95a39b;background:#f8faf9}.button.primary{border-color:var(--green);background:var(--green);color:#fff}.button.primary:hover{background:var(--green-dark)}.button.danger{border-color:#e5b6b3;color:var(--red)}.button.small{min-height:34px;padding:0 11px;font-size:12px}.button:disabled{cursor:not-allowed;opacity:.52}.locked .button{width:100%;min-height:48px}.app-head{padding:24px 20px 17px}.app-head-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.app-head h1{margin:0;font-size:22px}.wallet-button{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.balance{margin-top:22px}.balance strong{display:block;margin-top:4px;font-size:31px;font-weight:760}.balance-meta{display:flex;gap:16px;margin-top:8px;color:var(--muted);font-size:12px}.tabs{position:sticky;z-index:8;top:60px;display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 12px}.tab{min-width:0;border:0;border-bottom:2px solid transparent;background:transparent;padding:12px 4px;color:var(--muted);font-weight:650}.tab.active{border-color:var(--green);color:var(--ink)}.view{padding:2px 20px 84px}.band{border-top:1px solid var(--line);padding:23px 0}.band:first-child{border-top:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--muted);font-size:12px}.status{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:4px 9px;color:var(--muted);font-size:11px;white-space:nowrap}.status:before{width:6px;height:6px;border-radius:50%;background:var(--amber);content:""}.status.ok:before{background:var(--green)}.field+.field{margin-top:13px}.field-row{display:flex;align-items:stretch;gap:7px;margin-top:6px}.value{min-width:0;flex:1;border:1px solid var(--line);border-radius:5px;background:var(--soft);padding:10px 11px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.value.exports{min-height:76px;white-space:pre-wrap}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);border-radius:6px;overflow:hidden}.metric{min-width:0;padding:14px 10px;border-right:1px solid var(--line)}.metric:last-child{border-right:0}.metric span{display:block;color:var(--muted);font-size:11px}.metric strong{display:block;overflow:hidden;margin-top:4px;font-size:17px;text-overflow:ellipsis}.list{margin:0}.list div{display:grid;grid-template-columns:104px minmax(0,1fr);gap:12px;border-bottom:1px solid var(--line);padding:12px 0}.list div:last-child{border-bottom:0}.list dt{color:var(--muted)}.list dd{overflow:hidden;margin:0;text-align:right;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.topup{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.input,.select{width:100%;min-height:42px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:8px 10px;color:var(--ink)}.input:focus,.select:focus{border-color:var(--green);outline:2px solid rgba(20,117,83,.13)}.share-output{margin-top:16px}.empty{padding:34px 0;color:var(--muted);text-align:center}.table-wrap{overflow-x:auto;border-top:1px solid var(--line)}table{width:100%;min-width:620px;border-collapse:collapse}th,td{padding:11px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:12px}th{color:var(--muted);font-weight:650}.notice{border-left:3px solid var(--amber);background:#fff8e9;padding:10px 12px;color:#76511c;font-size:12px}.notice.error{border-color:var(--red);background:#fff3f2;color:#892f2b}.toast{position:fixed;z-index:30;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));left:16px;max-width:520px;margin:auto;border-radius:6px;background:var(--ink);padding:11px 14px;color:#fff;box-shadow:0 10px 30px rgba(23,33,29,.2);text-align:center}.toast.error{background:#7e2925}@media(min-width:761px){body{padding:22px}.shell{border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:0 18px 50px rgba(23,33,29,.08)}.workspace{min-height:calc(100vh - 106px)}.topbar{position:relative}.tabs{top:0}.view,.app-head{padding-right:28px;padding-left:28px}.toast{bottom:28px}}@media(max-width:420px){.balance strong{font-size:27px}.metrics{grid-template-columns:1fr}.metric{display:flex;align-items:center;justify-content:space-between;border-right:0;border-bottom:1px solid var(--line)}.metric:last-child{border-bottom:0}.metric strong{margin:0}.field-row{flex-direction:column}.field-row .button{width:100%}.topup{grid-template-columns:1fr}.topup .button{width:100%}}
|
|
4033
|
+
:root{--ink:#17211d;--muted:#68736e;--line:#d8dfdb;--soft:#f2f5f3;--paper:#fff;--green:#147553;--green-dark:#0d5b40;--amber:#9a6413;--red:#ae3d38;--blue:#365d86}*{box-sizing:border-box}[hidden]{display:none!important}html{background:#edf1ee}body{min-width:320px;margin:0;color:var(--ink);background:#edf1ee;font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}.shell{min-height:100vh}.topbar{position:sticky;z-index:10;top:0;display:flex;min-height:60px;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 max(18px,env(safe-area-inset-left))}.brand{display:flex;align-items:center;gap:10px;font-weight:780}.mark{display:grid;width:30px;height:30px;place-items:center;border-radius:6px;background:var(--ink);color:#fff;font-size:12px}.network{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#9ba49f}.dot.ok{background:var(--green)}.workspace{width:min(760px,100%);margin:0 auto;background:var(--paper);min-height:calc(100vh - 60px)}.locked{display:grid;min-height:calc(100vh - 60px);align-content:center;padding:42px 24px 88px}.locked-inner{width:min(420px,100%);margin:0 auto}.locked-badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:5px 10px;color:var(--muted);font-size:12px}.locked h1{margin:20px 0 9px;font-size:30px;line-height:1.16}.locked p{margin:0 0 26px;color:var(--muted)}.key-preview{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:14px 0;margin:0 0 22px}.label{display:block;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.key-preview .mono{display:block;margin-top:5px;font-size:12px}.button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:6px;background:var(--paper);padding:0 15px;color:var(--ink);font-weight:700}.button:hover{border-color:#95a39b;background:#f8faf9}.button.primary{border-color:var(--green);background:var(--green);color:#fff}.button.primary:hover{background:var(--green-dark)}.button.danger{border-color:#e5b6b3;color:var(--red)}.button.small{min-height:34px;padding:0 11px;font-size:12px}.button:disabled{cursor:not-allowed;opacity:.52}.locked .button{width:100%;min-height:48px}.app-head{padding:24px 20px 17px}.app-head-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.app-head h1{margin:0;font-size:22px}.wallet-button{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.balance{margin-top:22px}.balance strong{display:block;margin-top:4px;font-size:31px;font-weight:760}.balance-meta{display:flex;gap:16px;margin-top:8px;color:var(--muted);font-size:12px}.tabs{position:sticky;z-index:8;top:60px;display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 12px}.tab{min-width:0;border:0;border-bottom:2px solid transparent;background:transparent;padding:12px 4px;color:var(--muted);font-weight:650}.tab.active{border-color:var(--green);color:var(--ink)}.view{padding:2px 20px 84px}.band{border-top:1px solid var(--line);padding:23px 0}.band:first-child{border-top:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--muted);font-size:12px}.status{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:4px 9px;color:var(--muted);font-size:11px;white-space:nowrap}.status:before{width:6px;height:6px;border-radius:50%;background:var(--amber);content:""}.status.ok:before{background:var(--green)}.field+.field{margin-top:13px}.field-row{display:flex;align-items:stretch;gap:7px;margin-top:6px}.value{min-width:0;flex:1;border:1px solid var(--line);border-radius:5px;background:var(--soft);padding:10px 11px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.value.exports{min-height:76px;white-space:pre-wrap}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);border-radius:6px;overflow:hidden}.metric{min-width:0;padding:14px 10px;border-right:1px solid var(--line)}.metric:last-child{border-right:0}.metric span{display:block;color:var(--muted);font-size:11px}.metric strong{display:block;overflow:hidden;margin-top:4px;font-size:17px;text-overflow:ellipsis}.list{margin:0}.list div{display:grid;grid-template-columns:104px minmax(0,1fr);gap:12px;border-bottom:1px solid var(--line);padding:12px 0}.list div:last-child{border-bottom:0}.list dt{color:var(--muted)}.list dd{overflow:hidden;margin:0;text-align:right;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.topup{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.input,.select{width:100%;min-height:42px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:8px 10px;color:var(--ink)}.input:focus,.select:focus{border-color:var(--green);outline:2px solid rgba(20,117,83,.13)}.share-output{margin-top:16px}.empty{padding:34px 0;color:var(--muted);text-align:center}.table-wrap{overflow-x:auto;border-top:1px solid var(--line)}table{width:100%;min-width:620px;border-collapse:collapse}th,td{padding:11px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:12px}th{color:var(--muted);font-weight:650}.notice{border-left:3px solid var(--amber);background:#fff8e9;padding:10px 12px;color:#76511c;font-size:12px}.notice.error{border-color:var(--red);background:#fff3f2;color:#892f2b}.recovery{display:flex;align-items:center;justify-content:space-between;gap:12px}.recovery span{flex:1}.toast{position:fixed;z-index:30;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));left:16px;max-width:520px;margin:auto;border-radius:6px;background:var(--ink);padding:11px 14px;color:#fff;box-shadow:0 10px 30px rgba(23,33,29,.2);text-align:center}.toast.error{background:#7e2925}@media(min-width:761px){body{padding:22px}.shell{border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:0 18px 50px rgba(23,33,29,.08)}.workspace{min-height:calc(100vh - 106px)}.topbar{position:relative}.tabs{top:0}.view,.app-head{padding-right:28px;padding-left:28px}.toast{bottom:28px}}@media(max-width:420px){.balance strong{font-size:27px}.metrics{grid-template-columns:1fr}.metric{display:flex;align-items:center;justify-content:space-between;border-right:0;border-bottom:1px solid var(--line)}.metric:last-child{border-bottom:0}.metric strong{margin:0}.field-row{flex-direction:column}.field-row .button{width:100%}.topup{grid-template-columns:1fr}.topup .button{width:100%}}
|
|
1602
4034
|
</style></head><body>
|
|
1603
|
-
<div class="shell"><header class="topbar"><div class="brand"><span class="mark">M</span><span>MycoMesh</span></div><div class="network"><span id="networkDot" class="dot"></span><span id="networkName">
|
|
1604
|
-
<div id="locked" class="locked"><div class="locked-inner"><span class="locked-badge">
|
|
1605
|
-
<div id="app" hidden><div class="app-head"><div class="app-head-row"><div><span class="label">预付账户</span><h1>Consumer</h1></div><button id="walletButton" class="button small wallet-button" type="button">退出</button></div><div class="balance"><span class="label">可用余额</span><strong id="balance">--</strong><div class="balance-meta"><span id="keyStatus">Key 状态 --</span><span id="requestCount">0 次请求</span></div></div></div>
|
|
4035
|
+
<div class="shell"><header class="topbar"><div class="brand"><span class="mark">M</span><span>MycoMesh</span></div><div class="network"><span id="networkDot" class="dot"></span><span id="networkName">MycoMesh</span></div></header><main class="workspace">
|
|
4036
|
+
<div id="locked" class="locked"><div class="locked-inner"><span class="locked-badge">MycoMesh API</span><h1>连接钱包以继续</h1><p>连接钱包后获取 API 地址和访问密钥。无需填写钱包私钥。</p><p>请在已安装钱包扩展的浏览器中打开此本机地址;未安装钱包时可先安装,再刷新连接。</p><p id="loginOwnerHint" class="notice mono" aria-live="polite">正在核对本机付款 Key 的归属…</p><details class="key-preview"><summary>设备访问凭证详情</summary><span id="lockedKey" class="mono">读取中...</span></details><button id="login" class="button primary" type="button">连接钱包并签名</button><p id="loginError" class="notice error mono" role="alert" hidden></p></div></div>
|
|
4037
|
+
<div id="app" hidden><div class="app-head"><div class="app-head-row"><div><span class="label">预付账户</span><h1>Consumer</h1></div><button id="walletButton" class="button small wallet-button" type="button">退出</button></div><div class="balance"><span id="balanceLabel" class="label">可用余额</span><strong id="balance">--</strong><div class="balance-meta"><span id="keyStatus">Key 状态 --</span><span id="requestCount">0 次请求</span></div></div></div>
|
|
1606
4038
|
<nav class="tabs" aria-label="Consumer navigation"><button class="tab active" data-view="overview" type="button">概览</button><button class="tab" data-view="wallet" type="button">钱包</button><button class="tab" data-view="activity" type="button">记录</button><button class="tab" data-view="share" type="button">分享</button></nav>
|
|
1607
|
-
<div id="view-overview" class="view"><div class="band"><div class="section-head"><div><h2>访问凭证</h2><p id="credentialState">等待 Key 激活</p></div><span id="credentialBadge" class="status">锁定</span></div><div id="credentials" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="url" class="value"></div><button class="button small copy" data-copy="url" type="button">复制</button></div></div><div class="field"><span class="label">Key</span><div class="field-row"><div id="key" class="value"></div><button class="button small copy" data-copy="key" type="button">复制</button></div></div><div class="field"><span class="label">Export</span><div class="field-row"><div id="export" class="value exports"></div><button class="button small copy" data-copy="export" type="button">复制</button></div></div></div><div id="inactiveKey" class="notice"
|
|
1608
|
-
<div id="view-wallet" class="view" hidden><div class="band"><div class="section-head"><h2>钱包与 Key</h2><span id="chainStatus" class="status">读取中</span></div><dl class="list"><div><dt>钱包</dt><dd id="walletAddress" class="mono"></dd></div><div><dt>Key 地址</dt><dd id="keyAddress" class="mono"></dd></div><div><dt>单次上限</dt><dd id="keyLimit"></dd></div><div><dt>有效期</dt><dd id="keyValidity"></dd></div></dl><p id="chainError" class="notice error" hidden></p><div class="actions"><button id="activate" class="button primary" type="button">激活 Key</button><button id="rotate" class="button danger" type="button" hidden>更换 Key</button></div></div><div class="band"><div class="section-head"><div><h2>充值</h2><p id="walletBalance">钱包余额 --</p></div></div><div class="topup"><input id="amount" class="input" inputmode="decimal" placeholder="10.00 USDC"><button id="topup" class="button primary" type="button">充值</button></div></div></div>
|
|
1609
|
-
<div id="view-activity" class="view" hidden><div class="band"><div class="section-head"><div><h2>消费记录</h2><p
|
|
4039
|
+
<div id="view-overview" class="view"><div class="band"><div class="section-head"><div><h2>调用就绪状态</h2><p>四项全部就绪后才可发起付费请求</p></div><span id="paidReadiness" class="status">检查中</span></div><dl class="list"><div><dt>钱包与 Key</dt><dd><span id="walletReadiness" class="status">检查中</span></dd></div><div><dt>网络与 RPC</dt><dd><span id="networkReadiness" class="status">检查中</span></dd></div><div><dt>固定预算</dt><dd><span id="budgetReadinessBadge" class="status">检查中</span></dd></div><div><dt>可用模型</dt><dd><span id="modelsReadiness" class="status">检查中</span></dd></div></dl><p id="readinessAction" class="notice"></p></div><div class="band"><div class="section-head"><div><h2>访问凭证</h2><p id="credentialState">等待 Key 激活</p></div><span id="credentialBadge" class="status">锁定</span></div><div id="credentials" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="url" class="value"></div><button class="button small copy" data-copy="url" type="button">复制</button></div></div><div class="field"><span class="label">Key</span><div class="field-row"><div id="key" class="value"></div><button class="button small copy" data-copy="key" type="button">复制</button></div></div><div class="field"><span class="label">Export</span><div class="field-row"><div id="export" class="value exports"></div><button class="button small copy" data-copy="export" type="button">复制</button></div></div></div><div id="inactiveKey" class="notice"><span id="activationNotice">首次使用需在钱包中确认一次访问授权。</span><div class="actions"><button id="setupAccess" class="button primary" type="button">启用 API 访问</button></div></div></div><div class="band"><div class="section-head"><div><h2>网络与模型</h2><p>只显示通过部署校验的可用 Relay 路由</p></div><span id="modelStatus" class="status">读取中</span></div><dl id="modelList" class="list"><div><dt>模型目录</dt><dd>正在读取…</dd></div></dl><p id="modelError" class="notice error" hidden></p></div><div class="band"><div class="section-head"><h2>本地用量</h2></div><div class="metrics"><div class="metric"><span>累计消费</span><strong id="spent">--</strong></div><div class="metric"><span>输入 Tokens</span><strong id="inputTokens">0</strong></div><div class="metric"><span>输出 Tokens</span><strong id="outputTokens">0</strong></div></div></div></div>
|
|
4040
|
+
<div id="view-wallet" class="view" hidden><div id="budgetPanel" class="band" hidden><div class="section-head"><h2>固定预算</h2><span id="budgetLocked" class="status"></span></div><p id="budgetNote" class="notice"></p><p id="budgetUnallocated" class="notice"></p><p class="notice">充值余额需开通固定预算后才能调用;充值不等于已有可调用预算。</p><p id="budgetReadiness" class="notice"></p><div id="budgetChannels"></div></div><div class="band"><div class="section-head"><h2>钱包与 Key</h2><span id="chainStatus" class="status">读取中</span></div><dl class="list"><div><dt>钱包</dt><dd id="walletAddress" class="mono"></dd></div><div><dt>Key 地址</dt><dd id="keyAddress" class="mono"></dd></div><div><dt>单次上限</dt><dd id="keyLimit"></dd></div><div><dt>有效期</dt><dd id="keyValidity"></dd></div></dl><p id="chainError" class="notice error" hidden></p><div class="actions"><button id="activate" class="button primary" type="button">激活 Key</button><button id="rotate" class="button danger" type="button" hidden>更换 Key</button></div></div><div class="band"><div class="section-head"><div><h2>充值</h2><p id="walletBalance">钱包余额 --</p></div></div><div class="topup"><input id="amount" class="input" type="number" min="0.000001" step="0.000001" inputmode="decimal" autocomplete="off" placeholder="10.00 USDC" aria-label="充值金额(USDC)"><button id="topup" class="button primary" type="button">充值</button></div></div></div>
|
|
4041
|
+
<div id="view-activity" class="view" hidden><div class="band"><div class="section-head"><div><h2>消费记录</h2><p>当前 Key 在本机各 Consumer 的账单,按请求去重</p></div><button id="refresh" class="button small" type="button">刷新</button></div><p id="historySync" class="notice" hidden></p><p class="notice">申诉仅用于签名证据能够证明的客观欺诈或服务未交付;错误申诉可能损失举报保证金。页面不会公开完整提示词或 Provider 响应。</p><div id="historyRecovery" class="notice error recovery" hidden><span id="historyRecoveryText"></span><button id="historyRecoveryButton" class="button small" type="button">立即核验</button></div><div id="historyEmpty" class="empty">暂无消费记录</div><div id="historyTable" class="table-wrap" hidden><table><thead><tr><th>时间</th><th>模型</th><th>Tokens</th><th>费用</th><th>Provider</th><th>状态</th><th>会话</th><th>申诉</th></tr></thead><tbody id="history"></tbody></table></div></div></div>
|
|
1610
4042
|
<div id="view-share" class="view" hidden><div class="band"><div class="section-head"><div><h2>临时分享</h2><p>到期后自动关闭</p></div><span id="shareStatus" class="status">未启用</span></div><div class="topup"><select id="shareMinutes" class="select"><option value="10">10 分钟</option><option value="30" selected>30 分钟</option><option value="60">1 小时</option><option value="360">6 小时</option></select><button id="shareStart" class="button primary" type="button">开始分享</button></div><div id="shareOutput" class="share-output" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="shareUrl" class="value"></div><button class="button small copy" data-copy="shareUrl" type="button">复制</button></div></div><div class="field"><span class="label">临时 Key</span><div class="field-row"><div id="shareKey" class="value"></div><button class="button small copy" data-copy="shareKey" type="button">复制</button></div></div><p id="shareExpiry" class="notice"></p><div class="actions"><button id="shareStop" class="button danger" type="button">停止分享</button></div></div></div></div></div>
|
|
1611
4043
|
</main></div><div id="toast" class="toast" hidden></div>
|
|
1612
4044
|
<script>
|
|
1613
|
-
let state=null,wallet=null,managementToken=null,busy=false;const $=id=>document.getElementById(id);const short=value=>value?value.slice(0,6)+'...'+value.slice(-4):'--';
|
|
1614
|
-
function units(value,decimals=6){const raw=BigInt(value||0),base=10n**BigInt(decimals),whole=raw/base,fraction=(raw%base).toString().padStart(decimals,'0').replace(/0+$/,'');return whole.toLocaleString()+(fraction?'.'+fraction.slice(0,
|
|
4045
|
+
let state=null,wallet=null,managementToken=null,busy=false,loading=null;const $=id=>document.getElementById(id);const short=value=>value?value.slice(0,6)+'...'+value.slice(-4):'--';
|
|
4046
|
+
function units(value,decimals=6){const raw=BigInt(value||0),base=10n**BigInt(decimals),whole=raw/base,fraction=(raw%base).toString().padStart(decimals,'0').replace(/0+$/,'');return whole.toLocaleString()+(fraction?'.'+fraction.slice(0,Math.max(6,Math.min(decimals,8))):'')}
|
|
1615
4047
|
function authHeaders(headers={}){const next=new Headers(headers);if(managementToken)next.set('authorization','Bearer '+managementToken);return next}
|
|
1616
|
-
async function api(path,options={}){const response=await fetch(path,{...options,headers:authHeaders(options.headers)}),data=await response.json();if(!response.ok)
|
|
4048
|
+
async function api(path,options={}){const response=await fetch(path,{...options,headers:authHeaders(options.headers)}),data=await response.json();if(!response.ok){const error=new Error(typeof data.error==='string'?data.error:(data.error?.message||'请求失败'));for(const field of ['code','selected_wallet','expected_owner','payment_key_address'])if(typeof data[field]==='string')error[field]=data[field];if(error.code==='payment_key_owner_mismatch')error.message=ownerMismatchMessage(error);if(error.code==='wallet_verification_unavailable')error.message='暂时无法核验链上钱包归属或预算,请稍后重试。现有 Key、账单和已登录会话保持不变。';throw error}return data}
|
|
1617
4049
|
function toast(message,error=false){const node=$('toast');node.textContent=message;node.className='toast'+(error?' error':'');node.hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>node.hidden=true,3200)}
|
|
1618
4050
|
function setBusy(value){busy=value;for(const button of document.querySelectorAll('button'))button.disabled=value}
|
|
1619
|
-
async function run(task){if(busy)return;setBusy(true);try{await task()}catch(error){
|
|
1620
|
-
async function load(){state=await api('/v1/mycomesh/local/dashboard');render()}
|
|
1621
|
-
function render(){const authenticated=Boolean(managementToken&&state.auth?.authenticated),ready=authenticated&&state.auth.key_ready,grant=state.key.grant||{},decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';$('networkName').textContent=state.chain_error?'链上不可用':(state.settlement?.network_name||'
|
|
1622
|
-
function
|
|
4051
|
+
async function run(task){if(busy)return;setBusy(true);try{await task()}catch(error){const message=error?.message||String(error);if(!managementToken){$('loginError').textContent=message;$('loginError').hidden=false}toast(message,true)}finally{setBusy(false)}}
|
|
4052
|
+
async function load(){if(loading)return loading;loading=(async()=>{state=await api('/v1/mycomesh/local/dashboard');render()})();try{await loading}finally{loading=null}}
|
|
4053
|
+
function render(){const authenticated=Boolean(managementToken&&state.auth?.authenticated),ready=authenticated&&state.auth.key_ready,grant=state.key.grant||{},decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';$('networkName').textContent=state.chain_error?'链上不可用':(state.settlement?.network_name||'MycoMesh');$('networkDot').className='dot'+(state.inference_ready===true&&!state.chain_error?' ok':'');$('lockedKey').textContent=state.key.address;renderLoginOwner();$('locked').hidden=authenticated;$('app').hidden=!authenticated;if(!authenticated)return;renderReadiness();$('walletButton').textContent=short(state.auth.wallet);$('walletAddress').textContent=state.auth.wallet;$('keyAddress').textContent=state.key.address;$('balanceLabel').textContent=state.protocol_version===10?'可用请求预算':'可用余额';$('balance').textContent=state.protocol_version===10?units(state.budget_available_units||0,decimals)+' '+symbol:(state.account?units(state.account.available_balance_units,decimals)+' '+symbol:'--');$('requestCount').textContent=state.usage.request_count+' 次请求';$('keyStatus').textContent=ready?'Key 已激活':'Key 待激活';$('credentialBadge').className='status'+(ready?' ok':'');$('credentialBadge').textContent=ready?'可用':'待激活';$('credentialState').textContent=ready?'仅在本机显示':'链上确认后显示';$('credentials').hidden=!ready;$('inactiveKey').hidden=ready;if(ready){$('url').textContent=state.credentials.base_url;$('key').textContent=state.credentials.api_key;$('export').textContent=state.credentials.export}$('spent').textContent=units(state.usage.total_spent_units,decimals)+' '+symbol;$('inputTokens').textContent=Number(state.usage.input_tokens||0).toLocaleString();$('outputTokens').textContent=Number(state.usage.output_tokens||0).toLocaleString();$('keyLimit').textContent=grant.max_per_request?units(grant.max_per_request,decimals)+' '+symbol:'--';$('keyValidity').textContent=grant.valid_until?new Date(grant.valid_until*1000).toLocaleString():'长期有效';$('chainStatus').className='status'+(grant.active?' ok':'');$('chainStatus').textContent=grant.active?'链上有效':'等待激活';$('activate').hidden=ready;$('rotate').hidden=!ready;$('chainError').hidden=!state.chain_error;$('chainError').textContent=state.chain_error||'';$('walletBalance').textContent=state.wallet?'钱包余额 '+units(state.wallet.token_balance_units,decimals)+' '+symbol:'钱包余额 --';renderActivation();renderHistory();renderShare();renderBudget()}
|
|
4054
|
+
function renderReadiness(){
|
|
4055
|
+
const set=(id,ready,yes,no)=>{const node=$(id);node.className='status'+(ready?' ok':'');node.textContent=ready?yes:no};
|
|
4056
|
+
set('paidReadiness',state.paid_ready===true,'可调用','未就绪');
|
|
4057
|
+
set('walletReadiness',state.wallet_ready===true,'已验证','待验证');
|
|
4058
|
+
set('networkReadiness',state.network_ready===true,'已连接','不可用');
|
|
4059
|
+
set('budgetReadinessBadge',state.budget_ready===true,'可用','不可用');
|
|
4060
|
+
set('modelsReadiness',state.models_ready===true,'有可用线路','无可用线路');
|
|
4061
|
+
$('readinessAction').textContent=state.paid_ready===true?'钱包、网络、预算和模型线路均已就绪。':state.inference_code==='payment_key_not_ready'?'请先激活并验证当前钱包的付款 Key。':state.inference_code==='network_unavailable'?'请检查网络与 RPC 配置后刷新。':state.inference_code==='budget_not_started'?'固定预算尚未到启用时间。':state.inference_code==='budget_unavailable'?'当前没有覆盖可用 Provider 线路的固定预算;充值余额不等于可调用预算。':'当前没有可用模型或 Relay 线路。';
|
|
4062
|
+
}
|
|
4063
|
+
function renderActivation(){
|
|
4064
|
+
const grant=state.key.grant||{},ownedActive=grant.active===true&&grant.owner===state.auth?.wallet&&!state.key.pending;
|
|
4065
|
+
const exact=ownedActive&&grant.max_per_request===state.key.max_fee_units&&grant.valid_until===0;
|
|
4066
|
+
$('activate').textContent=ownedActive?'启用本地访问':'激活 Key';
|
|
4067
|
+
$('setupAccess').textContent=ownedActive?'启用本地访问':'启用 API 访问';
|
|
4068
|
+
$('activationNotice').textContent=exact?'链上授权已生效。启用此设备的本地访问后即可显示 API 凭证;可调用额度以账户预算为准。':ownedActive?'链上 Key 已生效,启用时将同步访问权限设置。':'首次使用需在钱包中确认一次访问授权。';
|
|
4069
|
+
}
|
|
4070
|
+
function renderBudget(){
|
|
4071
|
+
const panel=$('budgetPanel');panel.hidden=state.protocol_version!==10;if(panel.hidden)return;
|
|
4072
|
+
const decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'TestUSDC',now=Math.floor(Date.now()/1000);
|
|
4073
|
+
$('budgetLocked').textContent='可用 '+units(state.budget_available_units||0,decimals)+' '+symbol+' · 预算池 '+units(state.budget_locked_units||0,decimals)+' '+symbol;
|
|
4074
|
+
$('budgetNote').textContent=state.budget_note||'预算在结算窗口结束前不会撤回;结算按 2 小时或 100 笔先到触发。';
|
|
4075
|
+
const ownAccount=state.account&&state.account.owner===state.auth?.wallet;
|
|
4076
|
+
$('budgetUnallocated').textContent='已充值、尚未分配预算:'+(ownAccount?units(state.account.available_balance_units,decimals)+' '+symbol:'--');
|
|
4077
|
+
$('budgetReadiness').textContent=state.inference_ready?'预算与线路就绪 · 满 2 小时或 100 笔批量结算':state.inference_code==='budget_not_started'?'预算等待启用 · '+new Date(state.budget_available_at*1000).toLocaleString()+';启用前不会派发或扣费':state.inference_code==='budget_unavailable'?'当前没有可用固定预算,无法派发请求;不会扣费。请联系运营方续期开通预算后刷新本页':(state.inference_error||'正在确认预算和可用线路');
|
|
4078
|
+
const list=$('budgetChannels');list.replaceChildren();
|
|
4079
|
+
const channels=state.capacity_channels||[];
|
|
4080
|
+
if(!channels.length){list.textContent='尚未开通固定预算。受控测试期间,由运营方与 Provider 确认预算后开通。';return}
|
|
4081
|
+
for(const c of channels){
|
|
4082
|
+
const box=document.createElement('div');box.className='band';
|
|
4083
|
+
const title=document.createElement('strong');title.textContent='Provider '+short(c.provider_signer);box.appendChild(title);
|
|
4084
|
+
const info=document.createElement('p');const budget=c.budget||{};
|
|
4085
|
+
const phase=({closed:'已释放',releasable:'可释放余额',previous_key:'此前 Key 的预算',different_owner:'属于其他钱包',admission_closed:'已停止新请求,等待结算',admission_window_short:'剩余接单时间不足',claim_window_short:'剩余结算时间不足',request_limit:'单次请求上限超过预算限制',capacity_exhausted:'请求额度已用完',credit_insufficient:'预算余额不足',stake_insufficient:'Provider 线路暂不可用',not_started:'等待启用,约 '+Math.max(0,Math.ceil((c.valid_from-now)/60))+' 分钟',available:'预算可用'})[budget.reason]||'预算待核验';
|
|
4086
|
+
info.textContent=phase+' · 当前可用 '+units(budget.remaining_units||0,decimals)+' '+symbol+(budget.ready?' · 预计还可请求 '+budget.estimated_requests+' 次':'')+' · 最早释放 '+new Date(c.claim_until*1000).toLocaleString();box.appendChild(info);
|
|
4087
|
+
if(!c.closed&&now>c.claim_until){const button=document.createElement('button');button.className='button';button.textContent='释放剩余预算';button.onclick=()=>run(async()=>{const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'close_capacity_channel',wallet,channel_id:c.channel_id})});await sendPlan(plan);await load();toast('预算释放交易已确认')});box.appendChild(button)}
|
|
4088
|
+
list.appendChild(box)
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
function renderHistory(){
|
|
4092
|
+
const body=$('history'),items=state.history||[],decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';
|
|
4093
|
+
body.replaceChildren();$('historyEmpty').hidden=items.length>0;$('historyTable').hidden=items.length===0;
|
|
4094
|
+
$('spent').previousElementSibling.textContent='已结算消费';$('spent').textContent=units(state.usage.settled_units||0,decimals)+' '+symbol;
|
|
4095
|
+
const sync=state.history_sync||{},recoverySummary=state.recovery||{},summary='已结算 '+units(state.usage.settled_units||0,decimals)+' · 已知待结算 '+units(state.usage.pending_units||0,decimals)+' · 结算失败 '+units(state.usage.failed_units||0,decimals)+' '+symbol+(state.usage.unknown_fee_count?' · 费用待核实 '+state.usage.unknown_fee_count+' 笔,授权上限 '+units(state.usage.unknown_fee_authorized_maximum_units,decimals)+' '+symbol:'')+(recoverySummary.uncertain_count?' · 待核验 '+recoverySummary.uncertain_count+' 笔':'');
|
|
4096
|
+
$('historySync').hidden=false;$('historySync').textContent=summary+(sync.error?' — '+sync.error:(sync.running?' — 正在同步结算状态…':''));
|
|
4097
|
+
const unknown=items.filter(item=>['dispatching','outcome_unknown','broadcast_unknown'].includes(item.status)||['broadcast_unknown','confirmation_timeout'].includes(item.error_code));
|
|
4098
|
+
const recovery=$('historyRecovery');recovery.hidden=unknown.length===0&&!sync.error;
|
|
4099
|
+
if(unknown.length){const ids=unknown.slice(0,3).map(item=>short(item.request_id)).join('、');$('historyRecoveryText').textContent='有 '+unknown.length+' 笔请求的执行或付款状态尚未核实('+ids+(unknown.length>3?' 等':'')+')。请勿重复提交相同请求;先核验状态,系统只会在链上或 Relay 返回可验证结果后更新账单。'}
|
|
4100
|
+
else if(sync.error)$('historyRecoveryText').textContent='账单状态暂时无法核验,原记录已保留。请重试“立即核验”;在状态明确前不要重复提交相同请求。';
|
|
4101
|
+
for(const item of items){
|
|
4102
|
+
const row=document.createElement('tr'),status=({dispatching:'已发送,结果待核实',outcome_unknown:'结果待核实,请勿重复提交',not_dispatched:'未执行',escrowed:'已托管,争议期内',disputed:'裁决中',released:'已释放',refunded:'已退回余额',dismissed:'举报驳回,已释放',timed_out:'裁决超时,已释放'})[item.status]||(item.status==='confirmed'?'已结算':item.status==='failed'?(item.error_code==='authorization_expired'?'授权过期,未结算':'结算失败'):item.status==='rejected'?'未接受':(item.status==='broadcast_unknown'||item.error_code==='broadcast_unknown'||item.error_code==='confirmation_timeout')?'结果待核实':item.status==='submitted'?'已提交,待确认':'待结算');
|
|
4103
|
+
for(const value of [new Date(item.timestamp*1000).toLocaleString(),item.model||'--',(item.input_tokens??'--')+' / '+(item.output_tokens??'--'),item.actual_fee_units==null?'--':units(item.actual_fee_units,decimals),short(item.provider_signer||item.provider),(item.content_verification==='failed'?'正文核验失败;':'')+status,short(item.session_id)]){
|
|
4104
|
+
const cell=document.createElement('td');cell.textContent=value;row.appendChild(cell)
|
|
4105
|
+
}
|
|
4106
|
+
const dispute=document.createElement('td');
|
|
4107
|
+
if(item.jury_evidence_hash){
|
|
4108
|
+
const evidence=document.createElement('div');evidence.className='mono';evidence.textContent=short(item.jury_evidence_hash);evidence.title=item.jury_evidence_hash;dispute.appendChild(evidence);
|
|
4109
|
+
const bond=item.dispute_bond_units||state.settlement?.reporter_bond_units;
|
|
4110
|
+
const deadline=item.settlement_release_at?new Date(item.settlement_release_at*1000).toLocaleString():'待链上核验';
|
|
4111
|
+
const detail=document.createElement('div');detail.textContent=(bond?'保证金 '+units(bond,decimals)+' '+symbol+' · ':'')+'截止 '+deadline;dispute.appendChild(detail);
|
|
4112
|
+
if(item.dispute_explorer_url){const link=document.createElement('a');link.href=item.dispute_explorer_url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='查看交易';dispute.appendChild(link)}
|
|
4113
|
+
if(item.dispute_eligible){const button=document.createElement('button');button.className='button small danger';button.type='button';button.textContent=item.dispute_stage==='failed'?'重试申诉':'发起申诉';button.onclick=()=>run(()=>openDispute(item));dispute.appendChild(button)}
|
|
4114
|
+
else if(item.dispute_stage){const phase=({planned:'已生成计划',wallet_prompted:'等待钱包结果',submitted:'已提交',uncertain:'结果待核实,请勿重试',failed:'未提交或失败',confirmed:'链上已进入裁决'})[item.dispute_stage]||item.dispute_stage;const stage=document.createElement('div');stage.textContent=phase;dispute.appendChild(stage)}
|
|
4115
|
+
}else dispute.textContent='--';
|
|
4116
|
+
row.appendChild(dispute);
|
|
4117
|
+
body.appendChild(row)
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
1623
4120
|
function renderShare(){const share=state.share||{};$('shareStatus').className='status'+(share.active?' ok':'');$('shareStatus').textContent=share.active?'分享中':'未启用';$('shareOutput').hidden=!share.active;if(share.active){$('shareUrl').textContent=share.base_url;$('shareKey').textContent=share.api_key;$('shareExpiry').textContent='到期时间 '+new Date(share.expires_at*1000).toLocaleString()}}
|
|
4121
|
+
function renderModels(items){const list=$('modelList'),status=$('modelStatus'),error=$('modelError');list.replaceChildren();error.hidden=true;if(!Array.isArray(items)||!items.length){status.className='status';status.textContent='暂无可用模型';list.innerHTML='<div><dt>模型目录</dt><dd>暂无通过校验的 Relay 路由</dd></div>';return}const single=items.filter(item=>Number(item.route_count||0)<2);status.className='status'+(single.length?'':' ok');status.textContent=single.length?(items.length+' 个模型 · '+single.length+' 个单 Relay'):(items.length+' 个模型 · 已冗余');for(const item of items){const row=document.createElement('div'),name=document.createElement('dt'),value=document.createElement('dd');name.textContent=String(item.id||'未知模型');value.textContent=Number(item.route_count||0)>1?('双 Relay · '+item.route_count+' 条路由'):'单 Relay · 无自动故障切换';row.append(name,value);list.appendChild(row)}}
|
|
4122
|
+
async function loadModels(){try{const response=await fetch('/v1/models',{cache:'no-store'}),data=await response.json();if(!response.ok)throw new Error(data?.error?.message||'模型目录暂时不可用');renderModels(data.data)}catch(error){$('modelStatus').className='status';$('modelStatus').textContent='读取失败';$('modelError').hidden=false;$('modelError').textContent=error?.message||'模型目录暂时不可用'}}
|
|
4123
|
+
async function syncHistoryNow(){await api('/v1/mycomesh/local/history/sync',{method:'POST'});await load();const sync=state.history_sync||{};toast(sync.error?'暂时无法完成状态核验,原记录已保留':'已完成状态核验')}
|
|
1624
4124
|
function walletMessage(value){return '0x'+Array.from(new TextEncoder().encode(value),byte=>byte.toString(16).padStart(2,'0')).join('')}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
async function
|
|
4125
|
+
function walletProvider(){return window.okxwallet?.ethereum||window.okxwallet||window.ethereum?.providers?.find(p=>p.isOkxWallet||p.isOKExWallet)||window.ethereum}
|
|
4126
|
+
function ownerMismatchMessage(value){return '当前选择的钱包:'+value.selected_wallet+'。本机付款 Key('+value.payment_key_address+')已绑定钱包:'+value.expected_owner+'。请在钱包扩展中切换到该归属钱包后重试;若要使用当前钱包,请另建独立 Consumer 配置目录。不要删除或更换现有 Key,原账单和预算仍属于原钱包。'}
|
|
4127
|
+
function renderLoginOwner(){const owner=state?.key?.grant?.owner,hint=$('loginOwnerHint');hint.textContent=!owner?'暂时无法核验本机付款 Key 的归属,请稍后重试连接。':owner.toLowerCase()==='0x'+'0'.repeat(40)?'本机付款 Key 尚未绑定钱包,可连接你的钱包完成登录,再激活访问。':'本机付款 Key 已绑定钱包:'+owner+'。请在钱包扩展中选择该账户。若要使用其他钱包,请另建独立 Consumer 配置目录,保留原 Key 和账单。'}
|
|
4128
|
+
async function login(){
|
|
4129
|
+
$('loginError').hidden=true;
|
|
4130
|
+
const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');
|
|
4131
|
+
await load();
|
|
4132
|
+
const owner=state?.key?.grant?.owner?.toLowerCase();
|
|
4133
|
+
if(!owner)throw new Error('暂时无法核验本机付款 Key 的链上归属,请稍后重试;本次尚未请求签名。');
|
|
4134
|
+
const accounts=await provider.request({method:'eth_requestAccounts'}),selectedWallet=String(accounts[0]||'').toLowerCase();
|
|
4135
|
+
if(!selectedWallet)throw new Error('钱包未连接');
|
|
4136
|
+
if(owner!=='0x'+'0'.repeat(40)&&owner!==selectedWallet)throw Object.assign(new Error(ownerMismatchMessage({selected_wallet:selectedWallet,expected_owner:owner,payment_key_address:state.key.address})),{code:'payment_key_owner_mismatch'});
|
|
4137
|
+
const challenge=await api('/v1/mycomesh/local/wallet/challenge',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet:selectedWallet})});
|
|
4138
|
+
const signature=await provider.request({method:'personal_sign',params:[walletMessage(challenge.message),selectedWallet]});
|
|
4139
|
+
const result=await api('/v1/mycomesh/local/wallet/authenticate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet:selectedWallet,signature})});
|
|
4140
|
+
managementToken=result.token;wallet=selectedWallet;await load();toast(state.auth.key_ready?'钱包验证完成':'首次使用:点击“启用 API 访问”,并在钱包中确认');
|
|
4141
|
+
}
|
|
4142
|
+
async function ensureChain(){const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');const expected='0x'+Number(state.settlement.chain_id).toString(16),current=await provider.request({method:'eth_chainId'});if(current.toLowerCase()!==expected.toLowerCase())await provider.request({method:'wallet_switchEthereumChain',params:[{chainId:expected}]})}
|
|
4143
|
+
async function waitReceipt(hash){const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');for(let count=0;count<120;count++){const receipt=await provider.request({method:'eth_getTransactionReceipt',params:[hash]});if(receipt){if(receipt.status!=='0x1')throw Object.assign(new Error('链上交易失败'),{code:'transaction_failed'});return receipt}await new Promise(resolve=>setTimeout(resolve,1500))}throw Object.assign(new Error('等待链上确认超时'),{code:'confirmation_timeout'})}
|
|
4144
|
+
async function sendPlan(plan){if(Array.isArray(plan.transactions)&&plan.transactions.length===0)return;const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');await ensureChain();for(const transaction of plan.transactions){toast(transaction.label+':请在钱包中确认');const hash=await provider.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]});toast(transaction.label+':交易已提交,等待链上确认…');await waitReceipt(hash);toast(transaction.label+':已确认')}}
|
|
4145
|
+
async function disputeProgress(item,stage,kind,hash,errorCode){return api('/v1/mycomesh/local/disputes/progress',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet,request_id:item.request_id,channel_id:item.capacity_channel_id,stage,transaction_kind:kind,...(hash?{tx_hash:hash}:{}),...(errorCode?{error_code:errorCode}:{})})})}
|
|
4146
|
+
async function openDispute(item){
|
|
4147
|
+
const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'open_dispute',wallet,request_id:item.request_id,channel_id:item.capacity_channel_id})});
|
|
4148
|
+
const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包');await ensureChain();
|
|
4149
|
+
for(const transaction of plan.transactions){
|
|
4150
|
+
const kind=transaction.kind;await disputeProgress(item,'wallet_prompted',kind);let hash;
|
|
4151
|
+
try{hash=await provider.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]})}
|
|
4152
|
+
catch(error){await disputeProgress(item,error?.code===4001?'failed':'uncertain',kind,null,error?.code===4001?'wallet_rejected':'wallet_unknown');throw error}
|
|
4153
|
+
await disputeProgress(item,'submitted',kind,hash);toast(transaction.label+':交易已提交');
|
|
4154
|
+
try{await waitReceipt(hash)}catch(error){await disputeProgress(item,error?.code==='transaction_failed'?'failed':'uncertain',kind,hash,error?.code||'confirmation_timeout');throw error}
|
|
4155
|
+
}
|
|
4156
|
+
await api('/v1/mycomesh/local/history/sync',{method:'POST'});await load();toast('申诉交易已确认并进入链上裁决');
|
|
4157
|
+
}
|
|
1629
4158
|
async function activateCurrent(){const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(plan);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/wallet/activate',{method:'POST'});await load();toast('Key 已激活');return}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}}
|
|
1630
|
-
async function rotateKey(){const oldAddress=state.key.address;await api('/v1/mycomesh/local/key/prepare',{method:'POST'});const register=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(register);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});break}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}const revoke=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'revoke_key',wallet,key_address:oldAddress})});await sendPlan(revoke);await load();toast('新 Key 已启用,旧 Key 已撤销')}
|
|
1631
|
-
document.querySelectorAll('.tab').forEach(tab=>tab.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(item=>item.classList.toggle('active',item===tab));document.querySelectorAll('.view').forEach(view=>view.hidden=view.id!=='view-'+tab.dataset.view)}));
|
|
4159
|
+
async function rotateKey(){const oldAddress=state.key.address;await api('/v1/mycomesh/local/key/prepare',{method:'POST'});const register=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(register);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});break}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}const revoke=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'revoke_key',wallet,key_address:oldAddress})});await sendPlan(revoke);await load();toast(state.protocol_version===10?'新 Key 已启用;旧通道预算到期后才能释放,新 Key 需另开预算':'新 Key 已启用,旧 Key 已撤销')}
|
|
4160
|
+
document.querySelectorAll('.tab').forEach(tab=>tab.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(item=>item.classList.toggle('active',item===tab));document.querySelectorAll('.view').forEach(view=>view.hidden=view.id!=='view-'+tab.dataset.view);if(tab.dataset.view==='activity'&&managementToken&&!busy)load().catch(error=>toast(error.message,true))}));
|
|
4161
|
+
setInterval(()=>{if(managementToken&&!busy&&!document.hidden)load().catch(()=>{})},15000);
|
|
4162
|
+
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&managementToken&&!busy)load().catch(()=>{})});
|
|
1632
4163
|
document.addEventListener('click',event=>{const button=event.target.closest('.copy');if(button)navigator.clipboard.writeText($(button.dataset.copy).textContent).then(()=>toast('已复制')).catch(()=>toast('复制失败',true))});
|
|
1633
|
-
$('login').onclick=()=>run(login);$('activate').onclick=()=>run(activateCurrent);$('rotate').onclick=()=>run(rotateKey);$('refresh').onclick=()=>run(load);
|
|
1634
|
-
|
|
4164
|
+
$('setupAccess').onclick=()=>run(activateCurrent);$('login').onclick=()=>run(login);$('activate').onclick=()=>run(activateCurrent);$('rotate').onclick=()=>run(rotateKey);$('refresh').onclick=()=>run(load);$('historyRecoveryButton').onclick=()=>run(syncHistoryNow);
|
|
4165
|
+
function readTopupAmount(){const value=$('amount').value.trim();if(!/^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(value)||Number(value)<=0)throw new Error('请输入大于 0 且最多 6 位小数的 USDC 金额');return value}
|
|
4166
|
+
$('topup').onclick=()=>run(async()=>{const amount=readTopupAmount();const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'top_up',wallet,amount_usdc:amount})});await sendPlan(plan);$('amount').value='';await load();toast('充值已确认')});
|
|
1635
4167
|
$('shareStart').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({minutes:Number($('shareMinutes').value)})});state.share=value.share;renderShare();toast('临时分享已开启')});
|
|
1636
4168
|
$('shareStop').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/stop',{method:'POST'});state.share=value.share;renderShare();toast('临时分享已停止')});
|
|
1637
4169
|
$('walletButton').onclick=()=>run(async()=>{await api('/v1/mycomesh/local/wallet/lock',{method:'POST'});managementToken=null;wallet=null;await load()});
|
|
1638
|
-
|
|
4170
|
+
walletProvider()?.on?.('accountsChanged',()=>{if(managementToken)api('/v1/mycomesh/local/wallet/lock',{method:'POST'}).catch(()=>{}).finally(()=>{managementToken=null;wallet=null;load().catch(error=>toast(error.message,true))})});
|
|
1639
4171
|
load().catch(error=>{$('loginError').hidden=false;$('loginError').textContent=error.message});
|
|
4172
|
+
loadModels();
|
|
1640
4173
|
</script></body></html>`;
|
|
1641
4174
|
}
|
|
1642
4175
|
|