mycomesh-consumer 0.1.50 → 0.1.52

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.
@@ -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
- export const DEFAULT_MODEL = "mycomesh-codex-standard-v1";
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,11 +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 = 30_000;
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;
136
+ const WALLET_CHALLENGE_TTL_MS = 5 * 60_000;
91
137
 
92
138
  const DEFAULT_NETWORK = Object.freeze({
139
+ protocol_version: 8,
93
140
  chain_id: DEFAULT_CHAIN_ID,
94
141
  network_name: "Sepolia testnet",
95
142
  settlement_contract: DEFAULT_SETTLEMENT,
@@ -194,6 +241,228 @@ function stableStringify(value) {
194
241
  throw new Error("request must contain canonical JSON data");
195
242
  }
196
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
+
197
466
  function abiWord(value) {
198
467
  if (typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value)) {
199
468
  return Buffer.concat([Buffer.alloc(12), Buffer.from(value.slice(2), "hex")]);
@@ -212,6 +481,30 @@ function abiWord(value) {
212
481
  return output;
213
482
  }
214
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
+
215
508
  function hashText(value) {
216
509
  return Uint8Array.from(keccak_256(Buffer.from(value, "utf8")));
217
510
  }
@@ -275,12 +568,29 @@ function recoverAddress(digest, signatureValue) {
275
568
  return `0x${bytesToHex(keccak_256(publicKey).slice(-20))}`;
276
569
  }
277
570
 
278
- function typedDigest(structHash, chainId, contract) {
571
+ export function walletMessageDigest(message) {
572
+ const body = Buffer.from(String(message), "utf8");
573
+ const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${body.length}`, "utf8");
574
+ return Uint8Array.from(keccak_256(Buffer.concat([prefix, body])));
575
+ }
576
+
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) {
279
589
  const domain = keccak_256(
280
590
  Buffer.concat([
281
591
  Buffer.from(hashText(DOMAIN_TYPE)),
282
592
  Buffer.from(hashText("MycoMesh Settlement")),
283
- Buffer.from(hashText("8")),
593
+ Buffer.from(hashText(String(protocolVersion(version)))),
284
594
  abiWord(chainId),
285
595
  abiWord(normalizeAddress(contract)),
286
596
  ]),
@@ -412,6 +722,7 @@ function normalizeInferenceOptions(endpoint, options) {
412
722
  }
413
723
 
414
724
  export function buildAuthorization({
725
+ protocolVersion: requestedVersion = 8,
415
726
  paymentKey,
416
727
  chainId,
417
728
  settlementContract,
@@ -425,7 +736,9 @@ export function buildAuthorization({
425
736
  maxFee,
426
737
  issuedAt = Math.floor(Date.now() / 1000),
427
738
  deadline = issuedAt + 900,
739
+ maxAuthorizationTtlSeconds = MAX_AUTHORIZATION_TTL,
428
740
  }) {
741
+ const version = protocolVersion(requestedVersion);
429
742
  const key = paymentKeyAddress(paymentKey);
430
743
  const authorization = {
431
744
  request_id: normalizeBytes32(requestId, "request_id"),
@@ -446,13 +759,14 @@ export function buildAuthorization({
446
759
  if (authorization.pricing_hash === `0x${"0".repeat(64)}`) throw new Error("V8 pricing_hash cannot be zero");
447
760
  const issued = BigInt(authorization.issued_at);
448
761
  const expires = BigInt(authorization.deadline);
449
- if (expires <= issued || expires - issued > BigInt(MAX_AUTHORIZATION_TTL)) {
450
- throw new Error("V8 authorization lifetime must be between 1 and 3600 seconds");
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`);
451
765
  }
452
766
  const authorizationHash = authorizationStructHash(authorization);
453
- const digest = typedDigest(authorizationHash, chainId, settlementContract);
767
+ const digest = typedDigest(authorizationHash, chainId, settlementContract, version);
454
768
  return {
455
- schema: AUTH_SCHEMA,
769
+ schema: version === 9 ? V9_AUTH_SCHEMA : AUTH_SCHEMA,
456
770
  chain_id: jsonInteger(positiveBigInt(chainId, "chain_id")),
457
771
  settlement_contract: nonzeroAddress(settlementContract, "settlement_contract"),
458
772
  authorization,
@@ -463,9 +777,10 @@ export function buildAuthorization({
463
777
  }
464
778
 
465
779
  function verifyAuthorization(value, expected = {}) {
466
- if (!value || value.schema !== AUTH_SCHEMA || !value.authorization) {
467
- throw new Error("unsupported V8 payment authorization");
468
- }
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");
469
784
  const authorization = value.authorization;
470
785
  const chainId = uintBigInt(value.chain_id, "chain_id");
471
786
  const contract = nonzeroAddress(value.settlement_contract, "settlement_contract");
@@ -492,18 +807,25 @@ function verifyAuthorization(value, expected = {}) {
492
807
  const issued = BigInt(normalized.issued_at);
493
808
  const deadline = BigInt(normalized.deadline);
494
809
  if (issued > BigInt(now + 30) || deadline < BigInt(now)) throw new Error("V8 payment authorization is outside its time window");
495
- if (deadline <= issued || deadline - issued > BigInt(MAX_AUTHORIZATION_TTL)) throw new Error("V8 payment authorization lifetime is invalid");
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`);
496
811
  const structHash = authorizationStructHash(normalized);
497
812
  if (normalizeBytes32(value.authorization_hash, "authorization_hash") !== structHash) throw new Error("V8 authorization hash mismatch");
498
- const digest = typedDigest(structHash, chainId, contract);
813
+ const digest = typedDigest(structHash, chainId, contract, version);
499
814
  if (normalizeBytes32(value.authorization_digest, "authorization_digest") !== `0x${bytesToHex(digest)}`) throw new Error("V8 authorization digest mismatch");
500
815
  if (recoverAddress(digest, value.key_signature) !== normalized.key) throw new Error("V8 payment key signature mismatch");
501
816
  return { ...value, chain_id: jsonInteger(chainId), settlement_contract: contract, authorization: normalized };
502
817
  }
503
818
 
504
- function verifySignedReceipt(value) {
505
- if (!value || value.schema !== SIGNED_SCHEMA) throw new Error("unsupported signed V8 receipt");
506
- const authorization = verifyAuthorization(value.authorization);
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
+ }
507
829
  const receipt = value.receipt;
508
830
  if (!receipt || typeof receipt !== "object") throw new Error("V8 signed receipt is missing");
509
831
  const normalizedReceipt = {
@@ -520,25 +842,84 @@ function verifySignedReceipt(value) {
520
842
  if (normalizedReceipt.authorization_hash !== authorization.authorization_hash) throw new Error("V8 signed receipt authorization mismatch");
521
843
  if (normalizedReceipt.relay !== authorization.authorization.relay) throw new Error("V8 signed receipt Relay payout mismatch");
522
844
  if (value.key_signature !== authorization.key_signature) throw new Error("V8 signed receipt payment signature mismatch");
523
- const digest = typedDigest(receiptStructHash(normalizedReceipt), authorization.chain_id, authorization.settlement_contract);
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);
524
847
  if (recoverAddress(digest, value.provider_signature) !== normalizedReceipt.provider_signer) throw new Error("V8 Provider signature mismatch");
525
848
  if (recoverAddress(digest, value.relay_signature) !== authorization.authorization.relay_signer) throw new Error("V8 Relay signature mismatch");
526
849
  return { authorization, receipt: normalizedReceipt };
527
850
  }
528
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
+
529
880
  async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
530
881
  const controller = new AbortController();
531
- const timer = setTimeout(() => controller.abort(), timeoutMs);
882
+ const timer = setTimeout(() => controller.abort(deadlineError()), timeoutMs);
532
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); };
533
889
  try {
534
- return await fetch(url, { redirect: "error", ...options, signal: controller.signal });
535
- } finally {
536
- clearTimeout(timer);
537
- }
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; }
538
895
  }
539
896
 
540
897
  async function readJsonResponse(response) {
541
- const text = await response.text();
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); }
542
923
  try {
543
924
  return text ? JSON.parse(text) : {};
544
925
  } catch {
@@ -550,16 +931,112 @@ function openaiError(message, type = "server_error", code = type) {
550
931
  return { error: { message: String(message), type, param: null, code } };
551
932
  }
552
933
 
553
- function parseNetworkConfig(path) {
554
- if (!path || !existsSync(path)) return { ...DEFAULT_NETWORK };
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");
555
937
  try {
556
938
  const network = JSON.parse(readFileSync(path, "utf8"));
557
939
  const deploymentPath = network.deployment ? join(dirname(path), network.deployment) : path;
558
- const deployment = network.deployment && existsSync(deploymentPath)
559
- ? JSON.parse(readFileSync(deploymentPath, "utf8"))
560
- : network;
561
- if (Number(deployment.protocol_version || 0) !== 8) return { ...DEFAULT_NETWORK };
562
- return {
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,
563
1040
  chain_id: Number(deployment.chain_id),
564
1041
  network_name: Number(deployment.chain_id) === DEFAULT_CHAIN_ID ? "Sepolia testnet" : "EVM network",
565
1042
  settlement_contract: normalizeAddress(deployment.settlement),
@@ -569,11 +1046,168 @@ function parseNetworkConfig(path) {
569
1046
  rpc_urls: (network.settlement_rpc_urls || [network.settlement_rpc_url]).filter(Boolean),
570
1047
  explorer_url: Number(deployment.chain_id) === DEFAULT_CHAIN_ID ? "https://sepolia.etherscan.io" : "",
571
1048
  };
572
- } catch {
573
- return { ...DEFAULT_NETWORK };
1049
+ parsed.relay_discovery = parseDiscoveryConfig(network, parsed);
1050
+ return parsed;
1051
+ } catch (error) {
1052
+ throw new Error(`Invalid configured settlement network: ${error.message}`);
574
1053
  }
575
1054
  }
576
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
+
577
1211
  function defaultDataDir() {
578
1212
  return join(homedir(), ".mycomesh", "consumer");
579
1213
  }
@@ -598,6 +1232,24 @@ function parseRelayUrls(env, explicit) {
598
1232
  return urls;
599
1233
  }
600
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
+
601
1253
  function sameSecret(first, second) {
602
1254
  const a = Buffer.from(String(first || ""));
603
1255
  const b = Buffer.from(String(second || ""));
@@ -611,26 +1263,86 @@ export class NativeConsumerState {
611
1263
  mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
612
1264
  try { chmodSync(this.dataDir, 0o700); } catch {}
613
1265
  this.baseUrl = resolveBaseUrl(options.baseUrl || env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL || DEFAULT_BASE_URL);
614
- this.relayUrls = parseRelayUrls(env, options.relayUrls);
615
1266
  this.maxFeeUnits = options.maxFeeUnits || Number(env.MYCOMESH_V8_MAX_FEE_UNITS || DEFAULT_MAX_FEE_UNITS);
616
1267
  if (!Number.isSafeInteger(this.maxFeeUnits) || this.maxFeeUnits <= 0) throw new Error("max fee must be a positive integer");
617
1268
  this.timeoutMs = options.timeoutMs ?? Number(env.MYCOMESH_V8_REQUEST_TIMEOUT_SECONDS || 300) * 1000;
618
1269
  this.healthTimeoutMs = options.healthTimeoutMs ?? Number(env.MYCOMESH_V8_HEALTH_TIMEOUT_SECONDS || 10) * 1000;
619
- 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];
620
1274
  if (env.MYCOMESH_CONSUMER_SETTLEMENT_RPC_URLS) {
621
1275
  this.network.rpc_urls = String(env.MYCOMESH_CONSUMER_SETTLEMENT_RPC_URLS).split(",").map((item) => item.trim()).filter(Boolean);
622
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
+ }
623
1296
  this.proxy = options.proxy || env.MYCOMESH_CONSUMER_PROXY || "";
624
- this.dispatcher = this.proxy ? new ProxyAgent(this.proxy) : undefined;
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();
625
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;
626
1323
  this.historyPath = join(this.dataDir, "receipt-history.jsonl");
627
1324
  this.pendingKeyPath = join(this.dataDir, "pending-payment-key");
628
1325
  this.paymentKeyFromEnv = Boolean(String(env.MYCOMESH_V8_PAYMENT_KEY || "").trim());
629
1326
  this.paymentKey = this.loadPaymentKey(env.MYCOMESH_V8_PAYMENT_KEY);
630
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;
631
1339
  this.tunnelCommand = options.tunnelCommand || env.MYCOMESH_CONSUMER_TUNNEL_COMMAND || "cloudflared";
632
1340
  this.tunnelSpawn = options.tunnelSpawn || defaultSpawn;
633
1341
  this.share = null;
1342
+ this.walletChallenge = null;
1343
+ this.managementToken = null;
1344
+ this.unlockedWallet = null;
1345
+ this.paymentUnlocked = false;
634
1346
  }
635
1347
 
636
1348
  loadPaymentKey(configured) {
@@ -655,78 +1367,294 @@ export class NativeConsumerState {
655
1367
  return `export OPENAI_BASE_URL=${shellQuote(this.baseUrl)}\nexport OPENAI_API_KEY=${shellQuote(this.paymentKey)}`;
656
1368
  }
657
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
+
658
1377
  healthPayload() {
1378
+ this.syncDiscoveredRelays();
659
1379
  return {
660
1380
  ok: true,
661
- protocol: "mycomesh-consumer/v8",
1381
+ protocol: `mycomesh-consumer/v${this.network.protocol_version}`,
662
1382
  runtime: "node-native",
663
1383
  docker: false,
664
1384
  browser_app_ready: true,
665
1385
  gateway_dependency: false,
666
- routing_mode: "relay-scheduled-payment-key-v8",
1386
+ routing_mode: `relay-scheduled-payment-key-v${this.network.protocol_version}`,
667
1387
  relay_urls: this.relayUrls,
1388
+ relay_discovery: this.relayDiscovery ? { enabled: true, active: this.relayDiscovery.active().length,
1389
+ error: this.relayDiscovery.lastError } : { enabled: false },
668
1390
  payment_key_address: this.paymentAddress,
669
1391
  payment_key_persisted: !this.paymentKeyFromEnv,
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) },
670
1396
  responses_transports: ["http", "sse"],
671
1397
  };
672
1398
  }
673
1399
 
1400
+ walletAuthPayload() {
1401
+ return {
1402
+ authenticated: Boolean(this.managementToken && this.unlockedWallet),
1403
+ wallet: this.unlockedWallet,
1404
+ key_ready: this.paymentUnlocked,
1405
+ };
1406
+ }
1407
+
1408
+ createWalletChallenge(walletValue) {
1409
+ const wallet = normalizeAddress(walletValue, "wallet");
1410
+ const issuedAt = Date.now();
1411
+ const expiresAt = issuedAt + WALLET_CHALLENGE_TTL_MS;
1412
+ const nonce = randomBytes(24).toString("base64url");
1413
+ const message = [
1414
+ "MycoMesh Consumer wallet login",
1415
+ `Wallet: ${wallet}`,
1416
+ `Payment key: ${this.paymentAddress}`,
1417
+ `Nonce: ${nonce}`,
1418
+ `Issued at: ${new Date(issuedAt).toISOString()}`,
1419
+ "This signature unlocks this local Consumer process only.",
1420
+ ].join("\n");
1421
+ this.walletChallenge = { wallet, message, expiresAt };
1422
+ return { wallet, key_address: this.paymentAddress, message, expires_at: Math.floor(expiresAt / 1000) };
1423
+ }
1424
+
1425
+ async authenticateWallet(raw) {
1426
+ const wallet = normalizeAddress(raw?.wallet, "wallet");
1427
+ const challenge = this.walletChallenge;
1428
+ if (!challenge || challenge.wallet !== wallet || challenge.expiresAt < Date.now()) {
1429
+ this.walletChallenge = null;
1430
+ throw new Error("wallet login challenge is missing or expired");
1431
+ }
1432
+ this.walletChallenge = null;
1433
+ const recovered = recoverAddress(walletMessageDigest(challenge.message), raw?.signature);
1434
+ if (recovered !== wallet) throw new Error("wallet signature does not match the selected account");
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
+ }
1442
+ if (grant.owner !== ZERO_ADDRESS && grant.owner !== wallet) {
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
+ });
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.
1462
+ this.unlockedWallet = wallet;
1463
+ this.managementToken = `myco_local_${randomBytes(32).toString("base64url")}`;
1464
+ this.paymentUnlocked = paymentUnlocked;
1465
+ return { ok: true, token: this.managementToken, auth: this.walletAuthPayload(), grant };
1466
+ }
1467
+
1468
+ authorizeManagement(authorization) {
1469
+ return Boolean(this.managementToken && sameSecret(authorization, `Bearer ${this.managementToken}`));
1470
+ }
1471
+
1472
+ assertUnlockedWallet(walletValue = this.unlockedWallet) {
1473
+ const wallet = normalizeAddress(walletValue, "wallet");
1474
+ if (!this.managementToken || wallet !== this.unlockedWallet) {
1475
+ throw new Error("sign in with the payment-key owner wallet first");
1476
+ }
1477
+ return wallet;
1478
+ }
1479
+
1480
+ async activateCurrentPaymentKey() {
1481
+ const wallet = this.assertUnlockedWallet();
1482
+ const grant = await this.keyGrant(this.paymentAddress);
1483
+ if (!grant.active || grant.owner !== wallet) {
1484
+ throw new Error("the payment key is not active for this wallet on-chain");
1485
+ }
1486
+ this.paymentUnlocked = true;
1487
+ return { ok: true, auth: this.walletAuthPayload(), grant };
1488
+ }
1489
+
1490
+ async lockWallet() {
1491
+ this.walletChallenge = null;
1492
+ this.managementToken = null;
1493
+ this.unlockedWallet = null;
1494
+ this.paymentUnlocked = false;
1495
+ await this.stopShare();
1496
+ return { ok: true, auth: this.walletAuthPayload() };
1497
+ }
1498
+
674
1499
  history(limit = 100) {
675
- if (!existsSync(this.historyPath)) return [];
676
- const lines = readFileSync(this.historyPath, "utf8").split("\n").filter(Boolean);
677
- const selected = limit <= 0 ? lines : lines.slice(-Math.min(Math.max(limit, 1), 500));
678
- return selected.map((line) => {
679
- try { return JSON.parse(line); } catch { return null; }
680
- }).filter((value) => value && typeof value === "object").reverse();
1500
+ return this.historyLedger.history(limit);
1501
+ }
1502
+
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
+ };
681
1542
  }
682
1543
 
683
- recordReceipt(relayUrl, endpoint, model, settlement, routeModel = model) {
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") {
684
1559
  const signed = settlement?.signed_receipt;
685
1560
  const receipt = signed?.receipt;
686
1561
  const auth = signed?.authorization?.authorization;
687
1562
  if (!receipt || !auth) return;
1563
+ const jury = settlement?.jury_evidence_reference;
688
1564
  const entry = {
689
1565
  timestamp: Math.floor(Date.now() / 1000),
690
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),
691
1568
  settlement_key: String(settlement.settlement_key || ""),
692
- status: String(settlement.status || "queued"),
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",
693
1571
  accepted: Boolean(settlement.accepted),
694
1572
  endpoint,
695
1573
  model,
696
1574
  route_model: routeModel,
697
1575
  relay_url: relayUrl,
698
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,
699
1586
  input_tokens: Number(receipt.input_tokens || 0),
700
1587
  output_tokens: Number(receipt.output_tokens || 0),
701
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
+ } : {}),
702
1598
  };
703
- appendFileSync(this.historyPath, `${stableStringify(entry)}\n`, { mode: 0o600 });
704
- try { chmodSync(this.historyPath, 0o600); } catch {}
1599
+ this.historyLedger.append(entry);
705
1600
  }
706
1601
 
707
1602
  async rpcValue(callback) {
708
1603
  const errors = [];
709
- for (const rpcUrl of this.network.rpc_urls) {
710
- try { return await callback(rpcUrl); } catch (error) { errors.push(error.message); }
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;
711
1623
  }
712
- throw new Error(`all configured Settlement V8 RPC endpoints failed: ${errors.join("; ")}`);
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;
713
1628
  }
714
1629
 
715
1630
  async callRpc(rpcUrl, method, params) {
716
- const response = await fetchWithTimeout(rpcUrl, {
717
- method: "POST",
718
- headers: { "content-type": "application/json" },
719
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
720
- dispatcher: this.dispatcher,
721
- }, this.healthTimeoutMs);
722
- const payload = await readJsonResponse(response);
723
- if (!response.ok || payload.error) throw new Error(payload.error?.message || `RPC ${response.status}`);
724
- return payload.result;
725
- }
726
-
727
- async contractCall(rpcUrl, contract, signature, args) {
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") {
728
1656
  const data = `0x${bytesToHex(keccak_256(Buffer.from(signature, "ascii")).slice(0, 4))}${args.map((arg) => abiWord(arg).toString("hex")).join("")}`;
729
- return this.callRpc(rpcUrl, "eth_call", [{ to: normalizeAddress(contract), data }, "latest"]);
1657
+ return this.callRpc(rpcUrl, "eth_call", [{ to: normalizeAddress(contract), data }, blockTag]);
730
1658
  }
731
1659
 
732
1660
  async keyGrant(address) {
@@ -741,51 +1669,461 @@ export class NativeConsumerState {
741
1669
  };
742
1670
  }
743
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
+
744
1822
  async accountBalance(owner) {
745
1823
  const output = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.settlement_contract, "availableBalance(address)", [normalizeAddress(owner)]));
746
1824
  return BigInt(output || "0x0").toString();
747
1825
  }
748
1826
 
749
- async dashboardPayload(wallet) {
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
+
1843
+ async dashboardPayload(managementAuthorized = false) {
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
+ }
750
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)) : [];
1871
+ const pending = authenticated ? this.pendingPaymentKey() : null;
751
1872
  const payload = {
752
1873
  ok: true,
753
- protocol_version: 8,
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,
754
1882
  runtime: "node-native",
755
- credentials: { base_url: this.baseUrl, api_key: this.paymentKey, export: this.credentialsText() },
756
- key: { address: this.paymentAddress, max_fee_units: this.maxFeeUnits, pending: this.pendingPaymentKey() },
1883
+ auth: authenticated ? this.walletAuthPayload() : { authenticated: false, wallet: null, key_ready: false },
1884
+ credentials: authenticated && this.paymentUnlocked
1885
+ ? { base_url: this.baseUrl, api_key: this.paymentKey, export: this.credentialsText() }
1886
+ : null,
1887
+ key: {
1888
+ address: this.paymentAddress,
1889
+ max_fee_units: this.maxFeeUnits,
1890
+ pending: pending ? { payment_key_address: pending.payment_key_address } : null,
1891
+ },
757
1892
  settlement: this.network,
758
- history: allHistory.slice(0, 100),
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,
759
1898
  usage: {
760
- request_count: allHistory.length,
761
- total_spent_units: allHistory.reduce((total, item) => total + Number(item.actual_fee_units || 0), 0),
762
- input_tokens: allHistory.reduce((total, item) => total + Number(item.input_tokens || 0), 0),
763
- output_tokens: allHistory.reduce((total, item) => total + Number(item.output_tokens || 0), 0),
1899
+ request_count: authenticated ? allHistory.length : 0,
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,
1907
+ input_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.input_tokens || 0), 0) : 0,
1908
+ output_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.output_tokens || 0), 0) : 0,
764
1909
  },
765
- share: this.sharePayload(),
1910
+ share: authenticated ? this.sharePayload() : { active: false },
766
1911
  };
767
- try {
768
- const grant = await this.keyGrant(this.paymentAddress);
769
- payload.key.grant = grant;
770
- if (grant.owner !== ZERO_ADDRESS) {
771
- payload.account = { owner: grant.owner, available_balance_units: await this.accountBalance(grant.owner) };
772
- }
773
- } catch (error) {
774
- payload.chain_error = error.message;
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;
775
1931
  }
776
- if (wallet) {
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;
1955
+ }
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
+ }
777
1965
  try {
778
- const address = normalizeAddress(wallet);
779
- const token = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "balanceOf(address)", [address]));
780
- const allowance = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "allowance(address,address)", [address, this.network.settlement_contract]));
781
- payload.wallet = { address, token_balance_units: BigInt(token || "0x0").toString(), allowance_units: BigInt(allowance || "0x0").toString() };
1966
+ const address = this.unlockedWallet;
1967
+ payload.wallet = await this.walletSnapshot(address);
782
1968
  } catch (error) {
783
1969
  payload.wallet_error = error.message;
784
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
+ }
785
2017
  }
786
2018
  return payload;
787
2019
  }
788
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
+
789
2127
  sharePayload() {
790
2128
  const share = this.activeShare();
791
2129
  return share ? {
@@ -805,12 +2143,13 @@ export class NativeConsumerState {
805
2143
  }
806
2144
 
807
2145
  authorizeBearer(authorization, { shareOnly = false } = {}) {
808
- if (!shareOnly && sameSecret(authorization, `Bearer ${this.paymentKey}`)) return true;
2146
+ if (!shareOnly && this.paymentUnlocked && sameSecret(authorization, `Bearer ${this.paymentKey}`)) return true;
809
2147
  const share = this.activeShare();
810
2148
  return Boolean(share && sameSecret(authorization, `Bearer ${share.apiKey}`));
811
2149
  }
812
2150
 
813
2151
  async startShare(minutesValue) {
2152
+ if (!this.paymentUnlocked) throw new Error("sign in and activate the payment key before sharing");
814
2153
  const minutes = Number(minutesValue);
815
2154
  if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_SHARE_MINUTES) {
816
2155
  throw new Error(`share duration must be between 1 and ${MAX_SHARE_MINUTES} minutes`);
@@ -889,11 +2228,14 @@ export class NativeConsumerState {
889
2228
  }
890
2229
 
891
2230
  async activatePendingPaymentKey(wallet) {
892
- const owner = normalizeAddress(wallet);
2231
+ const owner = this.assertUnlockedWallet(wallet);
893
2232
  const pending = this.pendingPaymentKey();
894
2233
  if (!pending) throw new Error("no pending payment key exists");
895
2234
  const grant = await this.keyGrant(pending.payment_key_address);
896
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
+ }
897
2239
  const previous = this.paymentAddress;
898
2240
  const destination = join(this.dataDir, "payment-key");
899
2241
  writeFileSync(destination, `${pending.payment_key}\n`, { mode: 0o600 });
@@ -901,39 +2243,383 @@ export class NativeConsumerState {
901
2243
  unlinkSync(this.pendingKeyPath);
902
2244
  this.paymentKey = pending.payment_key;
903
2245
  this.paymentAddress = pending.payment_key_address;
904
- return { payment_key: this.paymentKey, payment_key_address: this.paymentAddress, previous_key_address: previous };
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;
2251
+ this.paymentUnlocked = true;
2252
+ return { payment_key_address: this.paymentAddress, previous_key_address: previous };
905
2253
  }
906
2254
 
907
2255
  async transactionPlan(raw) {
908
2256
  const action = String(raw?.action || "");
909
- const wallet = normalizeAddress(raw?.wallet, "wallet");
2257
+ const wallet = this.assertUnlockedWallet(raw?.wallet);
910
2258
  const settlement = this.network.settlement_contract;
911
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
+ }
912
2316
  if (action === "top_up") {
913
2317
  const amount = parseUsdc(raw.amount_usdc);
914
2318
  const allowance = BigInt(await this.rpcValue((rpc) => this.contractCall(rpc, token, "allowance(address,address)", [wallet, settlement])) || "0x0");
915
2319
  const transactions = [];
916
- if (allowance < amount) transactions.push({ label: "Approve stablecoin", to: token, data: contractData("approve(address,uint256)", [settlement, (1n << 256n) - 1n]) });
2320
+ if (allowance < amount) transactions.push({ label: "Approve stablecoin", to: token, data: contractData("approve(address,uint256)", [settlement, amount]) });
917
2321
  transactions.push({ label: "Deposit prepaid balance", to: settlement, data: contractData("deposit(uint256)", [amount]) });
918
2322
  return { action, amount_units: amount.toString(), transactions };
919
2323
  }
920
2324
  if (action === "register_key") {
921
2325
  const pending = this.pendingPaymentKey();
922
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
+ }
923
2332
  return { action, key_address: keyAddress, transactions: [{ label: "Register payment key", to: settlement, data: contractData("registerKey(address,uint256,uint64)", [keyAddress, this.maxFeeUnits, 0]) }] };
924
2333
  }
925
2334
  if (action === "revoke_key") {
926
2335
  const keyAddress = normalizeAddress(raw.key_address, "key_address");
927
2336
  return { action, key_address: keyAddress, transactions: [{ label: "Revoke previous payment key", to: settlement, data: contractData("revokeKey(address)", [keyAddress]) }] };
928
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
+ }
929
2344
  throw new Error("unsupported transaction action");
930
2345
  }
931
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
+
932
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) {
933
2615
  const cached = this.healthCache.get(relayUrl);
934
2616
  const cacheAge = cached ? Date.now() - cached.at : Infinity;
935
- if (cached && !refresh && cacheAge < RELAY_HEALTH_CACHE_MS) return cached.payload;
2617
+ if (cached && !refresh && cacheAge < RELAY_HEALTH_CACHE_MS) {
2618
+ this.verifyRelayIdentity(relayUrl, cached.payload);
2619
+ return cached.payload;
2620
+ }
936
2621
  const request = { dispatcher: this.dispatcher, headers: { accept: "application/json" } };
2622
+ if (this.relayDiscovery && !this.staticRelayUrls.includes(relayUrl)) await this.relayDiscovery.probe(relayUrl);
937
2623
  let payload;
938
2624
  let lastError;
939
2625
  for (let attempt = 0; attempt < 2 && !payload; attempt += 1) {
@@ -942,35 +2628,556 @@ export class NativeConsumerState {
942
2628
  payload = await readJsonResponse(response);
943
2629
  if (!response.ok || payload?.ok !== true) throw new Error(`Relay health is invalid for ${relayUrl}`);
944
2630
  } catch (error) {
945
- lastError = error;
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
+ }
946
2644
  payload = undefined;
947
2645
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, RELAY_HEALTH_RETRY_DELAY_MS));
948
2646
  }
949
2647
  }
950
2648
  if (!payload) {
951
- if (cached && cacheAge < RELAY_HEALTH_STALE_MS) return cached.payload;
952
2649
  throw lastError || new Error(`Relay health is unavailable for ${relayUrl}`);
953
2650
  }
954
- const v8 = payload.v8;
955
- if (!v8 || v8.enabled !== true || Number(v8.providers || 0) <= 0) throw new Error(`Relay has no live Settlement V8 Provider: ${relayUrl}`);
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
+ }
956
2658
  this.healthCache.set(relayUrl, { at: Date.now(), payload });
957
2659
  return payload;
958
2660
  }
959
2661
 
960
- async chooseRelay(exclude = new Set()) {
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];
961
2888
  const errors = [];
962
- for (const relayUrl of this.relayUrls) {
963
- if (exclude.has(relayUrl)) continue;
964
- try { return { relayUrl, health: await this.relayHealth(relayUrl) }; } catch (error) { errors.push(error.message); }
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);
3127
+ }
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 };
965
3140
  }
966
- throw new Error(`no healthy Settlement V8 Relay is available: ${errors.join("; ") || "all relays excluded"}`);
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
+ });
967
3163
  }
968
3164
 
969
- buildRelayPayment(path, body, health, requestId) {
3165
+ buildRelayPayment(path, body, health, requestId, timing) {
970
3166
  const endpoint = path.endsWith("/chat/completions") ? "chat" : "responses";
971
- const v8 = health?.v8;
3167
+ const v8 = this.capabilities(health);
972
3168
  if (!v8) throw new Error("Relay health has no V8 payment requirements");
973
- const model = String(v8.model || body.model || DEFAULT_MODEL);
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}`);
974
3181
  const maxOutput = body.max_output_tokens ?? body.max_tokens ?? v8.maxOutputTokens ?? 2000;
975
3182
  const options = {};
976
3183
  for (const field of [...RESPONSES_REQUEST_OPTION_FIELDS, ...RESPONSES_LOCAL_OPTION_FIELDS]) {
@@ -985,8 +3192,18 @@ export class NativeConsumerState {
985
3192
  maxOutputTokens: maxOutput,
986
3193
  options: normalizedOptions || undefined,
987
3194
  });
988
- const now = Math.floor(Date.now() / 1000);
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
+ }
989
3205
  const payment = buildAuthorization({
3206
+ protocolVersion: this.network.protocol_version,
990
3207
  paymentKey: this.paymentKey,
991
3208
  chainId: Number(v8.chain_id),
992
3209
  settlementContract: v8.settlement_contract,
@@ -999,25 +3216,107 @@ export class NativeConsumerState {
999
3216
  pricingHash: v8.pricing_hash,
1000
3217
  maxFee: this.maxFeeUnits,
1001
3218
  issuedAt: now - AUTHORIZATION_CLOCK_SKEW_SECONDS,
1002
- deadline: now + 900,
3219
+ deadline: now + (timing?.seconds ?? 900),
3220
+ maxAuthorizationTtlSeconds: timing?.maxTtl ?? 3600,
1003
3221
  });
1004
3222
  return { payment, request_id: requestId, model };
1005
3223
  }
1006
3224
 
1007
- async relayInference(path, body) {
1008
- const requestId = `0x${bytesToHex(randomBytes(32))}`;
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
+ });
1009
3281
  const used = new Set();
1010
3282
  let lastError = "no Relay accepted the request";
1011
3283
  let lastResponse;
1012
3284
  while (used.size < this.relayUrls.length) {
1013
3285
  let selected;
1014
- try { selected = await this.chooseRelay(used); } catch (error) { lastError = error.message; break; }
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
+ }
1015
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;
1016
3306
  try {
1017
3307
  const requestBody = {
1018
3308
  ...body,
1019
- model: selected.health.v8.model || body.model,
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),
1020
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
+ }
1021
3320
  if (!String(requestBody.prompt_cache_key || "").trim()) {
1022
3321
  const cacheKey = derivePromptCacheKey({
1023
3322
  endpoint: path.endsWith("/chat/completions") ? "chat" : "responses",
@@ -1028,54 +3327,217 @@ export class NativeConsumerState {
1028
3327
  });
1029
3328
  if (cacheKey) requestBody.prompt_cache_key = cacheKey;
1030
3329
  }
1031
- const payment = this.buildRelayPayment(path, requestBody, selected.health, requestId);
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;
1032
3335
  requestBody.model = payment.model;
1033
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;
1034
3346
  const response = await fetchWithTimeout(`${selected.relayUrl}${path}`, {
1035
3347
  method: "POST",
1036
- 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) },
1037
3351
  body: JSON.stringify(requestBody),
1038
3352
  dispatcher: this.dispatcher,
1039
- }, this.timeoutMs);
1040
- const payload = await readJsonResponse(response);
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;
1041
3363
  const retryAfter = response.headers.get("retry-after");
1042
- const headers = retryAfter ? { "Retry-After": retryAfter } : {};
1043
- if (RETRYABLE_RELAY_STATUS.has(response.status)) {
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);
1044
3377
  lastError = payload?.error?.message || `Relay returned HTTP ${response.status}`;
1045
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);
1046
3381
  continue;
1047
3382
  }
1048
- if (response.status >= 400) return { payload: normalizeOpenAiError(payload, "relay_error"), status: response.status, headers };
1049
- const paymentResponse = response.headers.get("PAYMENT-RESPONSE");
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");
1050
3386
  if (paymentResponse) {
1051
- const settlement = decodePaymentResponse(paymentResponse);
1052
- this.recordReceipt(
1053
- selected.relayUrl,
1054
- path,
1055
- String(body.model || payment.model),
1056
- settlement,
1057
- payment.model,
1058
- );
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
+ }
1059
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
+ }
1060
3445
  }
1061
- return { payload: restoreClientResponse(payload, body), status: 200, headers };
3446
+ this.sessions.remember(payload, session.id);
3447
+ return { payload: restoreClientResponse(payload, clientBody), status: 200, headers };
1062
3448
  } catch (error) {
1063
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(); }
1064
3490
  }
1065
3491
  }
1066
3492
  if (lastResponse) return lastResponse;
1067
- return { payload: openaiError(lastError, "relay_unavailable"), status: 503, headers: { "Retry-After": "2" } };
3493
+ return preDispatchFailure(lastError, keepSession ? "session_unavailable" : "relay_unavailable");
1068
3494
  }
1069
3495
  }
1070
3496
 
1071
- function decodePaymentResponse(value) {
3497
+ function decodePaymentResponse(value, expected = {}) {
1072
3498
  let payload;
1073
3499
  try { payload = JSON.parse(Buffer.from(String(value), "base64url").toString("utf8")); } catch { throw new Error("Relay returned an invalid PAYMENT-RESPONSE"); }
1074
3500
  if (!payload || typeof payload !== "object" || !payload.signed_receipt) throw new Error("Relay PAYMENT-RESPONSE is missing its signed receipt");
1075
- try { verifySignedReceipt(payload.signed_receipt); } catch (error) { throw new Error(`Relay returned an invalid signed receipt: ${error.message}`); }
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}`); }
1076
3519
  return payload;
1077
3520
  }
1078
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
+
1079
3541
  function normalizeOpenAiError(value, fallbackType) {
1080
3542
  if (!value || typeof value !== "object") return openaiError(String(value || fallbackType), fallbackType);
1081
3543
  if (!value.error || typeof value.error !== "object") return openaiError(value.detail || value.message || fallbackType, fallbackType);
@@ -1227,6 +3689,8 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
1227
3689
  if (alphaSearch) {
1228
3690
  body = {
1229
3691
  model: body.model,
3692
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
3693
+ ...(body.conversation !== undefined ? { conversation: body.conversation } : {}),
1230
3694
  input: [{ type: "mycomesh_alpha_search_request", request: body, query: alphaSearchQuery }],
1231
3695
  max_output_tokens: body.max_output_tokens ?? 2000,
1232
3696
  };
@@ -1241,7 +3705,7 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
1241
3705
  body.input = items;
1242
3706
  }
1243
3707
  try {
1244
- const result = await state.relayInference(path, body);
3708
+ const result = await state.relayInference(path, body, request.headers);
1245
3709
  if (result.status >= 400) {
1246
3710
  writeJson(response, result.status, result.payload, result.headers);
1247
3711
  return;
@@ -1257,7 +3721,7 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
1257
3721
  return;
1258
3722
  }
1259
3723
  if (body.stream === true) {
1260
- 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" });
1261
3725
  const events = path.endsWith("/chat/completions")
1262
3726
  ? chatCompletionSse(result.payload, body.stream_options?.include_usage === true)
1263
3727
  : responseSse(result.payload, path.endsWith("/responses/compact") || hasCompactionTrigger(body.input));
@@ -1278,9 +3742,9 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1278
3742
  const path = url.pathname.startsWith("/v1/v1/") ? url.pathname.slice(3) : url.pathname;
1279
3743
  if (publicOnly) {
1280
3744
  response.setHeader("Access-Control-Allow-Origin", "*");
1281
- 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");
1282
3746
  response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1283
- 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");
1284
3748
  if (request.method === "OPTIONS") { response.writeHead(204); response.end(); return; }
1285
3749
  if (request.method === "GET" && (path === "/health" || path === "/v1/health")) {
1286
3750
  if (!state.authorizeBearer(String(request.headers.authorization || ""), { shareOnly: true })) { writeJson(response, 401, openaiError("invalid temporary access key", "invalid_api_key")); return; }
@@ -1289,7 +3753,7 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1289
3753
  }
1290
3754
  if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
1291
3755
  if (!state.authorizeBearer(String(request.headers.authorization || ""), { shareOnly: true })) { writeJson(response, 401, openaiError("invalid temporary access key", "invalid_api_key")); return; }
1292
- try { const selected = await state.chooseRelay(); writeJson(response, 200, { object: "list", data: [{ id: selected.health.v8.model || DEFAULT_MODEL, object: "model", owned_by: "mycomesh", relay: selected.relayUrl }] }); }
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 })) }); }
1293
3757
  catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
1294
3758
  return;
1295
3759
  }
@@ -1303,6 +3767,24 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1303
3767
  writeJson(response, 404, openaiError("route not found", "invalid_request_error"));
1304
3768
  return;
1305
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
+ }
1306
3788
  if (request.method === "GET" && path === "/") {
1307
3789
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1308
3790
  response.end(consumerHtml());
@@ -1310,43 +3792,116 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1310
3792
  }
1311
3793
  if (request.method === "GET" && path === "/health") { writeJson(response, 200, state.healthPayload()); return; }
1312
3794
  if (request.method === "GET" && path === "/ready") {
1313
- try { const selected = await state.chooseRelay(); writeJson(response, 200, { ok: true, relay: selected.relayUrl, model: selected.health.v8.model || DEFAULT_MODEL }); }
1314
- catch (error) { writeJson(response, 503, { ok: false, error: error.message }); }
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);
1315
3821
  return;
1316
3822
  }
1317
3823
  if (request.method === "GET" && (path === "/credentials" || path === "/codex-env")) {
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; }
1318
3826
  response.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); response.end(`${state.credentialsText()}\n`); return;
1319
3827
  }
1320
3828
  if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
1321
- try { const selected = await state.chooseRelay(); writeJson(response, 200, { object: "list", data: [{ id: selected.health.v8.model || DEFAULT_MODEL, object: "model", owned_by: "mycomesh", relay: selected.relayUrl }] }); }
3829
+ try { writeJson(response, 200, { object: "list", data: await state.modelCatalog() }); }
1322
3830
  catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
1323
3831
  return;
1324
3832
  }
1325
- if (request.method === "GET" && path === "/v1/mycomesh/local/dashboard") { writeJson(response, 200, await state.dashboardPayload(url.searchParams.get("wallet"))); return; }
3833
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/challenge") {
3834
+ try { const value = await decodeRequestBody(request); writeJson(response, 200, state.createWalletChallenge(value.wallet)); }
3835
+ catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
3836
+ return;
3837
+ }
3838
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/authenticate") {
3839
+ try { writeJson(response, 200, await state.authenticateWallet(await decodeRequestBody(request))); }
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
+ }
3851
+ return;
3852
+ }
3853
+ if (request.method === "GET" && path === "/v1/mycomesh/local/dashboard") {
3854
+ writeJson(response, 200, await state.dashboardPayload(state.authorizeManagement(String(request.headers.authorization || "")))); return;
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
+ }
3864
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/activate") {
3865
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
3866
+ try { writeJson(response, 200, await state.activateCurrentPaymentKey()); }
3867
+ catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
3868
+ return;
3869
+ }
3870
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/lock") {
3871
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
3872
+ writeJson(response, 200, await state.lockWallet()); return;
3873
+ }
1326
3874
  if (request.method === "POST" && path === "/v1/mycomesh/local/transactions") {
3875
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1327
3876
  try { writeJson(response, 200, await state.transactionPlan(await decodeRequestBody(request))); }
1328
3877
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1329
3878
  return;
1330
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
+ }
1331
3886
  if (request.method === "POST" && path === "/v1/mycomesh/local/key/prepare") {
1332
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
3887
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1333
3888
  try { writeJson(response, 200, state.preparePaymentKey()); } catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1334
3889
  return;
1335
3890
  }
1336
3891
  if (request.method === "POST" && path === "/v1/mycomesh/local/key/activate") {
1337
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
3892
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1338
3893
  try { const value = await decodeRequestBody(request); writeJson(response, 200, await state.activatePendingPaymentKey(value.wallet)); }
1339
3894
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1340
3895
  return;
1341
3896
  }
1342
3897
  if (request.method === "POST" && path === "/v1/mycomesh/local/share/start") {
1343
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
3898
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1344
3899
  try { const value = await decodeRequestBody(request); writeJson(response, 200, { ok: true, share: await state.startShare(value.minutes) }); }
1345
3900
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1346
3901
  return;
1347
3902
  }
1348
3903
  if (request.method === "POST" && path === "/v1/mycomesh/local/share/stop") {
1349
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
3904
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1350
3905
  writeJson(response, 200, { ok: true, share: await state.stopShare() });
1351
3906
  return;
1352
3907
  }
@@ -1472,44 +4027,149 @@ export function chatCompletionSse(payload, includeUsage = false) {
1472
4027
 
1473
4028
  function consumerHtml() {
1474
4029
  return `<!doctype html>
1475
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1476
- <title>MycoMesh Consumer</title>
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">
4031
+ <title>Consumer | MycoMesh</title>
1477
4032
  <style>
1478
- body{font:15px system-ui;max-width:960px;margin:32px auto;padding:0 18px;color:#17211d;background:#f5f7f5}
1479
- h1{margin-bottom:4px}.muted{color:#68736e}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
1480
- .panel{background:white;border:1px solid #d9e0dc;border-radius:6px;padding:18px;margin:14px 0}.value{word-break:break-all;background:#f1f4f2;padding:10px;border-radius:4px;font:12px monospace;white-space:pre-wrap}
1481
- button{padding:9px 12px;border:1px solid #177b57;border-radius:4px;background:#177b57;color:#fff;cursor:pointer}input,select{padding:9px;border:1px solid #c7d0ca;border-radius:4px;background:#fff}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1482
- table{width:100%;border-collapse:collapse}td,th{padding:8px;border-bottom:1px solid #e1e7e3;text-align:left;font-size:13px}
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%}}
1483
4034
  </style></head><body>
1484
- <h1>MycoMesh Consumer V8</h1><p class="muted">Native Node runtime. This page manages credentials, prepaid balance, payment keys, and local consumption history.</p>
1485
- <div class="grid"><section class="panel"><h2>Credentials</h2><p>API URL</p><div id="url" class="value">Loading...</div><p>Payment key</p><div id="key" class="value"></div><p>Shell export</p><div id="export" class="value"></div><button id="copy" type="button">Copy export</button></section>
1486
- <section class="panel"><h2>Usage</h2><p id="usage">Loading...</p><p id="balance"></p><p>Key address</p><div id="address" class="value"></div><div class="row"><button id="register" type="button">Register key</button><button id="rotate" type="button">Rotate key</button></div></section></div>
1487
- <section class="panel"><h2>Prepaid top-up</h2><div class="row"><button id="connect" type="button">Connect wallet</button><input id="wallet" placeholder="Wallet address"><input id="amount" placeholder="10.00"><button id="topup" type="button">Top up</button></div><pre id="plan" class="value"></pre></section>
1488
- <section class="panel"><h2>Temporary share</h2><div class="row"><select id="shareMinutes"><option value="10">10 minutes</option><option value="30" selected>30 minutes</option><option value="60">1 hour</option><option value="360">6 hours</option></select><button id="shareStart" type="button">Start sharing</button><button id="shareStop" type="button">Stop</button></div><p>API URL</p><div id="shareUrl" class="value">Inactive</div><p>Temporary key</p><div id="shareKey" class="value"></div><p id="shareExpiry" class="muted"></p><button id="shareCopy" type="button">Copy export</button></section>
1489
- <section class="panel"><h2>Consumption history</h2><table><thead><tr><th>Time</th><th>Model</th><th>Tokens</th><th>Fee</th><th>Relay</th></tr></thead><tbody id="history"></tbody></table></section>
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>
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>
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>
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>
4043
+ </main></div><div id="toast" class="toast" hidden></div>
1490
4044
  <script>
1491
- let state;let wallet;const $=id=>document.getElementById(id);
1492
- async function api(path,options={}){const r=await fetch(path,options);const v=await r.json();if(!r.ok)throw Error(typeof v.error==='string'?v.error:(v.error?.message||'request failed'));return v}
1493
- function render(){
1494
- $('url').textContent=state.credentials.base_url;$('key').textContent=state.credentials.api_key;$('export').textContent=state.credentials.export;$('address').textContent=state.key.address;
1495
- $('usage').textContent=state.usage.request_count+' requests, '+state.usage.total_spent_units+' units';$('balance').textContent='Available balance: '+(state.account?.available_balance_units||'unknown');
1496
- $('history').innerHTML=(state.history||[]).map(x=>'<tr><td>'+new Date(x.timestamp*1000).toLocaleString()+'</td><td>'+x.model+'</td><td>'+x.input_tokens+' / '+x.output_tokens+'</td><td>'+x.actual_fee_units+'</td><td>'+x.relay_url+'</td></tr>').join('')||'<tr><td colspan="5">No consumption recorded.</td></tr>';
1497
- $('shareUrl').textContent=state.share?.base_url||'Inactive';$('shareKey').textContent=state.share?.api_key||'';$('shareExpiry').textContent=state.share?.expires_at?'Expires '+new Date(state.share.expires_at*1000).toLocaleString():'';
1498
- }
1499
- async function load(){state=await api('/v1/mycomesh/local/dashboard');render()}
1500
- async function connectWallet(){if(!window.ethereum)throw Error('No injected wallet found');const accounts=await window.ethereum.request({method:'eth_requestAccounts'});wallet=accounts[0];if(!wallet)throw Error('Wallet was not connected');$('wallet').value=wallet;$('connect').textContent=wallet.slice(0,6)+'...'+wallet.slice(-4)}
1501
- async function requireWallet(){if(!wallet)await connectWallet();return wallet}
1502
- async function waitReceipt(hash){for(let i=0;i<120;i++){const receipt=await window.ethereum.request({method:'eth_getTransactionReceipt',params:[hash]});if(receipt){if(receipt.status!=='0x1')throw Error('Chain transaction failed');return}await new Promise(resolve=>setTimeout(resolve,1500))}throw Error('Timed out waiting for chain confirmation')}
1503
- async function sendPlan(plan){for(const transaction of plan.transactions){const hash=await window.ethereum.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]});await waitReceipt(hash)}return plan}
1504
- async function showPlan(action,send=false){try{await requireWallet();const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action,wallet,key_address:state.key.address,amount_usdc:$('amount').value})});$('plan').textContent=JSON.stringify(plan,null,2);if(send){await sendPlan(plan);await load();$('plan').textContent='Transactions confirmed. '+JSON.stringify(plan,null,2)}}catch(error){$('plan').textContent=error.message}}
1505
- $('copy').onclick=()=>navigator.clipboard?.writeText($('export').textContent);
1506
- $('connect').onclick=()=>connectWallet().catch(error=>$('plan').textContent=error.message);
1507
- $('register').onclick=()=>showPlan('register_key',true);$('topup').onclick=()=>showPlan('top_up',true);
1508
- $('rotate').onclick=async()=>{try{await requireWallet();const oldAddress=state.key.address;const pending=await api('/v1/mycomesh/local/key/prepare',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key}});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);let activated;for(let i=0;i<8&&!activated;i++){try{activated=await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key,'content-type':'application/json'},body:JSON.stringify({wallet})})}catch(error){if(i===7)throw error;await new Promise(resolve=>setTimeout(resolve,1800))}}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);$('plan').textContent=JSON.stringify({pending,activated,revoke},null,2);await load()}catch(error){$('plan').textContent=error.message}};
1509
- $('shareStart').onclick=async()=>{const button=$('shareStart');button.disabled=true;button.textContent='Starting...';try{const v=await api('/v1/mycomesh/local/share/start',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key,'content-type':'application/json'},body:JSON.stringify({minutes:Number($('shareMinutes').value)})});state.share=v.share;render()}catch(error){$('shareExpiry').textContent=error.message}finally{button.disabled=false;button.textContent='Start sharing'}};
1510
- $('shareStop').onclick=async()=>{try{const v=await api('/v1/mycomesh/local/share/stop',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key}});state.share=v.share;render()}catch(error){$('shareExpiry').textContent=error.message}};
1511
- $('shareCopy').onclick=()=>navigator.clipboard?.writeText("export OPENAI_BASE_URL='"+$('shareUrl').textContent+"'\\nexport OPENAI_API_KEY='"+$('shareKey').textContent+"'");
1512
- load().catch(error=>$('plan').textContent=error.message);
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))):'')}
4047
+ function authHeaders(headers={}){const next=new Headers(headers);if(managementToken)next.set('authorization','Bearer '+managementToken);return next}
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}
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)}
4050
+ function setBusy(value){busy=value;for(const button of document.querySelectorAll('button'))button.disabled=value}
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
+ }
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?'暂时无法完成状态核验,原记录已保留':'已完成状态核验')}
4124
+ function walletMessage(value){return '0x'+Array.from(new TextEncoder().encode(value),byte=>byte.toString(16).padStart(2,'0')).join('')}
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
+ }
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))}}}
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(()=>{})});
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))});
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('充值已确认')});
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('临时分享已开启')});
4168
+ $('shareStop').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/stop',{method:'POST'});state.share=value.share;renderShare();toast('临时分享已停止')});
4169
+ $('walletButton').onclick=()=>run(async()=>{await api('/v1/mycomesh/local/wallet/lock',{method:'POST'});managementToken=null;wallet=null;await load()});
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))})});
4171
+ load().catch(error=>{$('loginError').hidden=false;$('loginError').textContent=error.message});
4172
+ loadModels();
1513
4173
  </script></body></html>`;
1514
4174
  }
1515
4175