mycomesh-consumer 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,12 +128,15 @@ const RESPONSES_LOCAL_OPTION_FIELDS = new Set(["stream", "stream_options"]);
85
128
  const MAX_SHARE_MINUTES = 24 * 60;
86
129
  const TUNNEL_START_TIMEOUT_MS = 30_000;
87
130
  const TUNNEL_STOP_TIMEOUT_MS = 2_000;
88
- const RELAY_HEALTH_CACHE_MS = 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;
91
136
  const WALLET_CHALLENGE_TTL_MS = 5 * 60_000;
92
137
 
93
138
  const DEFAULT_NETWORK = Object.freeze({
139
+ protocol_version: 8,
94
140
  chain_id: DEFAULT_CHAIN_ID,
95
141
  network_name: "Sepolia testnet",
96
142
  settlement_contract: DEFAULT_SETTLEMENT,
@@ -195,6 +241,228 @@ function stableStringify(value) {
195
241
  throw new Error("request must contain canonical JSON data");
196
242
  }
197
243
 
244
+ function normalizeJuryRelayPublicKeys(value) {
245
+ if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
246
+ throw new Error("dynamic V10 network requires 1 to 4 jury Relay public keys");
247
+ }
248
+ const keys = value.map((item) => {
249
+ if (typeof item !== "string" || !/^[0-9a-f]{64}$/.test(item)) {
250
+ throw new Error("jury Relay public keys must be lowercase 32-byte Ed25519 hex");
251
+ }
252
+ return item;
253
+ });
254
+ if (new Set(keys).size !== keys.length) throw new Error("jury Relay public keys must be unique");
255
+ return keys;
256
+ }
257
+
258
+ function juryEvidenceReferenceAudience({ network, settlement_contract, reporter }) {
259
+ return `${JURY_EVIDENCE_REFERENCE_SCHEMA}:${network}:${settlement_contract}:${reporter}`;
260
+ }
261
+
262
+ export function verifyJuryEvidenceReference(value, expected) {
263
+ const fields = new Set(["schema", "network", "chain_id", "settlement_contract",
264
+ "settlement_key", "request_id", "request_hash", "evidence_hash",
265
+ "predicted_report_id", "reporter", "origin_relay_public_key", "signature"]);
266
+ if (!value || typeof value !== "object" || Array.isArray(value)
267
+ || Object.keys(value).length !== fields.size
268
+ || Object.keys(value).some((key) => !fields.has(key))) {
269
+ throw new Error("jury evidence reference has unknown or missing fields");
270
+ }
271
+ const chainId = Number(value.chain_id);
272
+ if (!Number.isSafeInteger(chainId) || chainId <= 0 || chainId !== Number(expected.chainId)) {
273
+ throw new Error("jury evidence reference chain mismatch");
274
+ }
275
+ const network = `eip155:${chainId}`;
276
+ const settlementContract = normalizeAddress(value.settlement_contract);
277
+ const settlementKey = normalizeBytes32(value.settlement_key, "jury settlement key");
278
+ const requestId = normalizeBytes32(value.request_id, "jury request ID");
279
+ const requestHash = normalizeBytes32(value.request_hash, "jury request hash");
280
+ const evidenceHash = normalizeBytes32(value.evidence_hash, "jury evidence hash");
281
+ const predictedReportId = normalizeBytes32(value.predicted_report_id, "jury report ID");
282
+ const reporter = nonzeroAddress(value.reporter, "jury evidence reporter");
283
+ const origin = String(value.origin_relay_public_key || "");
284
+ const pins = normalizeJuryRelayPublicKeys(expected.juryRelayPublicKeys);
285
+ if (value.schema !== JURY_EVIDENCE_REFERENCE_SCHEMA || value.network !== network
286
+ || settlementContract !== normalizeAddress(expected.contract)
287
+ || settlementKey !== normalizeBytes32(expected.settlementKey, "expected settlement key")
288
+ || requestId !== normalizeBytes32(expected.requestId, "expected request ID")
289
+ || requestHash !== normalizeBytes32(expected.requestHash, "expected request hash")
290
+ || reporter !== nonzeroAddress(expected.reporter, "expected jury reporter")
291
+ || !pins.includes(origin)) {
292
+ throw new Error("jury evidence reference differs from the paid request or pinned origin");
293
+ }
294
+ const calculatedReportId = keccakHex(Buffer.concat([
295
+ abiWord(settlementKey), abiWord(reporter), abiWord(evidenceHash),
296
+ ]));
297
+ if (predictedReportId !== calculatedReportId) throw new Error("jury evidence report ID is not canonical");
298
+ const signature = value.signature;
299
+ const signatureFields = new Set(["nonce", "public_key", "purpose", "timestamp", "audience", "signature"]);
300
+ if (!signature || typeof signature !== "object" || Array.isArray(signature)
301
+ || Object.keys(signature).length !== signatureFields.size
302
+ || Object.keys(signature).some((key) => !signatureFields.has(key))
303
+ || signature.public_key !== origin || signature.purpose !== JURY_EVIDENCE_REFERENCE_SCHEMA
304
+ || signature.audience !== juryEvidenceReferenceAudience({ network, settlement_contract: settlementContract, reporter })
305
+ || signature.nonce !== evidenceHash.slice(2, 34)
306
+ || !Number.isSafeInteger(signature.timestamp) || signature.timestamp !== Number(expected.issuedAt)
307
+ || !/^[0-9a-f]{128}$/.test(String(signature.signature || ""))) {
308
+ throw new Error("jury evidence reference signature domain is invalid");
309
+ }
310
+ const unsigned = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "signature"));
311
+ const signatureMetadata = Object.fromEntries(Object.entries(signature).filter(([key]) => key !== "signature"));
312
+ const message = Buffer.from(stableStringify({ document: unsigned, signature: signatureMetadata }), "utf8");
313
+ let verified = false;
314
+ try {
315
+ verified = ed25519.verify(
316
+ Buffer.from(signature.signature, "hex"), message, Buffer.from(origin, "hex"),
317
+ );
318
+ } catch {}
319
+ if (!verified) throw new Error("jury evidence reference signature is invalid");
320
+ return { ...unsigned, settlement_contract: settlementContract, settlement_key: settlementKey,
321
+ request_id: requestId, request_hash: requestHash, evidence_hash: evidenceHash,
322
+ predicted_report_id: predictedReportId, reporter, origin_relay_public_key: origin,
323
+ signature: { ...signature } };
324
+ }
325
+
326
+ export function restoreJuryEvidenceReference(entry, network, expectedWallet) {
327
+ if (!entry || typeof entry !== "object") throw new Error("jury evidence history entry is missing");
328
+ const chainId = Number(entry.chain_id);
329
+ const contract = normalizeAddress(entry.settlement_contract);
330
+ const settlementKey = normalizeBytes32(entry.settlement_key, "jury settlement key");
331
+ const requestId = normalizeBytes32(entry.request_id, "jury request ID");
332
+ const requestHash = normalizeBytes32(entry.request_hash, "jury request hash");
333
+ const evidenceHash = normalizeBytes32(entry.jury_evidence_hash, "jury evidence hash");
334
+ const reportId = normalizeBytes32(entry.jury_report_id, "jury report ID");
335
+ const reporter = nonzeroAddress(entry.jury_reporter, "jury evidence reporter");
336
+ const origin = String(entry.jury_origin_relay_public_key || "");
337
+ const timestamp = Number(entry.jury_reference_timestamp);
338
+ if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
339
+ throw new Error("jury evidence history timestamp is invalid");
340
+ }
341
+ if (expectedWallet && reporter !== normalizeAddress(expectedWallet)) {
342
+ throw new Error("jury evidence reporter is not the authenticated wallet");
343
+ }
344
+ const schema = JURY_EVIDENCE_REFERENCE_SCHEMA;
345
+ const value = {
346
+ schema,
347
+ network: `eip155:${chainId}`,
348
+ chain_id: chainId,
349
+ settlement_contract: contract,
350
+ settlement_key: settlementKey,
351
+ request_id: requestId,
352
+ request_hash: requestHash,
353
+ evidence_hash: evidenceHash,
354
+ predicted_report_id: reportId,
355
+ reporter,
356
+ origin_relay_public_key: origin,
357
+ signature: {
358
+ nonce: evidenceHash.slice(2, 34),
359
+ public_key: origin,
360
+ purpose: schema,
361
+ timestamp,
362
+ audience: juryEvidenceReferenceAudience({
363
+ network: `eip155:${chainId}`,
364
+ settlement_contract: contract,
365
+ reporter,
366
+ }),
367
+ signature: String(entry.jury_reference_signature || ""),
368
+ },
369
+ };
370
+ return verifyJuryEvidenceReference(value, {
371
+ chainId: Number(network.chain_id),
372
+ contract: network.settlement_contract,
373
+ settlementKey,
374
+ requestId,
375
+ requestHash,
376
+ reporter,
377
+ issuedAt: timestamp,
378
+ juryRelayPublicKeys: network.jury_relay_public_keys,
379
+ });
380
+ }
381
+
382
+ function decodeAbiTuple(value, words, label) {
383
+ const text = String(value || "").toLowerCase();
384
+ if (!new RegExp(`^0x[0-9a-f]{${words * 64}}$`).test(text)) {
385
+ throw new Error(`${label} returned non-canonical ABI data`);
386
+ }
387
+ return text.slice(2).match(/.{64}/g);
388
+ }
389
+
390
+ function decodeTupleAddress(word, label, { nonzero = true } = {}) {
391
+ if (!/^0{24}[0-9a-f]{40}$/.test(word)) throw new Error(`${label} returned a non-canonical address`);
392
+ const value = `0x${word.slice(-40)}`;
393
+ if (nonzero && value === ZERO_ADDRESS) throw new Error(`${label} returned the zero address`);
394
+ return value;
395
+ }
396
+
397
+ function decodeV10Settlement(value) {
398
+ const words = decodeAbiTuple(value, 20, "Settlement V10 settlementInfo");
399
+ const status = BigInt(`0x${words[19]}`);
400
+ const settledAt = BigInt(`0x${words[17]}`);
401
+ const releaseAt = BigInt(`0x${words[18]}`);
402
+ if (status > 7n || settledAt >= (1n << 64n) || releaseAt >= (1n << 64n)) {
403
+ throw new Error("Settlement V10 record has out-of-range typed fields");
404
+ }
405
+ return {
406
+ owner: decodeTupleAddress(words[0], "settlement owner"),
407
+ key: decodeTupleAddress(words[1], "settlement key address"),
408
+ provider: decodeTupleAddress(words[2], "settlement Provider"),
409
+ provider_signer: decodeTupleAddress(words[3], "settlement Provider signer"),
410
+ relay: decodeTupleAddress(words[4], "settlement Relay"),
411
+ relay_signer: decodeTupleAddress(words[5], "settlement Relay signer"),
412
+ pool: decodeTupleAddress(words[6], "settlement pool", { nonzero: false }),
413
+ treasury: decodeTupleAddress(words[7], "settlement treasury"),
414
+ request_id: `0x${words[8]}`,
415
+ request_hash: `0x${words[9]}`,
416
+ authorization_hash: `0x${words[10]}`,
417
+ response_hash: `0x${words[11]}`,
418
+ gross_fee: BigInt(`0x${words[12]}`),
419
+ settled_at: Number(settledAt),
420
+ release_at: Number(releaseAt),
421
+ status: Number(status),
422
+ };
423
+ }
424
+
425
+ function decodeV10Policy(value) {
426
+ const words = decodeAbiTuple(value, 13, "Settlement V10 policy");
427
+ const uint64Indexes = [0, 1, 2];
428
+ const uint16Indexes = [4, 6];
429
+ if (uint64Indexes.some((index) => BigInt(`0x${words[index]}`) >= (1n << 64n))
430
+ || uint16Indexes.some((index) => BigInt(`0x${words[index]}`) >= (1n << 16n))) {
431
+ throw new Error("Settlement V10 policy has out-of-range typed fields");
432
+ }
433
+ decodeTupleAddress(words[12], "bond penalty recipient");
434
+ return { reporter_bond: BigInt(`0x${words[3]}`) };
435
+ }
436
+
437
+ function decodeV10Report(value) {
438
+ const words = decodeAbiTuple(value, 3, "Settlement V10 report");
439
+ const claimed = BigInt(`0x${words[2]}`);
440
+ if (claimed !== 0n && claimed !== 1n) throw new Error("Settlement V10 report returned a non-canonical bool");
441
+ return {
442
+ reporter: decodeTupleAddress(words[0], "dispute reporter"),
443
+ evidence_hash: `0x${words[1]}`,
444
+ bond_claimed: claimed === 1n,
445
+ };
446
+ }
447
+
448
+ function canonicalBlock(value, label = "canonical block") {
449
+ if (!value || typeof value !== "object"
450
+ || !/^0x[0-9a-f]{64}$/i.test(String(value.hash || ""))
451
+ || !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(value.number || ""))
452
+ || !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(value.timestamp || ""))) {
453
+ throw new Error(`${label} is unavailable`);
454
+ }
455
+ const timestamp = BigInt(value.timestamp);
456
+ if (timestamp >= (1n << 64n) || timestamp > BigInt(Number.MAX_SAFE_INTEGER)) {
457
+ throw new Error(`${label} timestamp is invalid`);
458
+ }
459
+ return {
460
+ hash: String(value.hash).toLowerCase(),
461
+ number: String(value.number).toLowerCase(),
462
+ timestamp: Number(timestamp),
463
+ };
464
+ }
465
+
198
466
  function abiWord(value) {
199
467
  if (typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value)) {
200
468
  return Buffer.concat([Buffer.alloc(12), Buffer.from(value.slice(2), "hex")]);
@@ -213,6 +481,30 @@ function abiWord(value) {
213
481
  return output;
214
482
  }
215
483
 
484
+ function decodeSingleAbiWord(value, label) {
485
+ const text = String(value || "").toLowerCase();
486
+ if (!/^0x[0-9a-f]{64}$/.test(text)) throw new Error(`${label} returned a non-canonical ABI word`);
487
+ return text;
488
+ }
489
+
490
+ function decodeAbiAddress(value, label) {
491
+ const word = decodeSingleAbiWord(value, label);
492
+ if (!/^0x0{24}/.test(word)) throw new Error(`${label} returned a non-canonical address`);
493
+ return nonzeroAddress(`0x${word.slice(-40)}`, label);
494
+ }
495
+
496
+ function decodeAbiUint(value, label, bits = 256) {
497
+ const result = BigInt(decodeSingleAbiWord(value, label));
498
+ if (result >= (1n << BigInt(bits))) throw new Error(`${label} exceeds uint${bits}`);
499
+ return result;
500
+ }
501
+
502
+ function decodeAbiBool(value, label) {
503
+ const result = decodeAbiUint(value, label, 8);
504
+ if (result !== 0n && result !== 1n) throw new Error(`${label} returned a non-canonical bool`);
505
+ return result === 1n;
506
+ }
507
+
216
508
  function hashText(value) {
217
509
  return Uint8Array.from(keccak_256(Buffer.from(value, "utf8")));
218
510
  }
@@ -282,12 +574,23 @@ export function walletMessageDigest(message) {
282
574
  return Uint8Array.from(keccak_256(Buffer.concat([prefix, body])));
283
575
  }
284
576
 
285
- function typedDigest(structHash, chainId, contract) {
577
+ export function createReceiptStatusQuery(privateKey, { chainId, contract, requestId, channelId, issuedAt = Math.floor(Date.now() / 1000) }) {
578
+ const query = { chain_id: Number(chainId), settlement_contract: normalizeAddress(contract),
579
+ key: paymentKeyAddress(privateKey), request_id: normalizeBytes32(requestId, "request_id"), issued_at: Number(issuedAt) };
580
+ if (!Number.isSafeInteger(query.chain_id) || query.chain_id <= 0 || !Number.isSafeInteger(query.issued_at) || query.issued_at <= 0) {
581
+ throw new Error("invalid receipt status query scope");
582
+ }
583
+ if (channelId !== undefined) query.channel_id = normalizeBytes32(channelId, "channel_id");
584
+ const message = `MycoMesh receipt status v${channelId === undefined ? 1 : 2}\nchain_id:${query.chain_id}\nsettlement_contract:${query.settlement_contract}\nkey:${query.key}${channelId === undefined ? "" : `\nchannel_id:${query.channel_id}`}\nrequest_id:${query.request_id}\nissued_at:${query.issued_at}`;
585
+ return { ...query, signature: signDigest(privateKey, walletMessageDigest(message)) };
586
+ }
587
+
588
+ function typedDigest(structHash, chainId, contract, version = 8) {
286
589
  const domain = keccak_256(
287
590
  Buffer.concat([
288
591
  Buffer.from(hashText(DOMAIN_TYPE)),
289
592
  Buffer.from(hashText("MycoMesh Settlement")),
290
- Buffer.from(hashText("8")),
593
+ Buffer.from(hashText(String(protocolVersion(version)))),
291
594
  abiWord(chainId),
292
595
  abiWord(normalizeAddress(contract)),
293
596
  ]),
@@ -419,6 +722,7 @@ function normalizeInferenceOptions(endpoint, options) {
419
722
  }
420
723
 
421
724
  export function buildAuthorization({
725
+ protocolVersion: requestedVersion = 8,
422
726
  paymentKey,
423
727
  chainId,
424
728
  settlementContract,
@@ -432,7 +736,9 @@ export function buildAuthorization({
432
736
  maxFee,
433
737
  issuedAt = Math.floor(Date.now() / 1000),
434
738
  deadline = issuedAt + 900,
739
+ maxAuthorizationTtlSeconds = MAX_AUTHORIZATION_TTL,
435
740
  }) {
741
+ const version = protocolVersion(requestedVersion);
436
742
  const key = paymentKeyAddress(paymentKey);
437
743
  const authorization = {
438
744
  request_id: normalizeBytes32(requestId, "request_id"),
@@ -453,13 +759,14 @@ export function buildAuthorization({
453
759
  if (authorization.pricing_hash === `0x${"0".repeat(64)}`) throw new Error("V8 pricing_hash cannot be zero");
454
760
  const issued = BigInt(authorization.issued_at);
455
761
  const expires = BigInt(authorization.deadline);
456
- if (expires <= issued || expires - issued > BigInt(MAX_AUTHORIZATION_TTL)) {
457
- 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`);
458
765
  }
459
766
  const authorizationHash = authorizationStructHash(authorization);
460
- const digest = typedDigest(authorizationHash, chainId, settlementContract);
767
+ const digest = typedDigest(authorizationHash, chainId, settlementContract, version);
461
768
  return {
462
- schema: AUTH_SCHEMA,
769
+ schema: version === 9 ? V9_AUTH_SCHEMA : AUTH_SCHEMA,
463
770
  chain_id: jsonInteger(positiveBigInt(chainId, "chain_id")),
464
771
  settlement_contract: nonzeroAddress(settlementContract, "settlement_contract"),
465
772
  authorization,
@@ -470,9 +777,10 @@ export function buildAuthorization({
470
777
  }
471
778
 
472
779
  function verifyAuthorization(value, expected = {}) {
473
- if (!value || value.schema !== AUTH_SCHEMA || !value.authorization) {
474
- throw new Error("unsupported V8 payment authorization");
475
- }
780
+ if (value?.schema === RESERVED_AUTH_SCHEMA) return verifyReservedAuthorization(value, expected);
781
+ const version = authorizationVersion(value);
782
+ if (!value.authorization) throw new Error("payment authorization is missing");
783
+ if (expected.protocolVersion !== undefined && version !== protocolVersion(expected.protocolVersion)) throw new Error("settlement protocol version mismatch");
476
784
  const authorization = value.authorization;
477
785
  const chainId = uintBigInt(value.chain_id, "chain_id");
478
786
  const contract = nonzeroAddress(value.settlement_contract, "settlement_contract");
@@ -499,18 +807,25 @@ function verifyAuthorization(value, expected = {}) {
499
807
  const issued = BigInt(normalized.issued_at);
500
808
  const deadline = BigInt(normalized.deadline);
501
809
  if (issued > BigInt(now + 30) || deadline < BigInt(now)) throw new Error("V8 payment authorization is outside its time window");
502
- if (deadline <= issued || deadline - issued > BigInt(MAX_AUTHORIZATION_TTL)) throw new Error("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`);
503
811
  const structHash = authorizationStructHash(normalized);
504
812
  if (normalizeBytes32(value.authorization_hash, "authorization_hash") !== structHash) throw new Error("V8 authorization hash mismatch");
505
- const digest = typedDigest(structHash, chainId, contract);
813
+ const digest = typedDigest(structHash, chainId, contract, version);
506
814
  if (normalizeBytes32(value.authorization_digest, "authorization_digest") !== `0x${bytesToHex(digest)}`) throw new Error("V8 authorization digest mismatch");
507
815
  if (recoverAddress(digest, value.key_signature) !== normalized.key) throw new Error("V8 payment key signature mismatch");
508
816
  return { ...value, chain_id: jsonInteger(chainId), settlement_contract: contract, authorization: normalized };
509
817
  }
510
818
 
511
- function verifySignedReceipt(value) {
512
- if (!value || value.schema !== SIGNED_SCHEMA) throw new Error("unsupported signed V8 receipt");
513
- 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
+ }
514
829
  const receipt = value.receipt;
515
830
  if (!receipt || typeof receipt !== "object") throw new Error("V8 signed receipt is missing");
516
831
  const normalizedReceipt = {
@@ -527,25 +842,84 @@ function verifySignedReceipt(value) {
527
842
  if (normalizedReceipt.authorization_hash !== authorization.authorization_hash) throw new Error("V8 signed receipt authorization mismatch");
528
843
  if (normalizedReceipt.relay !== authorization.authorization.relay) throw new Error("V8 signed receipt Relay payout mismatch");
529
844
  if (value.key_signature !== authorization.key_signature) throw new Error("V8 signed receipt payment signature mismatch");
530
- 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);
531
847
  if (recoverAddress(digest, value.provider_signature) !== normalizedReceipt.provider_signer) throw new Error("V8 Provider signature mismatch");
532
848
  if (recoverAddress(digest, value.relay_signature) !== authorization.authorization.relay_signer) throw new Error("V8 Relay signature mismatch");
533
849
  return { authorization, receipt: normalizedReceipt };
534
850
  }
535
851
 
852
+ const responseControls = new WeakMap();
853
+
854
+ function deadlineError() {
855
+ const error = new Error("request deadline exceeded");
856
+ error.name = "TimeoutError";
857
+ error.statusCode = 504;
858
+ return error;
859
+ }
860
+
861
+ function remainingMs(deadline) {
862
+ if (!Number.isFinite(deadline)) return Infinity;
863
+ const left = deadline - performance.now();
864
+ if (left <= 0) throw deadlineError();
865
+ return Math.max(1, Math.ceil(left));
866
+ }
867
+
868
+ async function withinDeadline(promise, deadline) {
869
+ if (!Number.isFinite(deadline)) return promise;
870
+ let timer;
871
+ try {
872
+ const milliseconds = remainingMs(deadline);
873
+ return await Promise.race([promise, new Promise((_, reject) => {
874
+ timer = setTimeout(() => reject(deadlineError()), milliseconds);
875
+ timer.unref?.();
876
+ })]);
877
+ } finally { clearTimeout(timer); }
878
+ }
879
+
536
880
  async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
537
881
  const controller = new AbortController();
538
- const timer = setTimeout(() => controller.abort(), timeoutMs);
882
+ const timer = setTimeout(() => controller.abort(deadlineError()), timeoutMs);
539
883
  timer.unref?.();
884
+ const external = options.signal;
885
+ const abort = () => controller.abort(external.reason);
886
+ external?.addEventListener("abort", abort, { once: true });
887
+ if (external?.aborted) abort();
888
+ const cleanup = () => { clearTimeout(timer); external?.removeEventListener("abort", abort); };
540
889
  try {
541
- return await fetch(url, { redirect: "error", ...options, signal: controller.signal });
542
- } finally {
543
- clearTimeout(timer);
544
- }
890
+ const response = await fetch(url, { redirect: "error", ...options, signal: controller.signal });
891
+ // Keep the same deadline alive until the complete body is consumed.
892
+ responseControls.set(response, { controller, cleanup });
893
+ return response;
894
+ } catch (error) { cleanup(); throw error; }
545
895
  }
546
896
 
547
897
  async function readJsonResponse(response) {
548
- const 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); }
549
923
  try {
550
924
  return text ? JSON.parse(text) : {};
551
925
  } catch {
@@ -557,16 +931,112 @@ function openaiError(message, type = "server_error", code = type) {
557
931
  return { error: { message: String(message), type, param: null, code } };
558
932
  }
559
933
 
560
- function parseNetworkConfig(path) {
561
- if (!path || !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");
562
937
  try {
563
938
  const network = JSON.parse(readFileSync(path, "utf8"));
564
939
  const deploymentPath = network.deployment ? join(dirname(path), network.deployment) : path;
565
- const deployment = network.deployment && existsSync(deploymentPath)
566
- ? JSON.parse(readFileSync(deploymentPath, "utf8"))
567
- : network;
568
- if (Number(deployment.protocol_version || 0) !== 8) return { ...DEFAULT_NETWORK };
569
- 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,
570
1040
  chain_id: Number(deployment.chain_id),
571
1041
  network_name: Number(deployment.chain_id) === DEFAULT_CHAIN_ID ? "Sepolia testnet" : "EVM network",
572
1042
  settlement_contract: normalizeAddress(deployment.settlement),
@@ -576,11 +1046,168 @@ function parseNetworkConfig(path) {
576
1046
  rpc_urls: (network.settlement_rpc_urls || [network.settlement_rpc_url]).filter(Boolean),
577
1047
  explorer_url: Number(deployment.chain_id) === DEFAULT_CHAIN_ID ? "https://sepolia.etherscan.io" : "",
578
1048
  };
579
- } catch {
580
- 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}`);
581
1053
  }
582
1054
  }
583
1055
 
1056
+ function validateV9CommonDeployment(value) {
1057
+ const maxTtl = value.max_authorization_ttl_seconds ?? 3600;
1058
+ const deadlineSeconds = value.authorization_deadline_seconds ?? 900;
1059
+ if (![3600, 10800].includes(maxTtl) || !Number.isSafeInteger(deadlineSeconds) || deadlineSeconds <= 0
1060
+ || deadlineSeconds + AUTHORIZATION_CLOCK_SKEW_SECONDS > maxTtl) throw new Error("invalid V9 authorization timing policy");
1061
+ const required = ["chain_id", "deployer", "stablecoin", "settlement", "treasury", "governance", "channel", "channel_hash", "pricing_version", "pricing_hash", "reward_token", "policy", "network_id", "channel_id", "backend_policy"];
1062
+ if (required.some((name) => value[name] === undefined)) throw new Error("V9 deployment requires explicit network and dispute policy fields");
1063
+ const uint = (item, name, bits = 256) => {
1064
+ if (!(typeof item === "number" && Number.isSafeInteger(item)) && !(typeof item === "string" && /^[0-9]+$/.test(item))) throw new Error(`invalid V9 ${name}`);
1065
+ const result = BigInt(item);
1066
+ if (result < 0n || result >= (1n << BigInt(bits))) throw new Error(`invalid V9 ${name}`);
1067
+ return result;
1068
+ };
1069
+ if (!Number.isSafeInteger(value.chain_id) || value.chain_id <= 0 || uint(value.pricing_version, "pricing_version", 64) === 0n) throw new Error("invalid V9 chain/pricing version");
1070
+ for (const name of ["deployer", "stablecoin", "settlement", "treasury", "governance"]) nonzeroAddress(value[name], name);
1071
+ for (const name of ["channel_hash", "pricing_hash"]) if (normalizeBytes32(value[name], name) === `0x${"0".repeat(64)}`) throw new Error(`zero V9 ${name}`);
1072
+ for (const name of ["channel", "network_id", "channel_id", "backend_policy"]) if (typeof value[name] !== "string" || !value[name].trim()) throw new Error(`missing V9 ${name}`);
1073
+ const fields = ["dispute_window", "arbitration_timeout", "consumer_withdrawal_delay", "reporter_bond", "slash_bps", "slash_cap", "reporter_bounty_bps", "stable_bounty_cap", "token_reward", "token_reward_cap", "token_minimum_exposure", "token_minimum_penalty", "bond_penalty_recipient"];
1074
+ if (!value.policy || typeof value.policy !== "object" || Array.isArray(value.policy)
1075
+ || Object.keys(value.policy).length !== fields.length || fields.some((name) => value.policy[name] === undefined)) throw new Error("V9 complete dispute policy required");
1076
+ const policy = Object.fromEntries(fields.slice(0, -1).map((name) => [name, uint(value.policy[name], name)]));
1077
+ const penalty = nonzeroAddress(value.policy.bond_penalty_recipient, "bond_penalty_recipient");
1078
+ for (const name of fields.slice(0, 3)) if (policy[name] === 0n || policy[name] > 2592000n) throw new Error(`invalid V9 ${name}`);
1079
+ if (policy.reporter_bond === 0n || policy.slash_bps === 0n || policy.slash_bps > 10000n || policy.slash_cap === 0n
1080
+ || policy.reporter_bounty_bps === 0n || policy.reporter_bounty_bps >= 10000n || policy.stable_bounty_cap === 0n || policy.stable_bounty_cap > policy.slash_cap) throw new Error("invalid V9 slash/bounty policy");
1081
+ const reward = normalizeAddress(value.reward_token);
1082
+ if (reward === ZERO_ADDRESS) {
1083
+ if (["token_reward", "token_reward_cap", "token_minimum_exposure", "token_minimum_penalty"].some((name) => policy[name] !== 0n)) throw new Error("V9 disabled reward policy allocates tokens");
1084
+ } else if (reward === normalizeAddress(value.stablecoin) || policy.token_reward === 0n || policy.token_reward > policy.token_reward_cap
1085
+ || policy.token_minimum_exposure === 0n || policy.token_minimum_penalty === 0n || policy.token_minimum_penalty > policy.slash_cap) throw new Error("invalid V9 token reward policy");
1086
+ return { uint, penalty, reward };
1087
+ }
1088
+
1089
+ function validateV9Deployment(value, { allowControlledTest = false } = {}) {
1090
+ const { uint, penalty, reward } = validateV9CommonDeployment(value);
1091
+ const required = ["adjudicators", "adjudication_threshold", "adjudicator_operators", "independence_attested"];
1092
+ if (required.some((name) => value[name] === undefined)) throw new Error("V9 deployment requires explicit independent committee fields");
1093
+ if (!Array.isArray(value.adjudicators) || value.adjudicators.length < 2 || value.adjudicators.length > 16) throw new Error("V9 explicit independent committee required");
1094
+ const judges = value.adjudicators.map((judge) => nonzeroAddress(judge, "adjudicator"));
1095
+ const threshold = uint(value.adjudication_threshold, "adjudication_threshold", 16);
1096
+ if (new Set(judges).size !== judges.length || threshold < 2n || threshold <= BigInt(Math.floor(judges.length / 2)) || threshold > BigInt(judges.length)) throw new Error("V9 invalid committee quorum");
1097
+ const authorities = [value.settlement, value.governance, value.treasury].map((item) => normalizeAddress(item)).concat(penalty);
1098
+ if (normalizeAddress(value.settlement) === penalty || normalizeAddress(value.settlement) === normalizeAddress(value.treasury) || judges.some((judge) => authorities.includes(judge))) throw new Error("V9 committee conflicts with settlement authorities");
1099
+ const operators = value.adjudicator_operators;
1100
+ const controlled = value.committee_mode === "controlled_test";
1101
+ if (controlled && !(allowControlledTest === true && [11155111,31337,1337].includes(value.chain_id) && value.network_id.endsWith("-controlled-test") && value.independence_attested === false && reward === ZERO_ADDRESS)) throw new Error("controlled test requires explicit --controlled-test opt-in, test chain and disabled rewards");
1102
+ if (value.committee_mode !== undefined && !["controlled_test", "independent_users"].includes(value.committee_mode)) throw new Error("unknown committee mode");
1103
+ if (!operators || typeof operators !== "object" || Array.isArray(operators) || (!controlled && value.independence_attested !== true)) throw new Error("V9 independent operator attestation required");
1104
+ const entries = Object.entries(operators).map(([judge, operator]) => [nonzeroAddress(judge, "adjudicator operator"), operator]);
1105
+ if (entries.length !== judges.length || new Set(entries.map(([judge]) => judge)).size !== judges.length
1106
+ || entries.some(([judge, operator]) => !judges.includes(judge) || typeof operator !== "string" || !operator.trim())
1107
+ || new Set(entries.map(([, operator]) => operator.trim().toLowerCase())).size !== (controlled ? 1 : judges.length)) throw new Error("V9 distinct operator identities required");
1108
+ }
1109
+
1110
+ function validateDynamicV10Deployment(value, { allowControlledTest = false } = {}) {
1111
+ validateV9CommonDeployment(value);
1112
+ const required = ["jury_registry", "jury_registry_governance", "reputation_authority", "minimum_provider_reputation",
1113
+ "jury_size", "adjudication_threshold", "jury_selection_delay_blocks", "jury_randomness",
1114
+ "jury_decision_policy_hash", "genesis_hash", "deployment_block",
1115
+ "deployment_block_hash", "settlement_runtime_code_keccak256",
1116
+ REPUTATION_HISTORY_FIELD];
1117
+ if (required.some((name) => value[name] === undefined)) throw new Error("V10 dynamic Provider jury requires an explicit registry and reputation policy");
1118
+ const forbidden = DYNAMIC_JURY_FORBIDDEN_FIELDS.filter((name) => Object.prototype.hasOwnProperty.call(value, name));
1119
+ if (forbidden.length) throw new Error(`V10 dynamic Provider jury deployment contains forbidden static committee fields: ${forbidden.join(", ")}`);
1120
+ const registry = nonzeroAddress(value.jury_registry, "jury_registry");
1121
+ const governance = nonzeroAddress(value.jury_registry_governance, "jury_registry_governance");
1122
+ const authority = nonzeroAddress(value.reputation_authority, "reputation_authority");
1123
+ const settlement = nonzeroAddress(value.settlement, "settlement");
1124
+ if (governance !== normalizeAddress(value.governance)
1125
+ || new Set([registry, governance, authority, settlement]).size !== 4) {
1126
+ throw new Error("V10 dynamic Provider jury authority bindings are unsafe");
1127
+ }
1128
+ const integer = (item, name, { minimum = 0, maximum = Number.MAX_SAFE_INTEGER } = {}) => {
1129
+ if (!Number.isSafeInteger(item) || item < minimum || item > maximum) throw new Error(`invalid V10 dynamic jury ${name}`);
1130
+ return item;
1131
+ };
1132
+ integer(value.minimum_provider_reputation, "minimum_provider_reputation", { minimum: 1 });
1133
+ const jurySize = integer(value.jury_size, "jury_size", { minimum: 3, maximum: MAX_DYNAMIC_JURY_SIZE });
1134
+ const threshold = integer(value.adjudication_threshold, "adjudication_threshold", { minimum: 2, maximum: jurySize });
1135
+ integer(value.jury_selection_delay_blocks, "jury_selection_delay_blocks", { minimum: 1, maximum: 64 });
1136
+ if (threshold <= Math.floor(jurySize / 2)) throw new Error("V10 dynamic Provider jury requires a strict-majority threshold");
1137
+ const decisionPolicyHash = normalizeBytes32(value.jury_decision_policy_hash, "jury_decision_policy_hash");
1138
+ if (value.jury_decision_policy_hash !== decisionPolicyHash || decisionPolicyHash === ZERO_BYTES32) {
1139
+ throw new Error("V10 dynamic Provider jury requires a canonical nonzero SHA-256 decision policy hash");
1140
+ }
1141
+ const deploymentBlockHash = normalizeBytes32(value.deployment_block_hash, "deployment_block_hash");
1142
+ const settlementRuntimeHash = normalizeBytes32(
1143
+ value.settlement_runtime_code_keccak256, "settlement_runtime_code_keccak256",
1144
+ );
1145
+ if (!Number.isSafeInteger(value.deployment_block) || value.deployment_block <= 0
1146
+ || value.deployment_block_hash !== deploymentBlockHash
1147
+ || deploymentBlockHash === ZERO_BYTES32
1148
+ || value.settlement_runtime_code_keccak256 !== settlementRuntimeHash
1149
+ || settlementRuntimeHash === ZERO_BYTES32) {
1150
+ throw new Error("V10 dynamic Settlement deployment boundary is invalid");
1151
+ }
1152
+ validateReputationHistoryImport(value[REPUTATION_HISTORY_FIELD], value);
1153
+ if (value.jury_randomness !== DYNAMIC_JURY_RANDOMNESS
1154
+ || allowControlledTest !== true
1155
+ || value.chain_id !== DEFAULT_CHAIN_ID
1156
+ || typeof value.network_id !== "string" || !value.network_id.endsWith("-controlled-test")
1157
+ || normalizeAddress(value.reward_token) !== ZERO_ADDRESS) {
1158
+ throw new Error("V10 future-block jury selection requires explicit Sepolia controlled-test opt-in and disabled rewards");
1159
+ }
1160
+ }
1161
+
1162
+ function validateReputationHistoryImport(history, deployment) {
1163
+ if (!history || typeof history !== "object" || Array.isArray(history)
1164
+ || Object.keys(history).length !== REPUTATION_HISTORY_FIELDS.length
1165
+ || REPUTATION_HISTORY_FIELDS.some((name) => !Object.prototype.hasOwnProperty.call(history, name))) {
1166
+ throw new Error("V10 reputation_history_import must be a non-null exact lineage object");
1167
+ }
1168
+ const genesisHash = normalizeBytes32(deployment.genesis_hash, "genesis_hash");
1169
+ const sourceGenesis = normalizeBytes32(history.source_genesis_hash, "source_genesis_hash");
1170
+ const sourceRuntime = normalizeBytes32(history.source_runtime_code_hash, "source_runtime_code_hash");
1171
+ const sourceDeploymentBlockHash = normalizeBytes32(
1172
+ history.source_deployment_block_hash, "source_deployment_block_hash",
1173
+ );
1174
+ const sourceHistoryThroughBlockHash = normalizeBytes32(
1175
+ history.source_history_through_block_hash, "source_history_through_block_hash",
1176
+ );
1177
+ const artifactRoot = normalizeBytes32(history.artifact_root, "artifact_root");
1178
+ const sourceSettlement = normalizeAddress(history.source_settlement_contract, "source_settlement_contract");
1179
+ if (deployment.genesis_hash !== genesisHash || genesisHash === ZERO_BYTES32
1180
+ || history.schema !== REPUTATION_HISTORY_SCHEMA
1181
+ || typeof history.source_network_id !== "string" || !history.source_network_id
1182
+ || history.source_network_id !== history.source_network_id.trim()
1183
+ || history.source_network_id.length > 160
1184
+ || history.source_network_id === deployment.network_id
1185
+ || ![9, 10].includes(history.source_protocol_version)
1186
+ || !Number.isSafeInteger(history.source_chain_id)
1187
+ || history.source_chain_id !== deployment.chain_id
1188
+ || history.source_genesis_hash !== sourceGenesis || sourceGenesis !== genesisHash
1189
+ || history.source_settlement_contract !== sourceSettlement
1190
+ || sourceSettlement === ZERO_ADDRESS || sourceSettlement === normalizeAddress(deployment.settlement)
1191
+ || history.source_runtime_code_hash !== sourceRuntime || sourceRuntime === ZERO_BYTES32
1192
+ || !Number.isSafeInteger(history.source_deployment_block)
1193
+ || history.source_deployment_block <= 0
1194
+ || history.source_deployment_block_hash !== sourceDeploymentBlockHash
1195
+ || sourceDeploymentBlockHash === ZERO_BYTES32
1196
+ || !Number.isSafeInteger(history.source_history_through_block)
1197
+ || history.source_history_through_block < history.source_deployment_block
1198
+ || history.source_history_through_block_hash !== sourceHistoryThroughBlockHash
1199
+ || sourceHistoryThroughBlockHash === ZERO_BYTES32
1200
+ || !Number.isSafeInteger(history.confirmations)
1201
+ || history.confirmations < 2 || history.confirmations > 256
1202
+ || typeof history.artifact_sha256 !== "string"
1203
+ || !/^[0-9a-f]{64}$/.test(history.artifact_sha256)
1204
+ || history.artifact_sha256 === "0".repeat(64)
1205
+ || history.artifact_root !== artifactRoot || artifactRoot === ZERO_BYTES32) {
1206
+ throw new Error("V10 reputation history lineage is invalid or not a prior same-chain deployment");
1207
+ }
1208
+ return history;
1209
+ }
1210
+
584
1211
  function defaultDataDir() {
585
1212
  return join(homedir(), ".mycomesh", "consumer");
586
1213
  }
@@ -605,6 +1232,24 @@ function parseRelayUrls(env, explicit) {
605
1232
  return urls;
606
1233
  }
607
1234
 
1235
+ function resolveTlsCaFile(configured, manifestPath, { allowControlledTest = false } = {}) {
1236
+ if (configured === undefined || configured === null || configured === "") return null;
1237
+ if (!allowControlledTest) {
1238
+ throw new Error("custom Relay TLS CA is only allowed for an explicit controlled test (--controlled-test)");
1239
+ }
1240
+ if (typeof configured !== "string" || configured.length > 1024 || configured.includes("\0")) {
1241
+ throw new Error("tls_ca_file must be a valid path");
1242
+ }
1243
+ const resolved = configured.startsWith("/") ? configured : join(dirname(manifestPath), configured);
1244
+ if (!existsSync(resolved)) throw new Error("configured Relay TLS CA file is missing");
1245
+ let certificate;
1246
+ try { certificate = readFileSync(resolved); } catch { throw new Error("configured Relay TLS CA file is unreadable"); }
1247
+ if (!certificate.toString("ascii").includes("BEGIN CERTIFICATE")) {
1248
+ throw new Error("configured Relay TLS CA file does not contain a PEM certificate");
1249
+ }
1250
+ return resolved;
1251
+ }
1252
+
608
1253
  function sameSecret(first, second) {
609
1254
  const a = Buffer.from(String(first || ""));
610
1255
  const b = Buffer.from(String(second || ""));
@@ -618,23 +1263,79 @@ export class NativeConsumerState {
618
1263
  mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
619
1264
  try { chmodSync(this.dataDir, 0o700); } catch {}
620
1265
  this.baseUrl = resolveBaseUrl(options.baseUrl || env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL || DEFAULT_BASE_URL);
621
- this.relayUrls = parseRelayUrls(env, options.relayUrls);
622
1266
  this.maxFeeUnits = options.maxFeeUnits || Number(env.MYCOMESH_V8_MAX_FEE_UNITS || DEFAULT_MAX_FEE_UNITS);
623
1267
  if (!Number.isSafeInteger(this.maxFeeUnits) || this.maxFeeUnits <= 0) throw new Error("max fee must be a positive integer");
624
1268
  this.timeoutMs = options.timeoutMs ?? Number(env.MYCOMESH_V8_REQUEST_TIMEOUT_SECONDS || 300) * 1000;
625
1269
  this.healthTimeoutMs = options.healthTimeoutMs ?? Number(env.MYCOMESH_V8_HEALTH_TIMEOUT_SECONDS || 10) * 1000;
626
- this.network = parseNetworkConfig(options.networkConfig || env.MYCOMESH_CONSUMER_NETWORK_CONFIG);
1270
+ this.network = parseNetworkConfig(options.networkConfig || env.MYCOMESH_CONSUMER_NETWORK_CONFIG, { allowControlledTest: options.allowControlledTest === true });
1271
+ this.relayUrls = parseRelayUrls(env, options.relayUrls || env.MYCOMESH_V8_RELAY_URLS
1272
+ || env.MYCOMESH_CONSUMER_RELAY_URL || this.network.relay_urls?.join(","));
1273
+ this.staticRelayUrls = [...this.relayUrls];
627
1274
  if (env.MYCOMESH_CONSUMER_SETTLEMENT_RPC_URLS) {
628
1275
  this.network.rpc_urls = String(env.MYCOMESH_CONSUMER_SETTLEMENT_RPC_URLS).split(",").map((item) => item.trim()).filter(Boolean);
629
1276
  }
1277
+ // A controlled IP testnet may use a private CA. The manifest path is
1278
+ // already trusted and resolves relative CA paths; an explicit env/option
1279
+ // is supported for generated manifests whose CA is staged separately.
1280
+ const caFile = options.caFile || env.MYCOMESH_CONSUMER_CA_FILE || this.network.tls_ca_file;
1281
+ if (caFile && options.allowControlledTest !== true) {
1282
+ throw new Error("custom Relay TLS CA is only allowed for an explicit controlled test (--controlled-test)");
1283
+ }
1284
+ this.tlsCaFile = caFile ? (String(caFile).startsWith("/") ? String(caFile) : caFile) : null;
1285
+ this.tlsCa = null;
1286
+ this.tlsCaBundle = rootCertificates;
1287
+ if (this.tlsCaFile) {
1288
+ if (!existsSync(this.tlsCaFile)) throw new Error("configured Relay TLS CA file is missing");
1289
+ try { this.tlsCa = readFileSync(this.tlsCaFile); } catch { throw new Error("configured Relay TLS CA file is unreadable"); }
1290
+ if (!this.tlsCa.toString("ascii").includes("BEGIN CERTIFICATE")) throw new Error("configured Relay TLS CA file does not contain a PEM certificate");
1291
+ // `ca` replaces (rather than augments) Node's defaults. Preserve public
1292
+ // trust roots so a manifest may safely mix private test Relays and
1293
+ // publicly certified fallback Relays.
1294
+ this.tlsCaBundle = [...rootCertificates, this.tlsCa.toString("utf8")];
1295
+ }
630
1296
  this.proxy = options.proxy || env.MYCOMESH_CONSUMER_PROXY || "";
631
- 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();
632
1306
  this.healthCache = new Map();
1307
+ this.healthRequests = new Map();
1308
+ // `/ready` is a bounded Relay/model liveness probe used by local tooling.
1309
+ // It never claims paid readiness; `/paid-ready` and inference perform the
1310
+ // wallet, network, and chain-backed budget checks.
1311
+ this.readinessCache = null;
1312
+ this.readinessRequest = null;
1313
+ this.preferredRpcUrl = null;
1314
+ this.capacityChannelsRequest = null;
1315
+ this.relayFailures = new Map();
1316
+ this.sessions = new ConsumerSessions();
1317
+ this.sessionRoutes = new Map();
1318
+ this.sessionSelections = new Map();
1319
+ this.relayInFlight = new Map();
1320
+ this.relayReservations = new Map();
1321
+ this.relaySelectionCursor = 0;
1322
+ this.activeInferenceRequests = 0;
633
1323
  this.historyPath = join(this.dataDir, "receipt-history.jsonl");
634
1324
  this.pendingKeyPath = join(this.dataDir, "pending-payment-key");
635
1325
  this.paymentKeyFromEnv = Boolean(String(env.MYCOMESH_V8_PAYMENT_KEY || "").trim());
636
1326
  this.paymentKey = this.loadPaymentKey(env.MYCOMESH_V8_PAYMENT_KEY);
637
1327
  this.paymentAddress = paymentKeyAddress(this.paymentKey);
1328
+ this.historyDir = options.historyDir || env.MYCOMESH_CONSUMER_HISTORY_DIR;
1329
+ this.historyLedger = new ConsumerHistoryLedger({
1330
+ localPath: this.historyPath,
1331
+ sharedDir: this.historyDir,
1332
+ chainId: this.network.chain_id,
1333
+ contract: this.network.settlement_contract,
1334
+ keyAddress: this.paymentAddress,
1335
+ });
1336
+ this.historySync = null;
1337
+ this.historySyncAt = 0;
1338
+ this.historySyncError = null;
638
1339
  this.tunnelCommand = options.tunnelCommand || env.MYCOMESH_CONSUMER_TUNNEL_COMMAND || "cloudflared";
639
1340
  this.tunnelSpawn = options.tunnelSpawn || defaultSpawn;
640
1341
  this.share = null;
@@ -666,19 +1367,32 @@ export class NativeConsumerState {
666
1367
  return `export OPENAI_BASE_URL=${shellQuote(this.baseUrl)}\nexport OPENAI_API_KEY=${shellQuote(this.paymentKey)}`;
667
1368
  }
668
1369
 
1370
+ capabilities(health) {
1371
+ const version = protocolVersion(this.network.protocol_version);
1372
+ const capabilities = health?.[`v${version}`];
1373
+ if (!capabilities || typeof capabilities !== "object") throw new Error(`Relay has no Settlement V${version} capabilities`);
1374
+ return capabilities;
1375
+ }
1376
+
669
1377
  healthPayload() {
1378
+ this.syncDiscoveredRelays();
670
1379
  return {
671
1380
  ok: true,
672
- protocol: "mycomesh-consumer/v8",
1381
+ protocol: `mycomesh-consumer/v${this.network.protocol_version}`,
673
1382
  runtime: "node-native",
674
1383
  docker: false,
675
1384
  browser_app_ready: true,
676
1385
  gateway_dependency: false,
677
- routing_mode: "relay-scheduled-payment-key-v8",
1386
+ routing_mode: `relay-scheduled-payment-key-v${this.network.protocol_version}`,
678
1387
  relay_urls: this.relayUrls,
1388
+ relay_discovery: this.relayDiscovery ? { enabled: true, active: this.relayDiscovery.active().length,
1389
+ error: this.relayDiscovery.lastError } : { enabled: false },
679
1390
  payment_key_address: this.paymentAddress,
680
1391
  payment_key_persisted: !this.paymentKeyFromEnv,
681
1392
  wallet_unlocked: this.paymentUnlocked,
1393
+ // This reports trust configuration only; server-certificate verification
1394
+ // is completed on each HTTPS request and is never claimed in advance.
1395
+ relay_tls: { mode: this.tlsCa ? "controlled_test_ca" : "system_trust_store", configured: Boolean(this.tlsCa) },
682
1396
  responses_transports: ["http", "sse"],
683
1397
  };
684
1398
  }
@@ -718,13 +1432,36 @@ export class NativeConsumerState {
718
1432
  this.walletChallenge = null;
719
1433
  const recovered = recoverAddress(walletMessageDigest(challenge.message), raw?.signature);
720
1434
  if (recovered !== wallet) throw new Error("wallet signature does not match the selected account");
721
- const grant = await this.keyGrant(this.paymentAddress);
1435
+ let grant;
1436
+ try { grant = await this.keyGrant(this.paymentAddress); }
1437
+ catch {
1438
+ throw Object.assign(new Error("Cannot verify payment-key ownership on-chain. Please retry when the connection recovers."), {
1439
+ code: "wallet_verification_unavailable", selected_wallet: wallet, payment_key_address: this.paymentAddress,
1440
+ });
1441
+ }
722
1442
  if (grant.owner !== ZERO_ADDRESS && grant.owner !== wallet) {
723
- throw new Error("this local payment key belongs to a different 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
+ });
724
1446
  }
1447
+ let paymentUnlocked = grant.active && grant.owner === wallet;
1448
+ if (this.network.protocol_version === 10 && !paymentUnlocked) {
1449
+ // Revocation prevents new channels; existing owner-approved snapshots
1450
+ // keep their bounded authorization until their original expiry.
1451
+ try {
1452
+ paymentUnlocked = (await this.capacityChannels()).some(c => !c.closed && c.consumer_owner === wallet
1453
+ && c.consumer_key === this.paymentAddress && Number(c.admit_until) >= Math.floor(Date.now()/1000));
1454
+ } catch {
1455
+ throw Object.assign(new Error("Cannot verify existing payment channels on-chain. Please retry when the connection recovers."), {
1456
+ code: "wallet_verification_unavailable", selected_wallet: wallet, payment_key_address: this.paymentAddress,
1457
+ });
1458
+ }
1459
+ }
1460
+ // Commit the session only after every chain check succeeds. A failed login
1461
+ // must neither create a partial session nor replace an existing owner session.
725
1462
  this.unlockedWallet = wallet;
726
1463
  this.managementToken = `myco_local_${randomBytes(32).toString("base64url")}`;
727
- this.paymentUnlocked = grant.active && grant.owner === wallet;
1464
+ this.paymentUnlocked = paymentUnlocked;
728
1465
  return { ok: true, token: this.managementToken, auth: this.walletAuthPayload(), grant };
729
1466
  }
730
1467
 
@@ -760,61 +1497,164 @@ export class NativeConsumerState {
760
1497
  }
761
1498
 
762
1499
  history(limit = 100) {
763
- if (!existsSync(this.historyPath)) return [];
764
- const lines = readFileSync(this.historyPath, "utf8").split("\n").filter(Boolean);
765
- const selected = limit <= 0 ? lines : lines.slice(-Math.min(Math.max(limit, 1), 500));
766
- return selected.map((line) => {
767
- try { return JSON.parse(line); } catch { return null; }
768
- }).filter((value) => value && typeof value === "object").reverse();
1500
+ return this.historyLedger.history(limit);
769
1501
  }
770
1502
 
771
- recordReceipt(relayUrl, endpoint, model, settlement, routeModel = model) {
1503
+ // Keep recovery state explicit for the local dashboard. The history ledger
1504
+ // is durable, so a restarted Consumer must show the operator whether a
1505
+ // request is still open, needs reconciliation, or is terminally failed;
1506
+ // callers must never infer this from a missing/zero fee.
1507
+ recoveryPayload(allHistory, authenticated = false) {
1508
+ if (!authenticated) {
1509
+ return {
1510
+ status: "locked",
1511
+ open_count: 0,
1512
+ uncertain_count: 0,
1513
+ failed_count: 0,
1514
+ requires_reconcile: false,
1515
+ last_checked_at: 0,
1516
+ error: null,
1517
+ };
1518
+ }
1519
+ const rows = Array.isArray(allHistory) ? allHistory : [];
1520
+ const openStatuses = new Set(["dispatching", "outcome_unknown", "queued", "pending", "submitted", "broadcast_unknown"]);
1521
+ const uncertainStatuses = new Set(["dispatching", "outcome_unknown", "submitted", "broadcast_unknown"]);
1522
+ const failedStatuses = new Set(["failed", "rejected"]);
1523
+ const open = rows.filter((item) => openStatuses.has(item.status));
1524
+ const uncertain = rows.filter((item) => uncertainStatuses.has(item.status)
1525
+ || ["broadcast_unknown", "confirmation_timeout"].includes(item.error_code));
1526
+ const failed = rows.filter((item) => failedStatuses.has(item.status));
1527
+ const error = this.historySyncError || null;
1528
+ const status = this.historySync ? "syncing"
1529
+ : error ? "degraded"
1530
+ : uncertain.length ? "needs_reconcile"
1531
+ : open.length ? "pending"
1532
+ : failed.length ? "failed" : "clear";
1533
+ return {
1534
+ status,
1535
+ open_count: open.length,
1536
+ uncertain_count: uncertain.length,
1537
+ failed_count: failed.length,
1538
+ requires_reconcile: Boolean(uncertain.length || error),
1539
+ last_checked_at: Number(this.historySyncAt || 0),
1540
+ error,
1541
+ };
1542
+ }
1543
+
1544
+ recordDispatch(relayUrl, endpoint, payment, sessionId, status = "dispatching") {
1545
+ const auth = payment.payment.authorization;
1546
+ this.historyLedger.append({
1547
+ timestamp: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
1548
+ request_id: auth.request_id, request_hash: auth.request_hash,
1549
+ key_address: this.paymentAddress, owner: this.unlockedWallet || undefined,
1550
+ chain_id: this.network.chain_id, settlement_contract: this.network.settlement_contract,
1551
+ relay_url: relayUrl, relay: payment.channel?.relay || auth.relay, capacity_channel_id: auth.channel_id, endpoint, model: payment.model,
1552
+ route_model: payment.model, session_id: sessionId, source: "local-dispatch",
1553
+ max_fee_units: Number(auth.max_fee), authorization_deadline: Number(auth.deadline),
1554
+ accepted: false, status,
1555
+ });
1556
+ }
1557
+
1558
+ recordReceipt(relayUrl, endpoint, model, settlement, routeModel = model, sessionId = null, contentVerification = "receipt-only") {
772
1559
  const signed = settlement?.signed_receipt;
773
1560
  const receipt = signed?.receipt;
774
1561
  const auth = signed?.authorization?.authorization;
775
1562
  if (!receipt || !auth) return;
1563
+ const jury = settlement?.jury_evidence_reference;
776
1564
  const entry = {
777
1565
  timestamp: Math.floor(Date.now() / 1000),
778
1566
  request_id: String(auth.request_id || ""),
1567
+ request_hash: auth.request_hash, capacity_channel_id: auth.channel_id, max_fee_units: Number(auth.max_fee),
779
1568
  settlement_key: String(settlement.settlement_key || ""),
780
- 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",
781
1571
  accepted: Boolean(settlement.accepted),
782
1572
  endpoint,
783
1573
  model,
784
1574
  route_model: routeModel,
785
1575
  relay_url: relayUrl,
786
1576
  provider: String(receipt.provider || ""),
1577
+ provider_signer: String(receipt.provider_signer || ""),
1578
+ response_hash: String(receipt.response_hash || ""),
1579
+ content_verification: contentVerification,
1580
+ owner: String(this.unlockedWallet || ""),
1581
+ key_address: this.paymentAddress,
1582
+ chain_id: this.network.chain_id,
1583
+ settlement_contract: this.network.settlement_contract,
1584
+ source: "local-receipt",
1585
+ session_id: sessionId,
787
1586
  input_tokens: Number(receipt.input_tokens || 0),
788
1587
  output_tokens: Number(receipt.output_tokens || 0),
789
1588
  actual_fee_units: Number(receipt.actual_fee || 0),
1589
+ authorization_deadline: Number(auth.deadline || 0),
1590
+ ...(jury ? {
1591
+ jury_evidence_hash: jury.evidence_hash,
1592
+ jury_report_id: jury.predicted_report_id,
1593
+ jury_reporter: jury.reporter,
1594
+ jury_origin_relay_public_key: jury.origin_relay_public_key,
1595
+ jury_reference_signature: jury.signature.signature,
1596
+ jury_reference_timestamp: jury.signature.timestamp,
1597
+ } : {}),
790
1598
  };
791
- appendFileSync(this.historyPath, `${stableStringify(entry)}\n`, { mode: 0o600 });
792
- try { chmodSync(this.historyPath, 0o600); } catch {}
1599
+ this.historyLedger.append(entry);
793
1600
  }
794
1601
 
795
1602
  async rpcValue(callback) {
796
1603
  const errors = [];
797
- for (const rpcUrl of this.network.rpc_urls) {
798
- 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;
799
1623
  }
800
- 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;
801
1628
  }
802
1629
 
803
1630
  async callRpc(rpcUrl, method, params) {
804
- const response = await fetchWithTimeout(rpcUrl, {
805
- method: "POST",
806
- headers: { "content-type": "application/json" },
807
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
808
- dispatcher: this.dispatcher,
809
- }, this.healthTimeoutMs);
810
- const payload = await readJsonResponse(response);
811
- if (!response.ok || payload.error) throw new Error(payload.error?.message || `RPC ${response.status}`);
812
- return payload.result;
813
- }
814
-
815
- 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") {
816
1656
  const data = `0x${bytesToHex(keccak_256(Buffer.from(signature, "ascii")).slice(0, 4))}${args.map((arg) => abiWord(arg).toString("hex")).join("")}`;
817
- return this.callRpc(rpcUrl, "eth_call", [{ to: normalizeAddress(contract), data }, "latest"]);
1657
+ return this.callRpc(rpcUrl, "eth_call", [{ to: normalizeAddress(contract), data }, blockTag]);
818
1658
  }
819
1659
 
820
1660
  async keyGrant(address) {
@@ -829,18 +1669,216 @@ export class NativeConsumerState {
829
1669
  };
830
1670
  }
831
1671
 
1672
+ async dynamicJuryNetworkReady(rpc) {
1673
+ if (this.network.committee_mode !== DYNAMIC_PROVIDER_AI_COMMITTEE) return true;
1674
+ const settlement = this.network.settlement_contract;
1675
+ const registry = this.network.jury_registry;
1676
+ const history = this.network.reputation_history_import;
1677
+ const settlementDeploymentBlock = await this.callRpc(
1678
+ rpc, "eth_getBlockByNumber", [`0x${this.network.deployment_block.toString(16)}`, false],
1679
+ );
1680
+ if (!settlementDeploymentBlock
1681
+ || BigInt(settlementDeploymentBlock.number) !== BigInt(this.network.deployment_block)
1682
+ || normalizeBytes32(
1683
+ settlementDeploymentBlock.hash, "Settlement deployment block hash",
1684
+ ) !== this.network.deployment_block_hash) {
1685
+ throw new Error("Settlement deployment block is not canonical");
1686
+ }
1687
+ const settlementRuntime = String(await this.callRpc(
1688
+ rpc, "eth_getCode", [settlement, {
1689
+ blockHash: this.network.deployment_block_hash, requireCanonical: true,
1690
+ }],
1691
+ ) || "");
1692
+ if (!/^0x[0-9a-f]+$/.test(settlementRuntime)
1693
+ || settlementRuntime === "0x" || /^0x0+$/.test(settlementRuntime)) {
1694
+ throw new Error("Settlement runtime is unavailable at its deployment boundary");
1695
+ }
1696
+ const settlementRuntimeHash = `0x${bytesToHex(keccak_256(hexToBytes(
1697
+ settlementRuntime, "Settlement deployment runtime",
1698
+ )))}`;
1699
+ if (settlementRuntimeHash !== this.network.settlement_runtime_code_keccak256) {
1700
+ throw new Error("Settlement runtime differs from its deployment manifest pin");
1701
+ }
1702
+ const genesis = await this.callRpc(rpc, "eth_getBlockByNumber", ["0x0", false]);
1703
+ if (!genesis || normalizeBytes32(genesis.hash, "RPC genesis hash") !== this.network.genesis_hash) {
1704
+ throw new Error("Settlement RPC genesis differs from reputation history lineage");
1705
+ }
1706
+ const sourceDeploymentNumber = BigInt(history.source_deployment_block);
1707
+ const sourceThroughNumber = BigInt(history.source_history_through_block);
1708
+ const [sourceDeploymentBlock, sourceThroughBlock, sourcePredeploymentBlock, sourceHeadRaw] = await Promise.all([
1709
+ this.callRpc(rpc, "eth_getBlockByNumber", [`0x${sourceDeploymentNumber.toString(16)}`, false]),
1710
+ this.callRpc(rpc, "eth_getBlockByNumber", [`0x${sourceThroughNumber.toString(16)}`, false]),
1711
+ this.callRpc(rpc, "eth_getBlockByNumber", [`0x${(sourceDeploymentNumber - 1n).toString(16)}`, false]),
1712
+ this.callRpc(rpc, "eth_blockNumber", []),
1713
+ ]);
1714
+ const sourceHead = BigInt(sourceHeadRaw);
1715
+ const sourceBlocks = [
1716
+ [sourceDeploymentBlock, sourceDeploymentNumber, history.source_deployment_block_hash, "deployment"],
1717
+ [sourceThroughBlock, sourceThroughNumber, history.source_history_through_block_hash, "history cutoff"],
1718
+ ];
1719
+ for (const [block, expectedNumber, expectedHash, label] of sourceBlocks) {
1720
+ if (!block || BigInt(block.number) !== expectedNumber
1721
+ || normalizeBytes32(block.hash, `source ${label} block hash`) !== expectedHash) {
1722
+ throw new Error(`reputation history source ${label} block is not canonical`);
1723
+ }
1724
+ }
1725
+ if (!sourcePredeploymentBlock
1726
+ || BigInt(sourcePredeploymentBlock.number) !== sourceDeploymentNumber - 1n) {
1727
+ throw new Error("reputation history source predeployment block is unavailable");
1728
+ }
1729
+ if (sourceHead - sourceThroughNumber + 1n < BigInt(history.confirmations)) {
1730
+ throw new Error("reputation history cutoff does not have the required confirmations");
1731
+ }
1732
+ const predeploymentHash = normalizeBytes32(
1733
+ sourcePredeploymentBlock.hash, "source predeployment block hash",
1734
+ );
1735
+ const predeploymentCode = String(await this.callRpc(
1736
+ rpc, "eth_getCode", [history.source_settlement_contract, {
1737
+ blockHash: predeploymentHash, requireCanonical: true,
1738
+ }],
1739
+ ) || "");
1740
+ if (predeploymentCode !== "0x") {
1741
+ throw new Error("historical Settlement existed before its pinned deployment block");
1742
+ }
1743
+ const historicalCode = String(await this.callRpc(
1744
+ rpc, "eth_getCode", [history.source_settlement_contract, {
1745
+ blockHash: history.source_deployment_block_hash, requireCanonical: true,
1746
+ }],
1747
+ ) || "");
1748
+ if (!/^0x[0-9a-f]+$/.test(historicalCode) || historicalCode === "0x" || /^0x0+$/.test(historicalCode)) {
1749
+ throw new Error("historical Settlement contract is unavailable on the configured network");
1750
+ }
1751
+ const historicalRuntimeHash = `0x${bytesToHex(keccak_256(hexToBytes(
1752
+ historicalCode, "historical Settlement runtime",
1753
+ )))}`;
1754
+ if (historicalRuntimeHash !== history.source_runtime_code_hash) {
1755
+ throw new Error("historical Settlement runtime differs from reputation history lineage");
1756
+ }
1757
+ const registryCode = String(await this.callRpc(rpc, "eth_getCode", [registry, "latest"]) || "");
1758
+ if (!/^0x[0-9a-f]+$/i.test(registryCode) || registryCode === "0x" || /^0x0+$/i.test(registryCode)) {
1759
+ throw new Error("jury registry contract is unavailable on the configured network");
1760
+ }
1761
+ const [settlementRegistry, settlementThreshold, registrySettlement, registryGovernance, reputationAuthority,
1762
+ bondPenaltyRecipient, minimumReputation, jurySize, registryThreshold, selectionDelay, randomnessMode,
1763
+ rosterVersion, providerCount, canFormJury] = await Promise.all([
1764
+ this.contractCall(rpc, settlement, "juryRegistry()", []),
1765
+ this.contractCall(rpc, settlement, "adjudicationThreshold()", []),
1766
+ this.contractCall(rpc, registry, "settlement()", []),
1767
+ this.contractCall(rpc, registry, "governance()", []),
1768
+ this.contractCall(rpc, registry, "reputationAuthority()", []),
1769
+ this.contractCall(rpc, registry, "bondPenaltyRecipient()", []),
1770
+ this.contractCall(rpc, registry, "minimumReputation()", []),
1771
+ this.contractCall(rpc, registry, "jurySize()", []),
1772
+ this.contractCall(rpc, registry, "threshold()", []),
1773
+ this.contractCall(rpc, registry, "selectionDelayBlocks()", []),
1774
+ this.contractCall(rpc, registry, "RANDOMNESS_MODE_HASH()", []),
1775
+ this.contractCall(rpc, registry, "rosterVersion()", []),
1776
+ this.contractCall(rpc, registry, "providerCount()", []),
1777
+ this.contractCall(rpc, registry, "canFormJury()", []),
1778
+ ]);
1779
+ const checks = [
1780
+ [decodeAbiAddress(settlementRegistry, "Settlement juryRegistry"), registry, "Settlement juryRegistry"],
1781
+ [decodeAbiUint(settlementThreshold, "Settlement adjudicationThreshold", 16), BigInt(this.network.adjudication_threshold), "Settlement adjudicationThreshold"],
1782
+ [decodeAbiAddress(registrySettlement, "jury registry settlement"), settlement, "jury registry settlement"],
1783
+ [decodeAbiAddress(registryGovernance, "jury registry governance"), this.network.jury_registry_governance, "jury registry governance"],
1784
+ [decodeAbiAddress(reputationAuthority, "jury registry reputationAuthority"), this.network.reputation_authority, "jury registry reputationAuthority"],
1785
+ [decodeAbiAddress(bondPenaltyRecipient, "jury registry bondPenaltyRecipient"), this.network.bond_penalty_recipient, "jury registry bondPenaltyRecipient"],
1786
+ [decodeAbiUint(minimumReputation, "jury registry minimumReputation", 64), BigInt(this.network.minimum_provider_reputation), "jury registry minimumReputation"],
1787
+ [decodeAbiUint(jurySize, "jury registry jurySize", 16), BigInt(this.network.jury_size), "jury registry jurySize"],
1788
+ [decodeAbiUint(registryThreshold, "jury registry threshold", 16), BigInt(this.network.adjudication_threshold), "jury registry threshold"],
1789
+ [decodeAbiUint(selectionDelay, "jury registry selectionDelayBlocks", 16), BigInt(this.network.jury_selection_delay_blocks), "jury registry selectionDelayBlocks"],
1790
+ [normalizeBytes32(randomnessMode, "jury registry RANDOMNESS_MODE_HASH"), DYNAMIC_JURY_RANDOMNESS_HASH, "jury registry RANDOMNESS_MODE_HASH"],
1791
+ ];
1792
+ for (const [actual, expected, label] of checks) {
1793
+ if (actual !== expected) throw new Error(`${label} does not match the deployment manifest`);
1794
+ }
1795
+ const liveProviderCount = decodeAbiUint(providerCount, "jury registry providerCount");
1796
+ if (liveProviderCount < BigInt(this.network.jury_size)
1797
+ || liveProviderCount > BigInt(MAX_DYNAMIC_PROVIDERS)) throw new Error("jury registry Provider pool is outside supported bounds");
1798
+ if (decodeAbiUint(rosterVersion, "jury registry rosterVersion", 64) === 0n) throw new Error("jury registry reputation pool is uninitialized");
1799
+ if (!decodeAbiBool(canFormJury, "jury registry canFormJury")) throw new Error("jury registry cannot form the declared jury");
1800
+ return true;
1801
+ }
1802
+
1803
+ async settlementNetworkReady() {
1804
+ return this.rpcValue(async (rpc) => {
1805
+ const chainId = await this.callRpc(rpc, "eth_chainId", []);
1806
+ if (BigInt(chainId) !== BigInt(this.network.chain_id)) {
1807
+ const error = new Error("Settlement RPC is connected to the wrong chain");
1808
+ error.code = "network_mismatch";
1809
+ throw error;
1810
+ }
1811
+ const code = String(await this.callRpc(rpc, "eth_getCode", [this.network.settlement_contract, "latest"]) || "");
1812
+ if (!/^0x[0-9a-f]+$/i.test(code) || code === "0x" || /^0x0+$/i.test(code)) {
1813
+ const error = new Error("Settlement contract is unavailable on the configured network");
1814
+ error.code = "settlement_unavailable";
1815
+ throw error;
1816
+ }
1817
+ await this.dynamicJuryNetworkReady(rpc);
1818
+ return true;
1819
+ });
1820
+ }
1821
+
832
1822
  async accountBalance(owner) {
833
1823
  const output = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.settlement_contract, "availableBalance(address)", [normalizeAddress(owner)]));
834
1824
  return BigInt(output || "0x0").toString();
835
1825
  }
836
1826
 
1827
+ // These two reads are independent. Keep them in one helper so the local
1828
+ // dashboard can fetch the wallet snapshot in one RPC round instead of
1829
+ // serialising the token balance behind the allowance read. Both calls are
1830
+ // read-only and still use rpcValue's endpoint failover independently.
1831
+ async walletSnapshot(owner) {
1832
+ const [token, allowance] = await Promise.all([
1833
+ this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "balanceOf(address)", [normalizeAddress(owner)])),
1834
+ this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "allowance(address,address)", [normalizeAddress(owner), this.network.settlement_contract])),
1835
+ ]);
1836
+ return {
1837
+ address: owner,
1838
+ token_balance_units: BigInt(token || "0x0").toString(),
1839
+ allowance_units: BigInt(allowance || "0x0").toString(),
1840
+ };
1841
+ }
1842
+
837
1843
  async dashboardPayload(managementAuthorized = false) {
838
1844
  const authenticated = managementAuthorized && Boolean(this.unlockedWallet);
1845
+ if (authenticated) {
1846
+ await this.refreshDisputeTransactions().catch(() => {
1847
+ this.disputeSyncError = "申诉交易状态暂时无法核验";
1848
+ });
1849
+ void this.refreshReceiptStatuses().catch(() => { this.historySyncError = "暂时无法同步账单状态"; });
1850
+ }
839
1851
  const allHistory = this.history(0);
1852
+ const now = Math.floor(Date.now() / 1000);
1853
+ const dashboardHistory = authenticated ? allHistory.slice(0, 100).map((item) => {
1854
+ const hasReference = Boolean(item.jury_evidence_hash && item.jury_report_id
1855
+ && item.jury_reporter === this.unlockedWallet && item.jury_reference_signature);
1856
+ const disputeActive = ["wallet_prompted", "submitted", "uncertain", "confirmed"].includes(item.dispute_stage);
1857
+ const disputeEligible = this.network.protocol_version === 10
1858
+ && this.network.committee_mode === DYNAMIC_PROVIDER_AI_COMMITTEE
1859
+ && item.status === "escrowed" && hasReference && !disputeActive
1860
+ && Number(item.settlement_release_at || 0) > now;
1861
+ return {
1862
+ ...item,
1863
+ dispute_eligible: disputeEligible,
1864
+ ...(item.dispute_tx_hash && this.network.explorer_url ? {
1865
+ dispute_explorer_url: `${String(this.network.explorer_url).replace(/\/$/, "")}/tx/${item.dispute_tx_hash}`,
1866
+ } : {}),
1867
+ };
1868
+ }) : [];
1869
+ const unknownFees = authenticated ? allHistory.filter((item) => item.actual_fee_units == null
1870
+ && !["not_dispatched", "failed", "rejected"].includes(item.status)) : [];
840
1871
  const pending = authenticated ? this.pendingPaymentKey() : null;
841
1872
  const payload = {
842
1873
  ok: true,
843
- 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,
844
1882
  runtime: "node-native",
845
1883
  auth: authenticated ? this.walletAuthPayload() : { authenticated: false, wallet: null, key_ready: false },
846
1884
  credentials: authenticated && this.paymentUnlocked
@@ -852,37 +1890,240 @@ export class NativeConsumerState {
852
1890
  pending: pending ? { payment_key_address: pending.payment_key_address } : null,
853
1891
  },
854
1892
  settlement: this.network,
855
- history: authenticated ? 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,
856
1898
  usage: {
857
1899
  request_count: authenticated ? allHistory.length : 0,
858
1900
  total_spent_units: authenticated ? allHistory.reduce((total, item) => total + Number(item.actual_fee_units || 0), 0) : 0,
1901
+ settled_units: authenticated ? allHistory.filter((item) => ["confirmed", "released", "dismissed", "timed_out"].includes(item.status)).reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
1902
+ pending_units: authenticated ? allHistory.filter((item) => !["confirmed", "released", "refunded", "dismissed", "timed_out", "failed", "rejected"].includes(item.status)).reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
1903
+ unknown_fee_count: unknownFees.length,
1904
+ unknown_fee_authorized_maximum_units: unknownFees.reduce((sum, item) => sum + Number(item.max_fee_units || 0), 0),
1905
+ refunded_units: authenticated ? allHistory.filter((item) => item.status === "refunded").reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
1906
+ failed_units: authenticated ? allHistory.filter((item) => ["failed", "rejected"].includes(item.status)).reduce((sum, item) => sum + Number(item.actual_fee_units || 0), 0) : 0,
859
1907
  input_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.input_tokens || 0), 0) : 0,
860
1908
  output_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.output_tokens || 0), 0) : 0,
861
1909
  },
862
1910
  share: authenticated ? this.sharePayload() : { active: false },
863
1911
  };
864
- try {
865
- const grant = await this.keyGrant(this.paymentAddress);
866
- payload.key.grant = grant;
867
- if (authenticated && grant.owner !== ZERO_ADDRESS) {
868
- payload.account = { owner: grant.owner, available_balance_units: await this.accountBalance(grant.owner) };
869
- }
870
- } catch (error) {
871
- 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;
1931
+ }
1932
+ const chainErrors = [networkResult, grantResult, channelsResult]
1933
+ .filter((result) => result.status === "rejected")
1934
+ .map((result) => result.reason?.message || String(result.reason));
1935
+ if (chainErrors.length) payload.chain_error = chainErrors.join("; ");
1936
+ if (authenticated && this.network.protocol_version === 10 && channelSnapshot) {
1937
+ const now = Math.floor(Date.now()/1000);
1938
+ payload.capacity_channels = channelSnapshot.filter(c => c.consumer_owner === this.unlockedWallet)
1939
+ .map(c => ({ ...c, budget: this.capacityBudget(c, allHistory, now) }));
1940
+ payload.budget_locked_units = payload.capacity_channels.filter(c=>!c.closed).reduce((n,c)=>n+BigInt(c.credit_remaining),0n).toString();
1941
+ payload.budget_available_units = payload.capacity_channels.filter(c => c.budget.ready)
1942
+ .reduce((total,c) => total+BigInt(c.budget.remaining_units),0n).toString();
1943
+ payload.budget_note = "固定预算在结算窗口结束前不会撤回;每次请求只占用授权上限。结算按 2 小时或 100 笔先到触发。撤销 Key 只会停止新请求,不会改变已有账单。";
1944
+ }
1945
+ if (authenticated && this.paymentUnlocked) {
1946
+ const now = Math.floor(Date.now() / 1000);
1947
+ const activeGrant = Boolean(dashboardGrant
1948
+ && dashboardGrant.owner === this.unlockedWallet && dashboardGrant.active === true
1949
+ && Number(dashboardGrant.max_per_request) >= this.maxFeeUnits
1950
+ && (Number(dashboardGrant.valid_until) === 0 || Number(dashboardGrant.valid_until) > now + Math.ceil(this.timeoutMs / 1000)));
1951
+ const activeChannel = this.network.protocol_version === 10 && Array.isArray(channelSnapshot)
1952
+ && channelSnapshot.some((channel) => !channel.closed && channel.consumer_owner === this.unlockedWallet
1953
+ && channel.consumer_key === this.paymentAddress && Number(channel.admit_until) > now);
1954
+ payload.wallet_ready = activeGrant || activeChannel;
872
1955
  }
873
1956
  if (authenticated) {
1957
+ if (dashboardGrant && (dashboardGrant.owner === this.unlockedWallet
1958
+ || (this.network.protocol_version === 10 && dashboardGrant.owner === ZERO_ADDRESS))) {
1959
+ try {
1960
+ payload.account = { owner: this.unlockedWallet, available_balance_units: await this.accountBalance(this.unlockedWallet) };
1961
+ } catch (error) {
1962
+ payload.account_error = error.message;
1963
+ }
1964
+ }
874
1965
  try {
875
1966
  const address = this.unlockedWallet;
876
- const token = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "balanceOf(address)", [address]));
877
- const allowance = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "allowance(address,address)", [address, this.network.settlement_contract]));
878
- payload.wallet = { address, token_balance_units: BigInt(token || "0x0").toString(), allowance_units: BigInt(allowance || "0x0").toString() };
1967
+ payload.wallet = await this.walletSnapshot(address);
879
1968
  } catch (error) {
880
1969
  payload.wallet_error = error.message;
881
1970
  }
1971
+ try {
1972
+ const selected = await this.chooseRelay(new Set(), { checkCapacity: false });
1973
+ this.verifyRelayIdentity(selected.relayUrl, selected.health, { requirePin: true });
1974
+ payload.models_ready = true;
1975
+ } catch (error) {
1976
+ payload.models_ready = false;
1977
+ payload.inference_error = error.message;
1978
+ payload.inference_code = error.code || "models_unavailable";
1979
+ }
1980
+ if (this.network.protocol_version === 10) {
1981
+ const channels = payload.capacity_channels || [];
1982
+ const ready = channels.filter((channel) => channel.budget?.ready);
1983
+ if (!ready.length) {
1984
+ const availableAt = channels.map((channel) => channel.budget?.available_at).filter(Number.isFinite).sort((a, b) => a - b)[0];
1985
+ payload.budget_ready = false;
1986
+ payload.inference_code = availableAt ? "budget_not_started" : "budget_unavailable";
1987
+ payload.inference_error = availableAt
1988
+ ? `Fixed budget becomes available at ${new Date(availableAt * 1000).toISOString()}`
1989
+ : "No active funded channel covers this model and Provider; renew the fixed budget";
1990
+ if (availableAt) payload.budget_available_at = availableAt;
1991
+ } else {
1992
+ try {
1993
+ // A locally usable channel is not enough: require a currently
1994
+ // healthy Relay/Provider route bound to that same channel.
1995
+ payload.budget_ready = Boolean(await this.chooseRelay());
1996
+ } catch (error) {
1997
+ payload.budget_ready = false;
1998
+ payload.inference_error = error.message;
1999
+ payload.inference_code = error.code || "budget_unavailable";
2000
+ if (error.code === "rpc_unavailable") payload.network_ready = false;
2001
+ if (error.availableAt) payload.budget_available_at = error.availableAt;
2002
+ }
2003
+ }
2004
+ } else {
2005
+ payload.budget_ready = Boolean(payload.account
2006
+ && BigInt(payload.account.available_balance_units) >= BigInt(this.maxFeeUnits));
2007
+ if (!payload.budget_ready && !payload.inference_code) payload.inference_code = "budget_unavailable";
2008
+ }
2009
+ payload.paid_ready = payload.wallet_ready && payload.network_ready && payload.budget_ready && payload.models_ready;
2010
+ payload.inference_ready = payload.paid_ready;
2011
+ if (!payload.paid_ready) {
2012
+ payload.inference_code = !payload.network_ready ? "network_unavailable"
2013
+ : !payload.wallet_ready ? "payment_key_not_ready"
2014
+ : !payload.models_ready ? "models_unavailable"
2015
+ : payload.inference_code === "budget_not_started" ? "budget_not_started" : "budget_unavailable";
2016
+ }
882
2017
  }
883
2018
  return payload;
884
2019
  }
885
2020
 
2021
+ async queryReceiptStatus(entry, privateKey = this.paymentKey) {
2022
+ if (!this.relayUrls.includes(entry.relay_url)) return null;
2023
+ const query = createReceiptStatusQuery(privateKey, { chainId: this.network.chain_id,
2024
+ contract: this.network.settlement_contract, requestId: entry.request_id, channelId: entry.capacity_channel_id });
2025
+ const response = await fetchWithTimeout(`${entry.relay_url}/v1/mycomesh/receipts/status`, {
2026
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(query), dispatcher: this.dispatcher,
2027
+ }, 5000);
2028
+ const value = await readJsonResponse(response);
2029
+ if (response.status === 404) return null; // Old Relay or an unknown receipt; never assume settled/failed.
2030
+ if (!response.ok || value.request_id !== entry.request_id || (entry.capacity_channel_id && value.channel_id !== entry.capacity_channel_id)) throw new Error("invalid Relay receipt status response");
2031
+ if (!["pending", "submitted", "broadcast_unknown", "confirmed", "escrowed", "failed"].includes(value.status)) throw new Error("invalid receipt state");
2032
+ return value;
2033
+ }
2034
+
2035
+ async refreshReceiptStatuses(force = false) {
2036
+ if (!this.unlockedWallet || !this.managementToken) return;
2037
+ if (this.historySync) return this.historySync;
2038
+ if (!force && Date.now() - this.historySyncAt < 30_000) return;
2039
+ this.historySyncAt = Date.now();
2040
+ const owner = this.unlockedWallet, key = this.paymentAddress, privateKey = this.paymentKey, ledger = this.historyLedger;
2041
+ const candidates = ledger.history(0).filter((entry) => (entry.accepted || ["dispatching", "outcome_unknown"].includes(entry.status)) && !["confirmed", "released", "refunded", "dismissed", "timed_out"].includes(entry.status)
2042
+ && /^0x[0-9a-f]{64}$/i.test(entry.request_id));
2043
+ const live = candidates.filter((entry) => !["failed", "rejected"].includes(entry.status));
2044
+ const terminal = candidates.filter((entry) => ["failed", "rejected"].includes(entry.status));
2045
+ this.historySyncCursors ||= { live: 0, terminal: 0 };
2046
+ const take = (rows, count, group) => {
2047
+ if (!rows.length) return [];
2048
+ const start = this.historySyncCursors[group] % rows.length;
2049
+ const selected = Array.from({ length: Math.min(count, rows.length) }, (_, index) => rows[(start + index) % rows.length]);
2050
+ this.historySyncCursors[group] = (start + selected.length) % rows.length;
2051
+ return selected;
2052
+ };
2053
+ // Reserve a small share for rechecking failed records while rotating all
2054
+ // live work. Old pending receipts cannot be hidden by recent failures.
2055
+ const pending = take(live, terminal.length ? 16 : 20, "live");
2056
+ pending.push(...take(terminal, 20 - pending.length, "terminal"));
2057
+ this.historySyncError = null;
2058
+ const check = async () => {
2059
+ while (pending.length) {
2060
+ const entry = pending.shift();
2061
+ try {
2062
+ const settlementKey = this.network.protocol_version === 10 ? reservedSettlementKey(entry.capacity_channel_id, entry.request_id) : keccakHex(Buffer.concat([abiWord(owner), abiWord(key), abiWord(entry.request_id)]));
2063
+ if (this.network.protocol_version >= 9) {
2064
+ const snapshot = await this.rpcValue(async (rpc) => {
2065
+ const block = canonicalBlock(
2066
+ await this.callRpc(rpc, "eth_getBlockByNumber", ["safe", false]),
2067
+ "safe Settlement block",
2068
+ );
2069
+ const record = await this.contractCall(rpc, this.network.settlement_contract, "settlementInfo(bytes32)", [settlementKey],
2070
+ { blockHash: block.hash, requireCanonical: true });
2071
+ return { record, block };
2072
+ });
2073
+ const result = snapshot.record;
2074
+ if (typeof result !== "string" || !/^0x[0-9a-f]{1280}$/i.test(result)) throw new Error("invalid V9 settlement record");
2075
+ const words = result.slice(2).match(/.{64}/g);
2076
+ if (words.slice(0, 8).some((word) => !/^0{24}/.test(word)) || BigInt(`0x${words[19]}`) > 6n
2077
+ || BigInt(`0x${words[17]}`) >= (1n << 64n) || BigInt(`0x${words[18]}`) >= (1n << 64n)) throw new Error("noncanonical V9 settlement record");
2078
+ const status = [null, "escrowed", "disputed", "released", "refunded", "dismissed", "timed_out"][Number(BigInt(`0x${words[19]}`))];
2079
+ if (status) {
2080
+ if (`0x${words[0].slice(-40)}` !== owner.toLowerCase() || `0x${words[1].slice(-40)}` !== key.toLowerCase() || `0x${words[8]}` !== entry.request_id.toLowerCase()
2081
+ || (entry.request_hash && `0x${words[9]}` !== entry.request_hash.toLowerCase())
2082
+ || (entry.actual_fee_units != null && BigInt(`0x${words[12]}`) !== BigInt(entry.actual_fee_units))
2083
+ || (entry.provider && `0x${words[2].slice(-40)}` !== entry.provider.toLowerCase())
2084
+ || (entry.provider_signer && `0x${words[3].slice(-40)}` !== entry.provider_signer.toLowerCase())
2085
+ || (entry.response_hash && `0x${words[11]}` !== entry.response_hash.toLowerCase())) throw new Error("V9 escrow scope mismatch");
2086
+ const releaseAt = Number(BigInt(`0x${words[18]}`));
2087
+ if (entry.status !== status || entry.settlement_release_at !== releaseAt) {
2088
+ ledger.append({ ...entry, owner, status, accepted: true,
2089
+ actual_fee_units: jsonInteger(BigInt(`0x${words[12]}`)),
2090
+ settlement_release_at: releaseAt,
2091
+ settlement_checked_at: snapshot.block.timestamp,
2092
+ updated_at: Math.floor(Date.now() / 1000) });
2093
+ }
2094
+ continue;
2095
+ }
2096
+ // No safe-block escrow: a Relay cannot declare escrow or release.
2097
+ const remote = await this.queryReceiptStatus(entry, privateKey);
2098
+ if (remote && ["pending", "submitted", "broadcast_unknown", "failed"].includes(remote.status) && remote.status !== entry.status) ledger.append({ ...entry, accepted: true, status: remote.status, updated_at: Math.floor(Date.now() / 1000) });
2099
+ continue;
2100
+ }
2101
+ const result = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.settlement_contract,
2102
+ "settled(bytes32)", [settlementKey]));
2103
+ if (BigInt(result || "0x0") === 1n) {
2104
+ ledger.append({ ...entry, owner, status: "confirmed", confirmed_at: Math.floor(Date.now() / 1000) });
2105
+ } else {
2106
+ const remote = await this.queryReceiptStatus(entry, privateKey);
2107
+ if (remote && remote.status !== "confirmed") {
2108
+ // A Relay cannot claim a final on-chain success without the RPC
2109
+ // check above. Its scoped authenticated status can report failure.
2110
+ const update = { status: remote.status, error_code: remote.error_code ?? (entry.error_code ? "" : undefined),
2111
+ tx_hash: remote.tx_hash, authorization_deadline: remote.authorization_deadline || entry.authorization_deadline };
2112
+ if (Object.entries(update).some(([field, value]) => value != null && value !== entry[field])) {
2113
+ ledger.append({ ...entry, ...update, accepted: true, updated_at: Math.floor(Date.now() / 1000) });
2114
+ }
2115
+ }
2116
+ }
2117
+ } catch {
2118
+ this.historySyncError = "暂时无法同步链上结算状态,已保留本地账单";
2119
+ pending.length = 0;
2120
+ }
2121
+ }
2122
+ };
2123
+ this.historySync = Promise.all([check(), check(), check()]);
2124
+ try { await this.historySync; } finally { this.historySync = null; }
2125
+ }
2126
+
886
2127
  sharePayload() {
887
2128
  const share = this.activeShare();
888
2129
  return share ? {
@@ -992,6 +2233,9 @@ export class NativeConsumerState {
992
2233
  if (!pending) throw new Error("no pending payment key exists");
993
2234
  const grant = await this.keyGrant(pending.payment_key_address);
994
2235
  if (!grant.active || grant.owner !== owner) throw new Error("the pending payment key is not active for this wallet on-chain");
2236
+ if (this.activeInferenceRequests > 0) {
2237
+ throw new Error("wait for active inference requests before rotating the payment key");
2238
+ }
995
2239
  const previous = this.paymentAddress;
996
2240
  const destination = join(this.dataDir, "payment-key");
997
2241
  writeFileSync(destination, `${pending.payment_key}\n`, { mode: 0o600 });
@@ -999,6 +2243,11 @@ export class NativeConsumerState {
999
2243
  unlinkSync(this.pendingKeyPath);
1000
2244
  this.paymentKey = pending.payment_key;
1001
2245
  this.paymentAddress = pending.payment_key_address;
2246
+ this.sessions = new ConsumerSessions();
2247
+ this.sessionRoutes.clear();
2248
+ this.historyLedger = new ConsumerHistoryLedger({ localPath: this.historyPath, sharedDir: this.historyDir,
2249
+ chainId: this.network.chain_id, contract: this.network.settlement_contract, keyAddress: this.paymentAddress });
2250
+ this.historySyncAt = 0;
1002
2251
  this.paymentUnlocked = true;
1003
2252
  return { payment_key_address: this.paymentAddress, previous_key_address: previous };
1004
2253
  }
@@ -1008,31 +2257,369 @@ export class NativeConsumerState {
1008
2257
  const wallet = this.assertUnlockedWallet(raw?.wallet);
1009
2258
  const settlement = this.network.settlement_contract;
1010
2259
  const token = this.network.stablecoin;
2260
+ if (action === "open_dispute") {
2261
+ if (this.network.protocol_version !== 10
2262
+ || this.network.committee_mode !== DYNAMIC_PROVIDER_AI_COMMITTEE) {
2263
+ throw new Error("open_dispute requires the dynamic Settlement V10 jury");
2264
+ }
2265
+ const allowed = new Set(["action", "wallet", "request_id", "channel_id"]);
2266
+ if (!raw || Object.keys(raw).some((name) => !allowed.has(name))) {
2267
+ throw new Error("open_dispute accepts only a local request and channel reference");
2268
+ }
2269
+ const { entry, reference, requestId, channelId, settlementKey } = this.disputeHistoryEntry(raw, wallet);
2270
+ if (["wallet_prompted", "submitted", "uncertain", "confirmed"].includes(entry.dispute_stage)) {
2271
+ throw new Error("this dispute is already awaiting a wallet or chain outcome");
2272
+ }
2273
+ const snapshot = await this.disputePlanningSnapshot(entry, reference, wallet);
2274
+ const transactions = [];
2275
+ if (snapshot.allowance < snapshot.bond) {
2276
+ transactions.push({
2277
+ kind: "approval",
2278
+ label: "Approve exact reporter bond",
2279
+ to: token,
2280
+ data: contractData("approve(address,uint256)", [settlement, snapshot.bond]),
2281
+ });
2282
+ }
2283
+ transactions.push({
2284
+ kind: "open_dispute",
2285
+ label: "Open objective-fraud dispute",
2286
+ to: settlement,
2287
+ data: contractData("openDispute(bytes32,bytes32)", [settlementKey, reference.evidence_hash]),
2288
+ });
2289
+ const now = Math.floor(Date.now() / 1000);
2290
+ this.historyLedger.append({
2291
+ ...entry,
2292
+ dispute_stage: "planned",
2293
+ dispute_tx_kind: "open_dispute",
2294
+ dispute_bond_units: snapshot.bond.toString(),
2295
+ settlement_release_at: snapshot.record.release_at,
2296
+ settlement_checked_at: snapshot.block.timestamp,
2297
+ dispute_updated_at: now,
2298
+ dispute_error: "",
2299
+ updated_at: now,
2300
+ });
2301
+ return {
2302
+ action,
2303
+ request_id: requestId,
2304
+ channel_id: channelId,
2305
+ settlement_key: settlementKey,
2306
+ evidence_hash: reference.evidence_hash,
2307
+ report_id: reference.predicted_report_id,
2308
+ reporter: reference.reporter,
2309
+ bond_units: snapshot.bond.toString(),
2310
+ release_at: snapshot.record.release_at,
2311
+ chain_timestamp: snapshot.block.timestamp,
2312
+ warning: "Only report objective fraud or non-delivery supported by the signed evidence commitment.",
2313
+ transactions,
2314
+ };
2315
+ }
1011
2316
  if (action === "top_up") {
1012
2317
  const amount = parseUsdc(raw.amount_usdc);
1013
2318
  const allowance = BigInt(await this.rpcValue((rpc) => this.contractCall(rpc, token, "allowance(address,address)", [wallet, settlement])) || "0x0");
1014
2319
  const transactions = [];
1015
- if (allowance < amount) transactions.push({ label: "Approve stablecoin", to: token, data: contractData("approve(address,uint256)", [settlement, (1n << 256n) - 1n]) });
2320
+ if (allowance < amount) transactions.push({ label: "Approve stablecoin", to: token, data: contractData("approve(address,uint256)", [settlement, amount]) });
1016
2321
  transactions.push({ label: "Deposit prepaid balance", to: settlement, data: contractData("deposit(uint256)", [amount]) });
1017
2322
  return { action, amount_units: amount.toString(), transactions };
1018
2323
  }
1019
2324
  if (action === "register_key") {
1020
2325
  const pending = this.pendingPaymentKey();
1021
2326
  const keyAddress = pending?.payment_key_address || this.paymentAddress;
2327
+ const grant = await this.keyGrant(keyAddress);
2328
+ if (grant.owner === wallet && grant.active === true
2329
+ && grant.max_per_request === this.maxFeeUnits && grant.valid_until === 0) {
2330
+ return { action, key_address: keyAddress, transactions: [] };
2331
+ }
1022
2332
  return { action, key_address: keyAddress, transactions: [{ label: "Register payment key", to: settlement, data: contractData("registerKey(address,uint256,uint64)", [keyAddress, this.maxFeeUnits, 0]) }] };
1023
2333
  }
1024
2334
  if (action === "revoke_key") {
1025
2335
  const keyAddress = normalizeAddress(raw.key_address, "key_address");
1026
2336
  return { action, key_address: keyAddress, transactions: [{ label: "Revoke previous payment key", to: settlement, data: contractData("revokeKey(address)", [keyAddress]) }] };
1027
2337
  }
2338
+ if (action === "close_capacity_channel" && this.network.protocol_version === 10) {
2339
+ const id = normalizeBytes32(raw.channel_id, "channel_id");
2340
+ const channel = (await this.capacityChannels()).find(c => c.channel_id === id && c.consumer_owner === wallet);
2341
+ if (!channel || channel.closed || Math.floor(Date.now()/1000) <= Number(channel.claim_until)) throw new Error("该预算尚未到可释放时间");
2342
+ return { action, channel_id: id, transactions: [{ label: "释放已到期的剩余预算", to: settlement, data: contractData("closeExpiredChannel(bytes32)", [id]) }] };
2343
+ }
1028
2344
  throw new Error("unsupported transaction action");
1029
2345
  }
1030
2346
 
2347
+ disputeHistoryEntry(raw, walletValue = this.unlockedWallet) {
2348
+ const wallet = normalizeAddress(walletValue, "wallet");
2349
+ const requestId = normalizeBytes32(raw?.request_id, "request_id");
2350
+ const channelId = normalizeBytes32(raw?.channel_id, "channel_id");
2351
+ const matches = this.historyLedger.history(0).filter((item) => (
2352
+ item.request_id === requestId && item.capacity_channel_id === channelId
2353
+ ));
2354
+ if (matches.length !== 1) throw new Error("no unique locally verified receipt exists for this request and channel");
2355
+ const entry = matches[0];
2356
+ if (entry.owner !== wallet || entry.jury_reporter !== wallet) {
2357
+ throw new Error("only the authenticated settlement owner may open this dispute");
2358
+ }
2359
+ const settlementKey = reservedSettlementKey(channelId, requestId);
2360
+ if (entry.settlement_key !== settlementKey) throw new Error("history settlement key is not canonical");
2361
+ const reference = restoreJuryEvidenceReference(entry, this.network, wallet);
2362
+ if (reference.settlement_key !== settlementKey) throw new Error("jury evidence belongs to another settlement");
2363
+ return { entry, reference, requestId, channelId, settlementKey };
2364
+ }
2365
+
2366
+ async disputePlanningSnapshot(entry, reference, wallet) {
2367
+ const settlement = this.network.settlement_contract;
2368
+ const token = this.network.stablecoin;
2369
+ const manifestBondText = String(this.network.reporter_bond_units || "");
2370
+ if (!/^[1-9][0-9]*$/.test(manifestBondText)) {
2371
+ throw new Error("dynamic jury reporter bond is not pinned by the network manifest");
2372
+ }
2373
+ const manifestBond = BigInt(manifestBondText);
2374
+ return this.rpcValue(async (rpc) => {
2375
+ const chainId = await this.callRpc(rpc, "eth_chainId", []);
2376
+ if (BigInt(chainId) !== BigInt(this.network.chain_id)) throw new Error("Settlement RPC is connected to the wrong chain");
2377
+ const block = canonicalBlock(
2378
+ await this.callRpc(rpc, "eth_getBlockByNumber", ["safe", false]),
2379
+ "safe Settlement V10 block",
2380
+ );
2381
+ const blockTag = { blockHash: block.hash, requireCanonical: true };
2382
+ const [rawRecord, rawPolicy, rawAllowance, rawBalance, rawRegistry] = await Promise.all([
2383
+ this.contractCall(rpc, settlement, "settlementInfo(bytes32)", [reference.settlement_key], blockTag),
2384
+ this.contractCall(rpc, settlement, "policy()", [], blockTag),
2385
+ this.contractCall(rpc, token, "allowance(address,address)", [wallet, settlement], blockTag),
2386
+ this.contractCall(rpc, token, "balanceOf(address)", [wallet], blockTag),
2387
+ this.contractCall(rpc, settlement, "juryRegistry()", [], blockTag),
2388
+ ]);
2389
+ const record = decodeV10Settlement(rawRecord);
2390
+ const policy = decodeV10Policy(rawPolicy);
2391
+ const allowance = decodeAbiUint(rawAllowance, "reporter bond allowance");
2392
+ const balance = decodeAbiUint(rawBalance, "reporter bond balance");
2393
+ const registry = decodeAbiAddress(rawRegistry, "Settlement juryRegistry");
2394
+ if (registry !== this.network.jury_registry) throw new Error("Settlement jury Registry differs from the network manifest");
2395
+ if (policy.reporter_bond !== manifestBond) throw new Error("on-chain reporter bond differs from the network manifest");
2396
+ if (balance < policy.reporter_bond) throw new Error("wallet balance is below the required reporter bond");
2397
+ if (record.status !== 1) throw new Error("settlement is not pending and cannot be disputed");
2398
+ if (record.owner !== wallet || record.key !== this.paymentAddress
2399
+ || record.request_id !== entry.request_id
2400
+ || record.request_hash !== entry.request_hash
2401
+ || record.response_hash !== entry.response_hash) {
2402
+ throw new Error("on-chain settlement differs from the locally verified receipt");
2403
+ }
2404
+ if (block.timestamp >= record.release_at) throw new Error("the settlement dispute window has closed");
2405
+ if (reference.reporter !== wallet
2406
+ || reference.predicted_report_id !== keccakHex(Buffer.concat([
2407
+ abiWord(reference.settlement_key), abiWord(wallet), abiWord(reference.evidence_hash),
2408
+ ]))) {
2409
+ throw new Error("jury evidence report binding is invalid");
2410
+ }
2411
+ return { block, record, policy, allowance, balance, bond: policy.reporter_bond };
2412
+ });
2413
+ }
2414
+
2415
+ recordDisputeProgress(raw) {
2416
+ const wallet = this.assertUnlockedWallet(raw?.wallet);
2417
+ const { entry } = this.disputeHistoryEntry(raw, wallet);
2418
+ const stage = String(raw?.stage || "");
2419
+ const kind = String(raw?.transaction_kind || entry.dispute_tx_kind || "");
2420
+ if (!["wallet_prompted", "submitted", "uncertain", "failed"].includes(stage)
2421
+ || !["approval", "open_dispute"].includes(kind)) {
2422
+ throw new Error("invalid dispute transaction progress");
2423
+ }
2424
+ const previous = entry.dispute_stage;
2425
+ const allowed = (
2426
+ (stage === "wallet_prompted" && (previous === "planned"
2427
+ || (["submitted", "uncertain"].includes(previous) && entry.dispute_tx_kind === "approval" && kind === "open_dispute")))
2428
+ || (stage === "submitted" && previous === "wallet_prompted")
2429
+ || (stage === "uncertain" && ["wallet_prompted", "submitted", "uncertain"].includes(previous))
2430
+ || (stage === "failed" && ["planned", "wallet_prompted", "submitted", "uncertain"].includes(previous))
2431
+ );
2432
+ if (!allowed) throw new Error("invalid dispute transaction state transition");
2433
+ const txHash = raw?.tx_hash === undefined ? null : normalizeBytes32(raw.tx_hash, "transaction hash");
2434
+ if (stage === "submitted" && !txHash) throw new Error("submitted dispute progress requires a transaction hash");
2435
+ if (stage === "uncertain" && !txHash
2436
+ && !entry.dispute_tx_hash && !entry.dispute_approval_tx_hash) {
2437
+ // The wallet may have accepted a transaction without returning its hash.
2438
+ // Persist uncertainty and block automatic retry instead of guessing.
2439
+ }
2440
+ const errors = {
2441
+ wallet_rejected: "The wallet rejected the transaction before submission.",
2442
+ wallet_unknown: "The wallet outcome is unknown; do not retry automatically.",
2443
+ confirmation_timeout: "The transaction was submitted but confirmation is still unknown.",
2444
+ transaction_failed: "The transaction failed on-chain.",
2445
+ };
2446
+ const now = Math.floor(Date.now() / 1000);
2447
+ const update = {
2448
+ ...entry,
2449
+ dispute_stage: stage,
2450
+ dispute_tx_kind: kind,
2451
+ dispute_updated_at: now,
2452
+ dispute_error: errors[String(raw?.error_code || "")] || "",
2453
+ updated_at: now,
2454
+ };
2455
+ if (txHash && kind === "approval") update.dispute_approval_tx_hash = txHash;
2456
+ if (txHash && kind === "open_dispute") update.dispute_tx_hash = txHash;
2457
+ this.historyLedger.append(update);
2458
+ return { ok: true, request_id: entry.request_id, channel_id: entry.capacity_channel_id,
2459
+ stage, transaction_kind: kind, tx_hash: txHash };
2460
+ }
2461
+
2462
+ async reconcileDisputeEntry(entry) {
2463
+ const wallet = this.assertUnlockedWallet();
2464
+ const { entry: checked, reference } = this.disputeHistoryEntry({
2465
+ request_id: entry.request_id,
2466
+ channel_id: entry.capacity_channel_id,
2467
+ }, wallet);
2468
+ const kind = checked.dispute_tx_kind;
2469
+ const txHash = kind === "approval" ? checked.dispute_approval_tx_hash : checked.dispute_tx_hash;
2470
+ if (!txHash || !["approval", "open_dispute"].includes(kind)) return { state: "pending" };
2471
+ const bond = BigInt(String(checked.dispute_bond_units || ""));
2472
+ if (bond <= 0n) throw new Error("stored dispute reporter bond is invalid");
2473
+ const expectedTo = kind === "approval" ? this.network.stablecoin : this.network.settlement_contract;
2474
+ const expectedData = kind === "approval"
2475
+ ? contractData("approve(address,uint256)", [this.network.settlement_contract, bond])
2476
+ : contractData("openDispute(bytes32,bytes32)", [reference.settlement_key, reference.evidence_hash]);
2477
+ const result = await this.rpcValue(async (rpc) => {
2478
+ const [receipt, transaction] = await Promise.all([
2479
+ this.callRpc(rpc, "eth_getTransactionReceipt", [txHash]),
2480
+ this.callRpc(rpc, "eth_getTransactionByHash", [txHash]),
2481
+ ]);
2482
+ if (receipt == null) return { state: "pending" };
2483
+ if (!transaction || typeof transaction !== "object") throw new Error("submitted dispute transaction is unavailable");
2484
+ if (String(receipt.transactionHash || "").toLowerCase() !== txHash
2485
+ || String(transaction.hash || "").toLowerCase() !== txHash
2486
+ || String(receipt.blockHash || "").toLowerCase() !== String(transaction.blockHash || "").toLowerCase()
2487
+ || String(receipt.blockNumber || "").toLowerCase() !== String(transaction.blockNumber || "").toLowerCase()
2488
+ || normalizeAddress(receipt.from) !== wallet || normalizeAddress(transaction.from) !== wallet
2489
+ || normalizeAddress(receipt.to) !== expectedTo || normalizeAddress(transaction.to) !== expectedTo
2490
+ || String(transaction.input || "").toLowerCase() !== expectedData.toLowerCase()) {
2491
+ throw new Error("submitted transaction differs from the dispute plan");
2492
+ }
2493
+ if (!/^0x[0-9a-f]{64}$/i.test(String(receipt.blockHash || ""))
2494
+ || !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(receipt.blockNumber || ""))
2495
+ || !["0x0", "0x1"].includes(String(receipt.status || "").toLowerCase())) {
2496
+ throw new Error("submitted transaction receipt is non-canonical");
2497
+ }
2498
+ const [rawBlock, rawCanonicalBlock, rawHead] = await Promise.all([
2499
+ this.callRpc(rpc, "eth_getBlockByHash", [receipt.blockHash, false]),
2500
+ this.callRpc(rpc, "eth_getBlockByNumber", [receipt.blockNumber, false]),
2501
+ this.callRpc(rpc, "eth_blockNumber", []),
2502
+ ]);
2503
+ const block = canonicalBlock(rawBlock, "dispute transaction block");
2504
+ const canonicalHeightBlock = canonicalBlock(rawCanonicalBlock, "canonical dispute transaction block");
2505
+ if (block.hash !== String(receipt.blockHash).toLowerCase()
2506
+ || block.number !== String(receipt.blockNumber).toLowerCase()
2507
+ || canonicalHeightBlock.hash !== block.hash
2508
+ || canonicalHeightBlock.number !== block.number) {
2509
+ throw new Error("dispute transaction block is not canonical");
2510
+ }
2511
+ if (!/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(String(rawHead || ""))) {
2512
+ throw new Error("dispute confirmation head is non-canonical");
2513
+ }
2514
+ const head = BigInt(rawHead);
2515
+ const receiptHeight = BigInt(block.number);
2516
+ if (head < receiptHeight) throw new Error("dispute confirmation head predates the receipt");
2517
+ const confirmations = head - receiptHeight + 1n;
2518
+ if (confirmations < DISPUTE_CONFIRMATIONS) {
2519
+ return { state: "pending", confirmations: Number(confirmations) };
2520
+ }
2521
+ if (String(receipt.status).toLowerCase() === "0x0") return { state: "failed", block };
2522
+ if (kind === "approval") return { state: "approval_confirmed", block };
2523
+ const blockTag = { blockHash: block.hash, requireCanonical: true };
2524
+ const [rawRecord, rawReport] = await Promise.all([
2525
+ this.contractCall(rpc, this.network.settlement_contract, "settlementInfo(bytes32)", [reference.settlement_key], blockTag),
2526
+ this.contractCall(rpc, this.network.settlement_contract, "reports(bytes32,bytes32)",
2527
+ [reference.settlement_key, reference.predicted_report_id], blockTag),
2528
+ ]);
2529
+ return { state: "confirmed", block, record: decodeV10Settlement(rawRecord), report: decodeV10Report(rawReport) };
2530
+ });
2531
+ if (result.state === "pending") return result;
2532
+ const now = Math.floor(Date.now() / 1000);
2533
+ if (result.state === "failed") {
2534
+ this.historyLedger.append({ ...checked, dispute_stage: "failed", dispute_error: "The transaction failed on-chain.",
2535
+ dispute_updated_at: now, updated_at: now });
2536
+ return result;
2537
+ }
2538
+ if (result.state === "approval_confirmed") {
2539
+ this.historyLedger.append({ ...checked, dispute_stage: "failed",
2540
+ dispute_error: "Reporter-bond approval confirmed; submit the dispute transaction again.",
2541
+ dispute_updated_at: now, updated_at: now });
2542
+ return result;
2543
+ }
2544
+ const { record, report, block } = result;
2545
+ if (record.status !== 2 || record.owner !== wallet || record.key !== this.paymentAddress
2546
+ || record.request_id !== checked.request_id || record.request_hash !== checked.request_hash
2547
+ || record.response_hash !== checked.response_hash || block.timestamp >= record.release_at
2548
+ || report.reporter !== wallet || report.evidence_hash !== reference.evidence_hash) {
2549
+ throw new Error("confirmed dispute transaction did not create the bound on-chain report");
2550
+ }
2551
+ this.historyLedger.append({ ...checked, status: "disputed", accepted: true,
2552
+ dispute_stage: "confirmed", dispute_error: "", dispute_confirmed_at: block.timestamp,
2553
+ dispute_updated_at: now, settlement_release_at: record.release_at,
2554
+ settlement_checked_at: block.timestamp, updated_at: now });
2555
+ return result;
2556
+ }
2557
+
2558
+ async refreshDisputeTransactions(force = false) {
2559
+ if (!this.unlockedWallet || !this.managementToken
2560
+ || this.network.protocol_version !== 10
2561
+ || this.network.committee_mode !== DYNAMIC_PROVIDER_AI_COMMITTEE) return;
2562
+ if (this.disputeSync) return this.disputeSync;
2563
+ if (!force && Date.now() - Number(this.disputeSyncAt || 0) < 10_000) return;
2564
+ const candidates = this.historyLedger.history(0).filter((entry) =>
2565
+ ["submitted", "uncertain"].includes(entry.dispute_stage)
2566
+ && (entry.dispute_tx_hash || entry.dispute_approval_tx_hash)).slice(0, 10);
2567
+ if (!candidates.length) return;
2568
+ this.disputeSyncAt = Date.now();
2569
+ this.disputeSyncError = null;
2570
+ const work = (async () => {
2571
+ for (const entry of candidates) {
2572
+ try {
2573
+ await this.reconcileDisputeEntry(entry);
2574
+ } catch {
2575
+ const current = this.historyLedger.history(0).find((item) =>
2576
+ item.request_id === entry.request_id && item.capacity_channel_id === entry.capacity_channel_id);
2577
+ if (current && current.dispute_stage !== "uncertain") {
2578
+ const now = Math.floor(Date.now() / 1000);
2579
+ this.historyLedger.append({ ...current, dispute_stage: "uncertain",
2580
+ dispute_error: "The submitted transaction cannot yet be reconciled; do not retry.",
2581
+ dispute_updated_at: now, updated_at: now });
2582
+ }
2583
+ this.disputeSyncError = "申诉交易状态暂时无法核验";
2584
+ }
2585
+ }
2586
+ })();
2587
+ this.disputeSync = work;
2588
+ try { await work; } finally { this.disputeSync = null; }
2589
+ }
2590
+
2591
+ noteRelayFailure(relayUrl, retryAfter) {
2592
+ this.readinessCache = null;
2593
+ this.healthCache.delete(relayUrl);
2594
+ const count = Math.min(6, (this.relayFailures.get(relayUrl)?.count || 0) + 1);
2595
+ const seconds = Number(retryAfter);
2596
+ const cooldown = Math.max(Math.min(30_000, 1000 * 2 ** (count - 1)),
2597
+ Number.isFinite(seconds) && seconds > 0 ? Math.min(30_000, seconds * 1000) : 0);
2598
+ this.relayFailures.set(relayUrl, { count, until: Date.now() + cooldown });
2599
+ }
2600
+
1031
2601
  async relayHealth(relayUrl, refresh = false) {
2602
+ const failed = this.relayFailures.get(relayUrl);
2603
+ if (failed && failed.until > Date.now()) throw new Error("Relay is cooling down after a failure");
2604
+ const running = this.healthRequests.get(relayUrl);
2605
+ if (running) return running;
2606
+ const pending = this.loadRelayHealth(relayUrl, refresh).catch((error) => {
2607
+ this.noteRelayFailure(relayUrl);
2608
+ throw error;
2609
+ });
2610
+ this.healthRequests.set(relayUrl, pending);
2611
+ try { return await pending; } finally { this.healthRequests.delete(relayUrl); }
2612
+ }
2613
+
2614
+ async loadRelayHealth(relayUrl, refresh = false) {
1032
2615
  const cached = this.healthCache.get(relayUrl);
1033
2616
  const cacheAge = cached ? Date.now() - cached.at : Infinity;
1034
- if (cached && !refresh && cacheAge < RELAY_HEALTH_CACHE_MS) return cached.payload;
2617
+ if (cached && !refresh && cacheAge < RELAY_HEALTH_CACHE_MS) {
2618
+ this.verifyRelayIdentity(relayUrl, cached.payload);
2619
+ return cached.payload;
2620
+ }
1035
2621
  const request = { dispatcher: this.dispatcher, headers: { accept: "application/json" } };
2622
+ if (this.relayDiscovery && !this.staticRelayUrls.includes(relayUrl)) await this.relayDiscovery.probe(relayUrl);
1036
2623
  let payload;
1037
2624
  let lastError;
1038
2625
  for (let attempt = 0; attempt < 2 && !payload; attempt += 1) {
@@ -1041,35 +2628,556 @@ export class NativeConsumerState {
1041
2628
  payload = await readJsonResponse(response);
1042
2629
  if (!response.ok || payload?.ok !== true) throw new Error(`Relay health is invalid for ${relayUrl}`);
1043
2630
  } catch (error) {
1044
- 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
+ }
1045
2644
  payload = undefined;
1046
2645
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, RELAY_HEALTH_RETRY_DELAY_MS));
1047
2646
  }
1048
2647
  }
1049
2648
  if (!payload) {
1050
- if (cached && cacheAge < RELAY_HEALTH_STALE_MS) return cached.payload;
1051
2649
  throw lastError || new Error(`Relay health is unavailable for ${relayUrl}`);
1052
2650
  }
1053
- const v8 = payload.v8;
1054
- 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
+ }
1055
2658
  this.healthCache.set(relayUrl, { at: Date.now(), payload });
1056
2659
  return payload;
1057
2660
  }
1058
2661
 
1059
- 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];
1060
2888
  const errors = [];
1061
- for (const relayUrl of this.relayUrls) {
1062
- if (exclude.has(relayUrl)) continue;
1063
- 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);
1064
3127
  }
1065
- throw new Error(`no healthy Settlement V8 Relay is available: ${errors.join("; ") || "all relays excluded"}`);
3128
+ const error = new Error(availableAt
3129
+ ? `Fixed budget becomes available at ${new Date(availableAt * 1000).toISOString()}`
3130
+ : "No active funded channel covers this model and Provider; renew the fixed budget");
3131
+ error.code = availableAt ? "budget_not_started" : "budget_unavailable";
3132
+ if (availableAt) error.availableAt = availableAt;
3133
+ throw error;
3134
+ }
3135
+
3136
+ async authorizationTiming(health, body) {
3137
+ if (this.network.protocol_version === 10) {
3138
+ const channel = await this.selectCapacityChannel(health, body?.model, body?.metadata?.mycomesh_provider_signer);
3139
+ return { now: Math.floor(Date.now()/1000), seconds: this.network.authorization_deadline_seconds, maxTtl: 10800, channel };
3140
+ }
3141
+ const seconds = this.network.authorization_deadline_seconds ?? 900;
3142
+ const maxTtl = this.network.max_authorization_ttl_seconds ?? 3600;
3143
+ if (this.network.protocol_version !== 9 || seconds <= 900) return { now: Math.floor(Date.now() / 1000), seconds, maxTtl: 3600 };
3144
+ return await this.rpcValue(async (rpc) => {
3145
+ const chain = await this.callRpc(rpc, "eth_chainId", []);
3146
+ if (BigInt(chain) !== BigInt(this.network.chain_id)) throw new Error("authorization RPC chain mismatch");
3147
+ const block = await this.callRpc(rpc, "eth_getBlockByNumber", ["latest", false]);
3148
+ const now = Math.floor(Date.now() / 1000);
3149
+ if (!block || !/^0x[0-9a-f]{64}$/i.test(block.hash) || !/^0x[0-9a-f]+$/i.test(block.timestamp || "")
3150
+ || Math.abs(Number(BigInt(block.timestamp)) - now) > AUTHORIZATION_CLOCK_SKEW_SECONDS) throw new Error("authorization RPC clock mismatch");
3151
+ const tag = { blockHash: block.hash, requireCanonical: true };
3152
+ const limit = await this.contractCall(rpc, this.network.settlement_contract, "MAX_AUTHORIZATION_TTL()", [], tag);
3153
+ if (!/^0x[0-9a-f]{64}$/i.test(limit) || BigInt(limit) !== BigInt(maxTtl)) throw new Error("contract authorization lifetime differs from manifest");
3154
+ const grant = await this.contractCall(rpc, this.network.settlement_contract, "keyGrants(address)", [this.paymentAddress], tag);
3155
+ if (!/^0x[0-9a-f]{256}$/i.test(grant)) throw new Error("invalid authorization key grant");
3156
+ const words = grant.slice(2).match(/.{64}/g);
3157
+ const validUntil = BigInt(`0x${words[2]}`);
3158
+ if (!/^0{24}/.test(words[0]) || BigInt(`0x${words[0]}`) === 0n || BigInt(`0x${words[3]}`) !== 1n
3159
+ || BigInt(`0x${words[1]}`) < BigInt(this.maxFeeUnits)
3160
+ || (validUntil !== 0n && validUntil < BigInt(now + seconds))) throw new Error("payment key does not cover the full settlement window");
3161
+ return { now, seconds, maxTtl };
3162
+ });
1066
3163
  }
1067
3164
 
1068
- buildRelayPayment(path, body, health, requestId) {
3165
+ buildRelayPayment(path, body, health, requestId, timing) {
1069
3166
  const endpoint = path.endsWith("/chat/completions") ? "chat" : "responses";
1070
- const v8 = health?.v8;
3167
+ const v8 = this.capabilities(health);
1071
3168
  if (!v8) throw new Error("Relay health has no V8 payment requirements");
1072
- 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}`);
1073
3181
  const maxOutput = body.max_output_tokens ?? body.max_tokens ?? v8.maxOutputTokens ?? 2000;
1074
3182
  const options = {};
1075
3183
  for (const field of [...RESPONSES_REQUEST_OPTION_FIELDS, ...RESPONSES_LOCAL_OPTION_FIELDS]) {
@@ -1084,8 +3192,18 @@ export class NativeConsumerState {
1084
3192
  maxOutputTokens: maxOutput,
1085
3193
  options: normalizedOptions || undefined,
1086
3194
  });
1087
- 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
+ }
1088
3205
  const payment = buildAuthorization({
3206
+ protocolVersion: this.network.protocol_version,
1089
3207
  paymentKey: this.paymentKey,
1090
3208
  chainId: Number(v8.chain_id),
1091
3209
  settlementContract: v8.settlement_contract,
@@ -1098,25 +3216,107 @@ export class NativeConsumerState {
1098
3216
  pricingHash: v8.pricing_hash,
1099
3217
  maxFee: this.maxFeeUnits,
1100
3218
  issuedAt: now - AUTHORIZATION_CLOCK_SKEW_SECONDS,
1101
- deadline: now + 900,
3219
+ deadline: now + (timing?.seconds ?? 900),
3220
+ maxAuthorizationTtlSeconds: timing?.maxTtl ?? 3600,
1102
3221
  });
1103
3222
  return { payment, request_id: requestId, model };
1104
3223
  }
1105
3224
 
1106
- async relayInference(path, body) {
1107
- 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
+ });
1108
3281
  const used = new Set();
1109
3282
  let lastError = "no Relay accepted the request";
1110
3283
  let lastResponse;
1111
3284
  while (used.size < this.relayUrls.length) {
1112
3285
  let selected;
1113
- try { selected = await this.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
+ }
1114
3295
  used.add(selected.relayUrl);
3296
+ if (selected.reserved) {
3297
+ this.relayReservations.set(selected.relayUrl, Math.max(0, (this.relayReservations.get(selected.relayUrl) || 1) - 1));
3298
+ selected.reserved = false;
3299
+ }
3300
+ const route = this.sessionRoutes.get(session.id);
3301
+ if (route) { route.inFlight += 1; route.dispatchPending = false; }
3302
+ this.relayInFlight.set(selected.relayUrl, (this.relayInFlight.get(selected.relayUrl) || 0) + 1);
3303
+ let successfulHttpResponse = false;
3304
+ let postStarted = false;
3305
+ let dispatchPayment;
1115
3306
  try {
1116
3307
  const requestBody = {
1117
3308
  ...body,
1118
- model: selected.health.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),
1119
3312
  };
3313
+ if (route?.providerSigner) {
3314
+ const supplied = requestBody.metadata?.mycomesh_provider_signer;
3315
+ if (supplied && String(supplied).toLowerCase() !== route.providerSigner.toLowerCase()) {
3316
+ return { payload: openaiError("Provider hint conflicts with the original session", "invalid_session"), status: 409, headers: {} };
3317
+ }
3318
+ requestBody.metadata = { ...requestBody.metadata, mycomesh_provider_signer: route.providerSigner };
3319
+ }
1120
3320
  if (!String(requestBody.prompt_cache_key || "").trim()) {
1121
3321
  const cacheKey = derivePromptCacheKey({
1122
3322
  endpoint: path.endsWith("/chat/completions") ? "chat" : "responses",
@@ -1127,54 +3327,217 @@ export class NativeConsumerState {
1127
3327
  });
1128
3328
  if (cacheKey) requestBody.prompt_cache_key = cacheKey;
1129
3329
  }
1130
- const 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;
1131
3335
  requestBody.model = payment.model;
1132
3336
  const encodedPayment = base64Url(Buffer.from(stableStringify(payment.payment), "utf8"));
3337
+ let milliseconds = remainingMs(deadline);
3338
+ this.verifyRelayIdentity(selected.relayUrl, selected.health);
3339
+ // Persist the request identity before any execution can occur. A crash
3340
+ // or lost response must leave a scoped record that can be reconciled.
3341
+ dispatchPayment = payment;
3342
+ try { this.recordDispatch(selected.relayUrl, path, payment, session.id); }
3343
+ catch { return { payload: openaiError("Cannot save request tracking; request was not dispatched", "request_tracking_unavailable"), status: 503, headers: { "x-mycomesh-request-id": requestId } }; }
3344
+ milliseconds = remainingMs(deadline);
3345
+ postStarted = true;
1133
3346
  const response = await fetchWithTimeout(`${selected.relayUrl}${path}`, {
1134
3347
  method: "POST",
1135
- headers: { "content-type": "application/json", accept: "application/json", "PAYMENT-SIGNATURE": encodedPayment },
3348
+ headers: { "content-type": "application/json", accept: "application/json", "PAYMENT-SIGNATURE": encodedPayment,
3349
+ ...(proofRequired ? { "X-MycoMesh-Response-Proof": RESPONSE_PROOF_SCHEMA } : {}),
3350
+ "X-MycoMesh-Request-Timeout-Ms": String(milliseconds) },
1136
3351
  body: JSON.stringify(requestBody),
1137
3352
  dispatcher: this.dispatcher,
1138
- }, this.timeoutMs);
1139
- 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;
1140
3363
  const retryAfter = response.headers.get("retry-after");
1141
- const headers = retryAfter ? { "Retry-After": retryAfter } : {};
1142
- 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);
1143
3377
  lastError = payload?.error?.message || `Relay returned HTTP ${response.status}`;
1144
3378
  lastResponse = { payload, status: response.status, headers };
3379
+ if (this.network.protocol_version === 10 || keepSession || payload?.error?.execution_status !== "not_dispatched") break;
3380
+ this.sessionRoutes.delete(session.id);
1145
3381
  continue;
1146
3382
  }
1147
- if (response.status >= 400) return { payload: normalizeOpenAiError(payload, "relay_error"), status: response.status, headers };
1148
- 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");
1149
3386
  if (paymentResponse) {
1150
- const settlement = decodePaymentResponse(paymentResponse);
1151
- this.recordReceipt(
1152
- selected.relayUrl,
1153
- path,
1154
- String(body.model || payment.model),
1155
- settlement,
1156
- payment.model,
1157
- );
3387
+ const settlement = decodePaymentResponse(paymentResponse, {
3388
+ protocolVersion: this.network.protocol_version,
3389
+ chainId: this.network.chain_id, contract: this.network.settlement_contract,
3390
+ requestId, requestHash: payment.payment.authorization.request_hash,
3391
+ relay: payment.payment.authorization.relay, relaySigner: payment.payment.authorization.relay_signer,
3392
+ channel: payment.channel,
3393
+ requireJuryEvidence: this.network.committee_mode === DYNAMIC_PROVIDER_AI_COMMITTEE,
3394
+ juryRelayPublicKeys: this.network.jury_relay_public_keys,
3395
+ reporter: payment.channel?.consumer_owner,
3396
+ });
3397
+ const returnedAuthorization = settlement.signed_receipt.authorization;
3398
+ if (returnedAuthorization.authorization_hash !== payment.payment.authorization_hash || returnedAuthorization.key_signature !== payment.payment.key_signature) {
3399
+ throw new Error("Relay receipt changed the dispatched payment authorization");
3400
+ }
3401
+ const receipt = settlement.signed_receipt.receipt;
3402
+ const authorization = settlement.signed_receipt.authorization.authorization;
3403
+ if (authorization.request_id !== requestId || authorization.key.toLowerCase() !== this.paymentAddress
3404
+ || (route?.providerSigner && receipt.provider_signer.toLowerCase() !== route.providerSigner.toLowerCase())) {
3405
+ throw new Error("Relay receipt does not match this request or its pinned Provider");
3406
+ }
3407
+ // A fully signed, request-bound receipt may settle regardless of an
3408
+ // unsigned Relay accepted=false label; keep it eligible for RPC sync.
3409
+ settlement.accepted = true;
3410
+ let contentError = bodyError
3411
+ || (settlement.jury_evidence_error
3412
+ ? new Error(`Relay returned no valid recoverable jury evidence: ${settlement.jury_evidence_error}`)
3413
+ : null)
3414
+ || (!successfulHttpResponse ? new Error("Relay returned a payment receipt with an HTTP error") : null);
3415
+ headers["x-mycomesh-content-verification"] = contentError ? "failed" : "receipt-only";
3416
+ if (proofRequired && !contentError) {
3417
+ try {
3418
+ payload = verifyResponseProof(payload, receipt, { requestId,
3419
+ endpoint: path.endsWith("/chat/completions") ? "chat" : "responses", model: payment.model });
3420
+ headers["x-mycomesh-content-verification"] = "provider-signed";
3421
+ } catch (error) {
3422
+ contentError = error;
3423
+ headers["x-mycomesh-content-verification"] = "failed";
3424
+ }
3425
+ }
3426
+ if (route) route.providerSigner = receipt.provider_signer;
3427
+ try {
3428
+ this.recordReceipt(selected.relayUrl, path, String(body.model || payment.model), settlement, payment.model, session.id,
3429
+ headers["x-mycomesh-content-verification"]);
3430
+ } catch {
3431
+ // A persistence failure must never replay a completed, billed inference.
3432
+ this.historySyncError = "付款回执已收到,但账单保存失败;请保留本次响应回执";
3433
+ headers["x-mycomesh-history-status"] = "persistence-error";
3434
+ }
1158
3435
  headers["PAYMENT-RESPONSE"] = paymentResponse;
3436
+ if (contentError) {
3437
+ this.noteRelayFailure(selected.relayUrl);
3438
+ const result = { payload: openaiError("Response content verification failed; a valid payment receipt was received and may settle. Request was not replayed.", "content_verification_failed"), status: 502, headers };
3439
+ if (this.network.protocol_version === 10) {
3440
+ result.status = 422; result.headers["x-should-retry"] = "false";
3441
+ Object.assign(result.payload.error, { execution_status: "executed", retryable: false, request_id: requestId });
3442
+ }
3443
+ return result;
3444
+ }
1159
3445
  }
1160
- return { payload: restoreClientResponse(payload, body), status: 200, headers };
3446
+ this.sessions.remember(payload, session.id);
3447
+ return { payload: restoreClientResponse(payload, clientBody), status: 200, headers };
1161
3448
  } catch (error) {
1162
3449
  lastError = error.message;
3450
+ if (!postStarted && dispatchPayment) {
3451
+ try { this.recordDispatch(selected.relayUrl, path, dispatchPayment, session.id, "not_dispatched"); } catch {}
3452
+ }
3453
+ if (postStarted && dispatchPayment) {
3454
+ try { this.recordDispatch(selected.relayUrl, path, dispatchPayment, session.id, "outcome_unknown"); } catch {}
3455
+ }
3456
+ if (postStarted) this.noteRelayFailure(selected.relayUrl);
3457
+ if (postStarted && this.network.protocol_version === 10) {
3458
+ const payload = openaiError("Execution or payment status is unknown; this request was not replayed. Check its request ID before retrying.", "relay_outcome_unknown");
3459
+ Object.assign(payload.error, { execution_status: "unknown", retryable: false, request_id: requestId });
3460
+ const transportCode = error?.cause?.code ?? error?.code;
3461
+ if (["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"].includes(transportCode)) {
3462
+ payload.error.transport_code = transportCode;
3463
+ }
3464
+ return { payload, status: 422, headers: { "x-should-retry": "false", "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId } };
3465
+ }
3466
+ if (!postStarted && error.code === "rpc_unavailable") {
3467
+ lastResponse = preDispatchFailure(lastError, "rpc_unavailable");
3468
+ break;
3469
+ }
3470
+ if (!postStarted && error.statusCode === 504) {
3471
+ lastResponse = preDispatchFailure(lastError, "request_timeout", 504);
3472
+ break;
3473
+ }
3474
+ if (successfulHttpResponse) {
3475
+ lastResponse = { payload: openaiError(lastError, "invalid_relay_response"), status: 502,
3476
+ headers: { "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId } };
3477
+ break;
3478
+ }
3479
+ if (postStarted) {
3480
+ // A lost connection is not proof that a billed inference did not run.
3481
+ lastResponse = { payload: openaiError("Relay outcome is unknown; request was not replayed", "relay_outcome_unknown"), status: 502,
3482
+ headers: { "x-mycomesh-session-id": session.id, "x-mycomesh-request-id": requestId } };
3483
+ break;
3484
+ }
3485
+ if (keepSession) break;
3486
+ this.sessionRoutes.delete(session.id);
3487
+ } finally {
3488
+ this.relayInFlight.set(selected.relayUrl, Math.max(0, (this.relayInFlight.get(selected.relayUrl) || 1) - 1));
3489
+ if (route) { route.inFlight = Math.max(0, route.inFlight - 1); route.lastUsed = Date.now(); }
1163
3490
  }
1164
3491
  }
1165
3492
  if (lastResponse) return lastResponse;
1166
- return { payload: openaiError(lastError, "relay_unavailable"), status: 503, headers: { "Retry-After": "2" } };
3493
+ return preDispatchFailure(lastError, keepSession ? "session_unavailable" : "relay_unavailable");
1167
3494
  }
1168
3495
  }
1169
3496
 
1170
- function decodePaymentResponse(value) {
3497
+ function decodePaymentResponse(value, expected = {}) {
1171
3498
  let payload;
1172
3499
  try { payload = JSON.parse(Buffer.from(String(value), "base64url").toString("utf8")); } catch { throw new Error("Relay returned an invalid PAYMENT-RESPONSE"); }
1173
3500
  if (!payload || typeof payload !== "object" || !payload.signed_receipt) throw new Error("Relay PAYMENT-RESPONSE is missing its signed receipt");
1174
- try { 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}`); }
1175
3519
  return payload;
1176
3520
  }
1177
3521
 
3522
+ export function verifyResponseProof(proof, receipt, { requestId, endpoint, model }) {
3523
+ if (!proof || proof.schema !== RESPONSE_PROOF_SCHEMA || typeof proof.commitment_b64 !== "string"
3524
+ || proof.commitment_b64.length > MAX_BODY_BYTES) throw new Error("Relay response is missing its Provider content proof");
3525
+ const bytes = Buffer.from(proof.commitment_b64, "base64");
3526
+ if (bytes.toString("base64") !== proof.commitment_b64) throw new Error("Invalid Provider content proof encoding");
3527
+ if (`0x${createHash("sha256").update(bytes).digest("hex")}` !== receipt.response_hash) {
3528
+ throw new Error("Response content differs from its Provider-signed commitment");
3529
+ }
3530
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
3531
+ if (!value || value.schema !== "mycomesh.provider-response-commitment.v1"
3532
+ || value.request_id !== requestId || value.endpoint !== endpoint || value.model !== model
3533
+ || !value.raw || typeof value.raw !== "object" || Array.isArray(value.raw)) {
3534
+ throw new Error("Provider content proof does not match this request");
3535
+ }
3536
+ // Only authenticated bytes supply the API result. Ignore unauthenticated
3537
+ // sibling fields; verify before model/schema restoration or SSE generation.
3538
+ return value.raw;
3539
+ }
3540
+
1178
3541
  function normalizeOpenAiError(value, fallbackType) {
1179
3542
  if (!value || typeof value !== "object") return openaiError(String(value || fallbackType), fallbackType);
1180
3543
  if (!value.error || typeof value.error !== "object") return openaiError(value.detail || value.message || fallbackType, fallbackType);
@@ -1326,6 +3689,8 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
1326
3689
  if (alphaSearch) {
1327
3690
  body = {
1328
3691
  model: body.model,
3692
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
3693
+ ...(body.conversation !== undefined ? { conversation: body.conversation } : {}),
1329
3694
  input: [{ type: "mycomesh_alpha_search_request", request: body, query: alphaSearchQuery }],
1330
3695
  max_output_tokens: body.max_output_tokens ?? 2000,
1331
3696
  };
@@ -1340,7 +3705,7 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
1340
3705
  body.input = items;
1341
3706
  }
1342
3707
  try {
1343
- const result = await state.relayInference(path, body);
3708
+ const result = await state.relayInference(path, body, request.headers);
1344
3709
  if (result.status >= 400) {
1345
3710
  writeJson(response, result.status, result.payload, result.headers);
1346
3711
  return;
@@ -1356,7 +3721,7 @@ async function handleInference(state, request, response, path, alphaSearchQuery,
1356
3721
  return;
1357
3722
  }
1358
3723
  if (body.stream === true) {
1359
- response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive", "x-mycomesh-streaming-mode": "buffered" });
3724
+ response.writeHead(200, { ...result.headers, "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive", "x-mycomesh-streaming-mode": "buffered" });
1360
3725
  const events = path.endsWith("/chat/completions")
1361
3726
  ? chatCompletionSse(result.payload, body.stream_options?.include_usage === true)
1362
3727
  : responseSse(result.payload, path.endsWith("/responses/compact") || hasCompactionTrigger(body.input));
@@ -1377,9 +3742,9 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1377
3742
  const path = url.pathname.startsWith("/v1/v1/") ? url.pathname.slice(3) : url.pathname;
1378
3743
  if (publicOnly) {
1379
3744
  response.setHeader("Access-Control-Allow-Origin", "*");
1380
- response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
3745
+ response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-MycoMesh-Session-Id, X-Session-Id, session_id");
1381
3746
  response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1382
- response.setHeader("Access-Control-Expose-Headers", "PAYMENT-RESPONSE, Retry-After");
3747
+ response.setHeader("Access-Control-Expose-Headers", "PAYMENT-RESPONSE, Retry-After, X-MycoMesh-Session-Id, X-MycoMesh-History-Status, X-MycoMesh-Content-Verification");
1383
3748
  if (request.method === "OPTIONS") { response.writeHead(204); response.end(); return; }
1384
3749
  if (request.method === "GET" && (path === "/health" || path === "/v1/health")) {
1385
3750
  if (!state.authorizeBearer(String(request.headers.authorization || ""), { shareOnly: true })) { writeJson(response, 401, openaiError("invalid temporary access key", "invalid_api_key")); return; }
@@ -1388,7 +3753,7 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1388
3753
  }
1389
3754
  if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
1390
3755
  if (!state.authorizeBearer(String(request.headers.authorization || ""), { shareOnly: true })) { writeJson(response, 401, openaiError("invalid temporary access key", "invalid_api_key")); return; }
1391
- try { const selected = await state.chooseRelay(); writeJson(response, 200, { object: "list", data: [{ 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 })) }); }
1392
3757
  catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
1393
3758
  return;
1394
3759
  }
@@ -1402,6 +3767,24 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1402
3767
  writeJson(response, 404, openaiError("route not found", "invalid_request_error"));
1403
3768
  return;
1404
3769
  }
3770
+ if (path === "/" || path === "/credentials" || path === "/codex-env" || path.startsWith("/v1/mycomesh/local/")) {
3771
+ const remote = request.socket.remoteAddress || "";
3772
+ const authority = String(request.headers.host || "").toLowerCase();
3773
+ const localPort = server.address()?.port;
3774
+ const localAuthority = new Set([`127.0.0.1:${localPort}`, `localhost:${localPort}`, `[::1]:${localPort}`]);
3775
+ const origin = request.headers.origin;
3776
+ if (!(remote === "::1" || /^(?:::ffff:)?127\.\d+\.\d+\.\d+$/.test(remote))
3777
+ || !localAuthority.has(authority)
3778
+ || (origin !== undefined && origin !== `http://${authority}`)
3779
+ || request.headers["sec-fetch-site"] === "cross-site") {
3780
+ writeJson(response, 403, { ok: false, error: "local management requires a same-origin loopback connection" });
3781
+ return;
3782
+ }
3783
+ response.setHeader("X-Frame-Options", "DENY");
3784
+ response.setHeader("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'none'; object-src 'none'");
3785
+ response.setHeader("Referrer-Policy", "no-referrer");
3786
+ response.setHeader("X-Content-Type-Options", "nosniff");
3787
+ }
1405
3788
  if (request.method === "GET" && path === "/") {
1406
3789
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1407
3790
  response.end(consumerHtml());
@@ -1409,16 +3792,41 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1409
3792
  }
1410
3793
  if (request.method === "GET" && path === "/health") { writeJson(response, 200, state.healthPayload()); return; }
1411
3794
  if (request.method === "GET" && path === "/ready") {
1412
- try { const selected = await state.chooseRelay(); writeJson(response, 200, { ok: true, relay: selected.relayUrl, model: selected.health.v8.model || DEFAULT_MODEL }); }
1413
- 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);
1414
3821
  return;
1415
3822
  }
1416
3823
  if (request.method === "GET" && (path === "/credentials" || path === "/codex-env")) {
1417
3824
  if (!state.paymentUnlocked) { writeJson(response, 423, { ok: false, error: "sign in with the payment-key owner wallet first" }); return; }
3825
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1418
3826
  response.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); response.end(`${state.credentialsText()}\n`); return;
1419
3827
  }
1420
3828
  if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
1421
- try { 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() }); }
1422
3830
  catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
1423
3831
  return;
1424
3832
  }
@@ -1429,12 +3837,30 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1429
3837
  }
1430
3838
  if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/authenticate") {
1431
3839
  try { writeJson(response, 200, await state.authenticateWallet(await decodeRequestBody(request))); }
1432
- catch (error) { writeJson(response, 401, { ok: false, error: error.message }); }
3840
+ catch (error) {
3841
+ const payload = { ok: false, error: error.message };
3842
+ // Keep the legacy error string, and expose only explicit public fields.
3843
+ if (["payment_key_owner_mismatch", "wallet_verification_unavailable"].includes(error.code)) {
3844
+ payload.code = error.code;
3845
+ payload.selected_wallet = error.selected_wallet;
3846
+ payload.payment_key_address = error.payment_key_address;
3847
+ if (error.expected_owner) payload.expected_owner = error.expected_owner;
3848
+ }
3849
+ writeJson(response, error.code === "wallet_verification_unavailable" ? 503 : 401, payload);
3850
+ }
1433
3851
  return;
1434
3852
  }
1435
3853
  if (request.method === "GET" && path === "/v1/mycomesh/local/dashboard") {
1436
3854
  writeJson(response, 200, await state.dashboardPayload(state.authorizeManagement(String(request.headers.authorization || "")))); return;
1437
3855
  }
3856
+ if (request.method === "POST" && path === "/v1/mycomesh/local/history/sync") {
3857
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
3858
+ await state.refreshDisputeTransactions(true);
3859
+ await state.refreshReceiptStatuses(true);
3860
+ writeJson(response, 200, { ok: true, history_sync: {
3861
+ running: Boolean(state.historySync), last_checked_at: state.historySyncAt, error: state.historySyncError,
3862
+ } }); return;
3863
+ }
1438
3864
  if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/activate") {
1439
3865
  if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1440
3866
  try { writeJson(response, 200, await state.activateCurrentPaymentKey()); }
@@ -1451,6 +3877,12 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1451
3877
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1452
3878
  return;
1453
3879
  }
3880
+ if (request.method === "POST" && path === "/v1/mycomesh/local/disputes/progress") {
3881
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
3882
+ try { writeJson(response, 200, state.recordDisputeProgress(await decodeRequestBody(request))); }
3883
+ catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
3884
+ return;
3885
+ }
1454
3886
  if (request.method === "POST" && path === "/v1/mycomesh/local/key/prepare") {
1455
3887
  if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1456
3888
  try { writeJson(response, 200, state.preparePaymentKey()); } catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
@@ -1598,45 +4030,146 @@ function consumerHtml() {
1598
4030
  <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light">
1599
4031
  <title>Consumer | MycoMesh</title>
1600
4032
  <style>
1601
- :root{--ink:#17211d;--muted:#68736e;--line:#d8dfdb;--soft:#f2f5f3;--paper:#fff;--green:#147553;--green-dark:#0d5b40;--amber:#9a6413;--red:#ae3d38;--blue:#365d86}*{box-sizing:border-box}[hidden]{display:none!important}html{background:#edf1ee}body{min-width:320px;margin:0;color:var(--ink);background:#edf1ee;font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}.shell{min-height:100vh}.topbar{position:sticky;z-index:10;top:0;display:flex;min-height:60px;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 max(18px,env(safe-area-inset-left))}.brand{display:flex;align-items:center;gap:10px;font-weight:780}.mark{display:grid;width:30px;height:30px;place-items:center;border-radius:6px;background:var(--ink);color:#fff;font-size:12px}.network{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#9ba49f}.dot.ok{background:var(--green)}.workspace{width:min(760px,100%);margin:0 auto;background:var(--paper);min-height:calc(100vh - 60px)}.locked{display:grid;min-height:calc(100vh - 60px);align-content:center;padding:42px 24px 88px}.locked-inner{width:min(420px,100%);margin:0 auto}.locked-badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:5px 10px;color:var(--muted);font-size:12px}.locked h1{margin:20px 0 9px;font-size:30px;line-height:1.16}.locked p{margin:0 0 26px;color:var(--muted)}.key-preview{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:14px 0;margin:0 0 22px}.label{display:block;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.key-preview .mono{display:block;margin-top:5px;font-size:12px}.button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:6px;background:var(--paper);padding:0 15px;color:var(--ink);font-weight:700}.button:hover{border-color:#95a39b;background:#f8faf9}.button.primary{border-color:var(--green);background:var(--green);color:#fff}.button.primary:hover{background:var(--green-dark)}.button.danger{border-color:#e5b6b3;color:var(--red)}.button.small{min-height:34px;padding:0 11px;font-size:12px}.button:disabled{cursor:not-allowed;opacity:.52}.locked .button{width:100%;min-height:48px}.app-head{padding:24px 20px 17px}.app-head-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.app-head h1{margin:0;font-size:22px}.wallet-button{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.balance{margin-top:22px}.balance strong{display:block;margin-top:4px;font-size:31px;font-weight:760}.balance-meta{display:flex;gap:16px;margin-top:8px;color:var(--muted);font-size:12px}.tabs{position:sticky;z-index:8;top:60px;display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 12px}.tab{min-width:0;border:0;border-bottom:2px solid transparent;background:transparent;padding:12px 4px;color:var(--muted);font-weight:650}.tab.active{border-color:var(--green);color:var(--ink)}.view{padding:2px 20px 84px}.band{border-top:1px solid var(--line);padding:23px 0}.band:first-child{border-top:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--muted);font-size:12px}.status{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:4px 9px;color:var(--muted);font-size:11px;white-space:nowrap}.status:before{width:6px;height:6px;border-radius:50%;background:var(--amber);content:""}.status.ok:before{background:var(--green)}.field+.field{margin-top:13px}.field-row{display:flex;align-items:stretch;gap:7px;margin-top:6px}.value{min-width:0;flex:1;border:1px solid var(--line);border-radius:5px;background:var(--soft);padding:10px 11px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.value.exports{min-height:76px;white-space:pre-wrap}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);border-radius:6px;overflow:hidden}.metric{min-width:0;padding:14px 10px;border-right:1px solid var(--line)}.metric:last-child{border-right:0}.metric span{display:block;color:var(--muted);font-size:11px}.metric strong{display:block;overflow:hidden;margin-top:4px;font-size:17px;text-overflow:ellipsis}.list{margin:0}.list div{display:grid;grid-template-columns:104px minmax(0,1fr);gap:12px;border-bottom:1px solid var(--line);padding:12px 0}.list div:last-child{border-bottom:0}.list dt{color:var(--muted)}.list dd{overflow:hidden;margin:0;text-align:right;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.topup{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.input,.select{width:100%;min-height:42px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:8px 10px;color:var(--ink)}.input:focus,.select:focus{border-color:var(--green);outline:2px solid rgba(20,117,83,.13)}.share-output{margin-top:16px}.empty{padding:34px 0;color:var(--muted);text-align:center}.table-wrap{overflow-x:auto;border-top:1px solid var(--line)}table{width:100%;min-width:620px;border-collapse:collapse}th,td{padding:11px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:12px}th{color:var(--muted);font-weight:650}.notice{border-left:3px solid var(--amber);background:#fff8e9;padding:10px 12px;color:#76511c;font-size:12px}.notice.error{border-color:var(--red);background:#fff3f2;color:#892f2b}.toast{position:fixed;z-index:30;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));left:16px;max-width:520px;margin:auto;border-radius:6px;background:var(--ink);padding:11px 14px;color:#fff;box-shadow:0 10px 30px rgba(23,33,29,.2);text-align:center}.toast.error{background:#7e2925}@media(min-width:761px){body{padding:22px}.shell{border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:0 18px 50px rgba(23,33,29,.08)}.workspace{min-height:calc(100vh - 106px)}.topbar{position:relative}.tabs{top:0}.view,.app-head{padding-right:28px;padding-left:28px}.toast{bottom:28px}}@media(max-width:420px){.balance strong{font-size:27px}.metrics{grid-template-columns:1fr}.metric{display:flex;align-items:center;justify-content:space-between;border-right:0;border-bottom:1px solid var(--line)}.metric:last-child{border-bottom:0}.metric strong{margin:0}.field-row{flex-direction:column}.field-row .button{width:100%}.topup{grid-template-columns:1fr}.topup .button{width:100%}}
4033
+ :root{--ink:#17211d;--muted:#68736e;--line:#d8dfdb;--soft:#f2f5f3;--paper:#fff;--green:#147553;--green-dark:#0d5b40;--amber:#9a6413;--red:#ae3d38;--blue:#365d86}*{box-sizing:border-box}[hidden]{display:none!important}html{background:#edf1ee}body{min-width:320px;margin:0;color:var(--ink);background:#edf1ee;font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}.shell{min-height:100vh}.topbar{position:sticky;z-index:10;top:0;display:flex;min-height:60px;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 max(18px,env(safe-area-inset-left))}.brand{display:flex;align-items:center;gap:10px;font-weight:780}.mark{display:grid;width:30px;height:30px;place-items:center;border-radius:6px;background:var(--ink);color:#fff;font-size:12px}.network{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#9ba49f}.dot.ok{background:var(--green)}.workspace{width:min(760px,100%);margin:0 auto;background:var(--paper);min-height:calc(100vh - 60px)}.locked{display:grid;min-height:calc(100vh - 60px);align-content:center;padding:42px 24px 88px}.locked-inner{width:min(420px,100%);margin:0 auto}.locked-badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:5px 10px;color:var(--muted);font-size:12px}.locked h1{margin:20px 0 9px;font-size:30px;line-height:1.16}.locked p{margin:0 0 26px;color:var(--muted)}.key-preview{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:14px 0;margin:0 0 22px}.label{display:block;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.key-preview .mono{display:block;margin-top:5px;font-size:12px}.button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:6px;background:var(--paper);padding:0 15px;color:var(--ink);font-weight:700}.button:hover{border-color:#95a39b;background:#f8faf9}.button.primary{border-color:var(--green);background:var(--green);color:#fff}.button.primary:hover{background:var(--green-dark)}.button.danger{border-color:#e5b6b3;color:var(--red)}.button.small{min-height:34px;padding:0 11px;font-size:12px}.button:disabled{cursor:not-allowed;opacity:.52}.locked .button{width:100%;min-height:48px}.app-head{padding:24px 20px 17px}.app-head-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.app-head h1{margin:0;font-size:22px}.wallet-button{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.balance{margin-top:22px}.balance strong{display:block;margin-top:4px;font-size:31px;font-weight:760}.balance-meta{display:flex;gap:16px;margin-top:8px;color:var(--muted);font-size:12px}.tabs{position:sticky;z-index:8;top:60px;display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 12px}.tab{min-width:0;border:0;border-bottom:2px solid transparent;background:transparent;padding:12px 4px;color:var(--muted);font-weight:650}.tab.active{border-color:var(--green);color:var(--ink)}.view{padding:2px 20px 84px}.band{border-top:1px solid var(--line);padding:23px 0}.band:first-child{border-top:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--muted);font-size:12px}.status{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:4px 9px;color:var(--muted);font-size:11px;white-space:nowrap}.status:before{width:6px;height:6px;border-radius:50%;background:var(--amber);content:""}.status.ok:before{background:var(--green)}.field+.field{margin-top:13px}.field-row{display:flex;align-items:stretch;gap:7px;margin-top:6px}.value{min-width:0;flex:1;border:1px solid var(--line);border-radius:5px;background:var(--soft);padding:10px 11px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.value.exports{min-height:76px;white-space:pre-wrap}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);border-radius:6px;overflow:hidden}.metric{min-width:0;padding:14px 10px;border-right:1px solid var(--line)}.metric:last-child{border-right:0}.metric span{display:block;color:var(--muted);font-size:11px}.metric strong{display:block;overflow:hidden;margin-top:4px;font-size:17px;text-overflow:ellipsis}.list{margin:0}.list div{display:grid;grid-template-columns:104px minmax(0,1fr);gap:12px;border-bottom:1px solid var(--line);padding:12px 0}.list div:last-child{border-bottom:0}.list dt{color:var(--muted)}.list dd{overflow:hidden;margin:0;text-align:right;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.topup{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.input,.select{width:100%;min-height:42px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:8px 10px;color:var(--ink)}.input:focus,.select:focus{border-color:var(--green);outline:2px solid rgba(20,117,83,.13)}.share-output{margin-top:16px}.empty{padding:34px 0;color:var(--muted);text-align:center}.table-wrap{overflow-x:auto;border-top:1px solid var(--line)}table{width:100%;min-width:620px;border-collapse:collapse}th,td{padding:11px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:12px}th{color:var(--muted);font-weight:650}.notice{border-left:3px solid var(--amber);background:#fff8e9;padding:10px 12px;color:#76511c;font-size:12px}.notice.error{border-color:var(--red);background:#fff3f2;color:#892f2b}.recovery{display:flex;align-items:center;justify-content:space-between;gap:12px}.recovery span{flex:1}.toast{position:fixed;z-index:30;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));left:16px;max-width:520px;margin:auto;border-radius:6px;background:var(--ink);padding:11px 14px;color:#fff;box-shadow:0 10px 30px rgba(23,33,29,.2);text-align:center}.toast.error{background:#7e2925}@media(min-width:761px){body{padding:22px}.shell{border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:0 18px 50px rgba(23,33,29,.08)}.workspace{min-height:calc(100vh - 106px)}.topbar{position:relative}.tabs{top:0}.view,.app-head{padding-right:28px;padding-left:28px}.toast{bottom:28px}}@media(max-width:420px){.balance strong{font-size:27px}.metrics{grid-template-columns:1fr}.metric{display:flex;align-items:center;justify-content:space-between;border-right:0;border-bottom:1px solid var(--line)}.metric:last-child{border-bottom:0}.metric strong{margin:0}.field-row{flex-direction:column}.field-row .button{width:100%}.topup{grid-template-columns:1fr}.topup .button{width:100%}}
1602
4034
  </style></head><body>
1603
- <div class="shell"><header class="topbar"><div class="brand"><span class="mark">M</span><span>MycoMesh</span></div><div class="network"><span id="networkDot" class="dot"></span><span id="networkName">V8 Consumer</span></div></header><main class="workspace">
1604
- <div id="locked" class="locked"><div class="locked-inner"><span class="locked-badge">Consumer V8</span><h1>连接钱包以继续</h1><p>签名后读取并核对当前 Key 的链上归属。</p><div class="key-preview"><span class="label">本地 Key 地址</span><span id="lockedKey" class="mono">读取中...</span></div><button id="login" class="button primary" type="button">连接钱包并签名</button><p id="loginError" class="notice error" hidden></p></div></div>
1605
- <div id="app" hidden><div class="app-head"><div class="app-head-row"><div><span class="label">预付账户</span><h1>Consumer</h1></div><button id="walletButton" class="button small wallet-button" type="button">退出</button></div><div class="balance"><span class="label">可用余额</span><strong id="balance">--</strong><div class="balance-meta"><span id="keyStatus">Key 状态 --</span><span id="requestCount">0 次请求</span></div></div></div>
4035
+ <div class="shell"><header class="topbar"><div class="brand"><span class="mark">M</span><span>MycoMesh</span></div><div class="network"><span id="networkDot" class="dot"></span><span id="networkName">MycoMesh</span></div></header><main class="workspace">
4036
+ <div id="locked" class="locked"><div class="locked-inner"><span class="locked-badge">MycoMesh API</span><h1>连接钱包以继续</h1><p>连接钱包后获取 API 地址和访问密钥。无需填写钱包私钥。</p><p>请在已安装钱包扩展的浏览器中打开此本机地址;未安装钱包时可先安装,再刷新连接。</p><p id="loginOwnerHint" class="notice mono" aria-live="polite">正在核对本机付款 Key 的归属…</p><details class="key-preview"><summary>设备访问凭证详情</summary><span id="lockedKey" class="mono">读取中...</span></details><button id="login" class="button primary" type="button">连接钱包并签名</button><p id="loginError" class="notice error mono" role="alert" hidden></p></div></div>
4037
+ <div id="app" hidden><div class="app-head"><div class="app-head-row"><div><span class="label">预付账户</span><h1>Consumer</h1></div><button id="walletButton" class="button small wallet-button" type="button">退出</button></div><div class="balance"><span id="balanceLabel" class="label">可用余额</span><strong id="balance">--</strong><div class="balance-meta"><span id="keyStatus">Key 状态 --</span><span id="requestCount">0 次请求</span></div></div></div>
1606
4038
  <nav class="tabs" aria-label="Consumer navigation"><button class="tab active" data-view="overview" type="button">概览</button><button class="tab" data-view="wallet" type="button">钱包</button><button class="tab" data-view="activity" type="button">记录</button><button class="tab" data-view="share" type="button">分享</button></nav>
1607
- <div id="view-overview" class="view"><div class="band"><div class="section-head"><div><h2>访问凭证</h2><p id="credentialState">等待 Key 激活</p></div><span id="credentialBadge" class="status">锁定</span></div><div id="credentials" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="url" class="value"></div><button class="button small copy" data-copy="url" type="button">复制</button></div></div><div class="field"><span class="label">Key</span><div class="field-row"><div id="key" class="value"></div><button class="button small copy" data-copy="key" type="button">复制</button></div></div><div class="field"><span class="label">Export</span><div class="field-row"><div id="export" class="value exports"></div><button class="button small copy" data-copy="export" type="button">复制</button></div></div></div><div id="inactiveKey" class="notice">当前 Key 尚未在链上激活。</div></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>
1608
- <div id="view-wallet" class="view" hidden><div class="band"><div class="section-head"><h2>钱包与 Key</h2><span id="chainStatus" class="status">读取中</span></div><dl class="list"><div><dt>钱包</dt><dd id="walletAddress" class="mono"></dd></div><div><dt>Key 地址</dt><dd id="keyAddress" class="mono"></dd></div><div><dt>单次上限</dt><dd id="keyLimit"></dd></div><div><dt>有效期</dt><dd id="keyValidity"></dd></div></dl><p id="chainError" class="notice error" hidden></p><div class="actions"><button id="activate" class="button primary" type="button">激活 Key</button><button id="rotate" class="button danger" type="button" hidden>更换 Key</button></div></div><div class="band"><div class="section-head"><div><h2>充值</h2><p id="walletBalance">钱包余额 --</p></div></div><div class="topup"><input id="amount" class="input" inputmode="decimal" placeholder="10.00 USDC"><button id="topup" class="button primary" type="button">充值</button></div></div></div>
1609
- <div id="view-activity" class="view" hidden><div class="band"><div class="section-head"><div><h2>消费记录</h2><p>当前设备已确认的推理账单</p></div><button id="refresh" 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></tr></thead><tbody id="history"></tbody></table></div></div></div>
4039
+ <div id="view-overview" class="view"><div class="band"><div class="section-head"><div><h2>调用就绪状态</h2><p>四项全部就绪后才可发起付费请求</p></div><span id="paidReadiness" class="status">检查中</span></div><dl class="list"><div><dt>钱包与 Key</dt><dd><span id="walletReadiness" class="status">检查中</span></dd></div><div><dt>网络与 RPC</dt><dd><span id="networkReadiness" class="status">检查中</span></dd></div><div><dt>固定预算</dt><dd><span id="budgetReadinessBadge" class="status">检查中</span></dd></div><div><dt>可用模型</dt><dd><span id="modelsReadiness" class="status">检查中</span></dd></div></dl><p id="readinessAction" class="notice"></p></div><div class="band"><div class="section-head"><div><h2>访问凭证</h2><p id="credentialState">等待 Key 激活</p></div><span id="credentialBadge" class="status">锁定</span></div><div id="credentials" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="url" class="value"></div><button class="button small copy" data-copy="url" type="button">复制</button></div></div><div class="field"><span class="label">Key</span><div class="field-row"><div id="key" class="value"></div><button class="button small copy" data-copy="key" type="button">复制</button></div></div><div class="field"><span class="label">Export</span><div class="field-row"><div id="export" class="value exports"></div><button class="button small copy" data-copy="export" type="button">复制</button></div></div></div><div id="inactiveKey" class="notice"><span id="activationNotice">首次使用需在钱包中确认一次访问授权。</span><div class="actions"><button id="setupAccess" class="button primary" type="button">启用 API 访问</button></div></div></div><div class="band"><div class="section-head"><div><h2>网络与模型</h2><p>只显示通过部署校验的可用 Relay 路由</p></div><span id="modelStatus" class="status">读取中</span></div><dl id="modelList" class="list"><div><dt>模型目录</dt><dd>正在读取…</dd></div></dl><p id="modelError" class="notice error" hidden></p></div><div class="band"><div class="section-head"><h2>本地用量</h2></div><div class="metrics"><div class="metric"><span>累计消费</span><strong id="spent">--</strong></div><div class="metric"><span>输入 Tokens</span><strong id="inputTokens">0</strong></div><div class="metric"><span>输出 Tokens</span><strong id="outputTokens">0</strong></div></div></div></div>
4040
+ <div id="view-wallet" class="view" hidden><div id="budgetPanel" class="band" hidden><div class="section-head"><h2>固定预算</h2><span id="budgetLocked" class="status"></span></div><p id="budgetNote" class="notice"></p><p id="budgetUnallocated" class="notice"></p><p class="notice">充值余额需开通固定预算后才能调用;充值不等于已有可调用预算。</p><p id="budgetReadiness" class="notice"></p><div id="budgetChannels"></div></div><div class="band"><div class="section-head"><h2>钱包与 Key</h2><span id="chainStatus" class="status">读取中</span></div><dl class="list"><div><dt>钱包</dt><dd id="walletAddress" class="mono"></dd></div><div><dt>Key 地址</dt><dd id="keyAddress" class="mono"></dd></div><div><dt>单次上限</dt><dd id="keyLimit"></dd></div><div><dt>有效期</dt><dd id="keyValidity"></dd></div></dl><p id="chainError" class="notice error" hidden></p><div class="actions"><button id="activate" class="button primary" type="button">激活 Key</button><button id="rotate" class="button danger" type="button" hidden>更换 Key</button></div></div><div class="band"><div class="section-head"><div><h2>充值</h2><p id="walletBalance">钱包余额 --</p></div></div><div class="topup"><input id="amount" class="input" type="number" min="0.000001" step="0.000001" inputmode="decimal" autocomplete="off" placeholder="10.00 USDC" aria-label="充值金额(USDC)"><button id="topup" class="button primary" type="button">充值</button></div></div></div>
4041
+ <div id="view-activity" class="view" hidden><div class="band"><div class="section-head"><div><h2>消费记录</h2><p>当前 Key 在本机各 Consumer 的账单,按请求去重</p></div><button id="refresh" class="button small" type="button">刷新</button></div><p id="historySync" class="notice" hidden></p><p class="notice">申诉仅用于签名证据能够证明的客观欺诈或服务未交付;错误申诉可能损失举报保证金。页面不会公开完整提示词或 Provider 响应。</p><div id="historyRecovery" class="notice error recovery" hidden><span id="historyRecoveryText"></span><button id="historyRecoveryButton" class="button small" type="button">立即核验</button></div><div id="historyEmpty" class="empty">暂无消费记录</div><div id="historyTable" class="table-wrap" hidden><table><thead><tr><th>时间</th><th>模型</th><th>Tokens</th><th>费用</th><th>Provider</th><th>状态</th><th>会话</th><th>申诉</th></tr></thead><tbody id="history"></tbody></table></div></div></div>
1610
4042
  <div id="view-share" class="view" hidden><div class="band"><div class="section-head"><div><h2>临时分享</h2><p>到期后自动关闭</p></div><span id="shareStatus" class="status">未启用</span></div><div class="topup"><select id="shareMinutes" class="select"><option value="10">10 分钟</option><option value="30" selected>30 分钟</option><option value="60">1 小时</option><option value="360">6 小时</option></select><button id="shareStart" class="button primary" type="button">开始分享</button></div><div id="shareOutput" class="share-output" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="shareUrl" class="value"></div><button class="button small copy" data-copy="shareUrl" type="button">复制</button></div></div><div class="field"><span class="label">临时 Key</span><div class="field-row"><div id="shareKey" class="value"></div><button class="button small copy" data-copy="shareKey" type="button">复制</button></div></div><p id="shareExpiry" class="notice"></p><div class="actions"><button id="shareStop" class="button danger" type="button">停止分享</button></div></div></div></div></div>
1611
4043
  </main></div><div id="toast" class="toast" hidden></div>
1612
4044
  <script>
1613
- let state=null,wallet=null,managementToken=null,busy=false;const $=id=>document.getElementById(id);const short=value=>value?value.slice(0,6)+'...'+value.slice(-4):'--';
1614
- function units(value,decimals=6){const raw=BigInt(value||0),base=10n**BigInt(decimals),whole=raw/base,fraction=(raw%base).toString().padStart(decimals,'0').replace(/0+$/,'');return whole.toLocaleString()+(fraction?'.'+fraction.slice(0,4):'')}
4045
+ let state=null,wallet=null,managementToken=null,busy=false,loading=null;const $=id=>document.getElementById(id);const short=value=>value?value.slice(0,6)+'...'+value.slice(-4):'--';
4046
+ function units(value,decimals=6){const raw=BigInt(value||0),base=10n**BigInt(decimals),whole=raw/base,fraction=(raw%base).toString().padStart(decimals,'0').replace(/0+$/,'');return whole.toLocaleString()+(fraction?'.'+fraction.slice(0,Math.max(6,Math.min(decimals,8))):'')}
1615
4047
  function authHeaders(headers={}){const next=new Headers(headers);if(managementToken)next.set('authorization','Bearer '+managementToken);return next}
1616
- async function api(path,options={}){const response=await fetch(path,{...options,headers:authHeaders(options.headers)}),data=await response.json();if(!response.ok)throw new Error(typeof data.error==='string'?data.error:(data.error?.message||'请求失败'));return data}
4048
+ async function api(path,options={}){const response=await fetch(path,{...options,headers:authHeaders(options.headers)}),data=await response.json();if(!response.ok){const error=new Error(typeof data.error==='string'?data.error:(data.error?.message||'请求失败'));for(const field of ['code','selected_wallet','expected_owner','payment_key_address'])if(typeof data[field]==='string')error[field]=data[field];if(error.code==='payment_key_owner_mismatch')error.message=ownerMismatchMessage(error);if(error.code==='wallet_verification_unavailable')error.message='暂时无法核验链上钱包归属或预算,请稍后重试。现有 Key、账单和已登录会话保持不变。';throw error}return data}
1617
4049
  function toast(message,error=false){const node=$('toast');node.textContent=message;node.className='toast'+(error?' error':'');node.hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>node.hidden=true,3200)}
1618
4050
  function setBusy(value){busy=value;for(const button of document.querySelectorAll('button'))button.disabled=value}
1619
- async function run(task){if(busy)return;setBusy(true);try{await task()}catch(error){toast(error?.message||String(error),true)}finally{setBusy(false)}}
1620
- async function load(){state=await api('/v1/mycomesh/local/dashboard');render()}
1621
- function render(){const authenticated=Boolean(managementToken&&state.auth?.authenticated),ready=authenticated&&state.auth.key_ready,grant=state.key.grant||{},decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';$('networkName').textContent=state.chain_error?'链上不可用':(state.settlement?.network_name||'V8 Consumer');$('networkDot').className='dot'+(state.chain_error?'':' ok');$('lockedKey').textContent=state.key.address;$('locked').hidden=authenticated;$('app').hidden=!authenticated;if(!authenticated)return;$('walletButton').textContent=short(state.auth.wallet);$('walletAddress').textContent=state.auth.wallet;$('keyAddress').textContent=state.key.address;$('balance').textContent=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:'钱包余额 --';renderHistory();renderShare()}
1622
- function renderHistory(){const body=$('history'),items=state.history||[];body.replaceChildren();$('historyEmpty').hidden=items.length>0;$('historyTable').hidden=items.length===0;for(const item of items){const row=document.createElement('tr');for(const value of [new Date(item.timestamp*1000).toLocaleString(),item.model,(item.input_tokens||0)+' / '+(item.output_tokens||0),units(item.actual_fee_units,state.settlement?.stablecoin_decimals||6),short(item.provider)]){const cell=document.createElement('td');cell.textContent=value;row.appendChild(cell)}body.appendChild(row)}}
4051
+ async function run(task){if(busy)return;setBusy(true);try{await task()}catch(error){const message=error?.message||String(error);if(!managementToken){$('loginError').textContent=message;$('loginError').hidden=false}toast(message,true)}finally{setBusy(false)}}
4052
+ async function load(){if(loading)return loading;loading=(async()=>{state=await api('/v1/mycomesh/local/dashboard');render()})();try{await loading}finally{loading=null}}
4053
+ function render(){const authenticated=Boolean(managementToken&&state.auth?.authenticated),ready=authenticated&&state.auth.key_ready,grant=state.key.grant||{},decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';$('networkName').textContent=state.chain_error?'链上不可用':(state.settlement?.network_name||'MycoMesh');$('networkDot').className='dot'+(state.inference_ready===true&&!state.chain_error?' ok':'');$('lockedKey').textContent=state.key.address;renderLoginOwner();$('locked').hidden=authenticated;$('app').hidden=!authenticated;if(!authenticated)return;renderReadiness();$('walletButton').textContent=short(state.auth.wallet);$('walletAddress').textContent=state.auth.wallet;$('keyAddress').textContent=state.key.address;$('balanceLabel').textContent=state.protocol_version===10?'可用请求预算':'可用余额';$('balance').textContent=state.protocol_version===10?units(state.budget_available_units||0,decimals)+' '+symbol:(state.account?units(state.account.available_balance_units,decimals)+' '+symbol:'--');$('requestCount').textContent=state.usage.request_count+' 次请求';$('keyStatus').textContent=ready?'Key 已激活':'Key 待激活';$('credentialBadge').className='status'+(ready?' ok':'');$('credentialBadge').textContent=ready?'可用':'待激活';$('credentialState').textContent=ready?'仅在本机显示':'链上确认后显示';$('credentials').hidden=!ready;$('inactiveKey').hidden=ready;if(ready){$('url').textContent=state.credentials.base_url;$('key').textContent=state.credentials.api_key;$('export').textContent=state.credentials.export}$('spent').textContent=units(state.usage.total_spent_units,decimals)+' '+symbol;$('inputTokens').textContent=Number(state.usage.input_tokens||0).toLocaleString();$('outputTokens').textContent=Number(state.usage.output_tokens||0).toLocaleString();$('keyLimit').textContent=grant.max_per_request?units(grant.max_per_request,decimals)+' '+symbol:'--';$('keyValidity').textContent=grant.valid_until?new Date(grant.valid_until*1000).toLocaleString():'长期有效';$('chainStatus').className='status'+(grant.active?' ok':'');$('chainStatus').textContent=grant.active?'链上有效':'等待激活';$('activate').hidden=ready;$('rotate').hidden=!ready;$('chainError').hidden=!state.chain_error;$('chainError').textContent=state.chain_error||'';$('walletBalance').textContent=state.wallet?'钱包余额 '+units(state.wallet.token_balance_units,decimals)+' '+symbol:'钱包余额 --';renderActivation();renderHistory();renderShare();renderBudget()}
4054
+ function renderReadiness(){
4055
+ const set=(id,ready,yes,no)=>{const node=$(id);node.className='status'+(ready?' ok':'');node.textContent=ready?yes:no};
4056
+ set('paidReadiness',state.paid_ready===true,'可调用','未就绪');
4057
+ set('walletReadiness',state.wallet_ready===true,'已验证','待验证');
4058
+ set('networkReadiness',state.network_ready===true,'已连接','不可用');
4059
+ set('budgetReadinessBadge',state.budget_ready===true,'可用','不可用');
4060
+ set('modelsReadiness',state.models_ready===true,'有可用线路','无可用线路');
4061
+ $('readinessAction').textContent=state.paid_ready===true?'钱包、网络、预算和模型线路均已就绪。':state.inference_code==='payment_key_not_ready'?'请先激活并验证当前钱包的付款 Key。':state.inference_code==='network_unavailable'?'请检查网络与 RPC 配置后刷新。':state.inference_code==='budget_not_started'?'固定预算尚未到启用时间。':state.inference_code==='budget_unavailable'?'当前没有覆盖可用 Provider 线路的固定预算;充值余额不等于可调用预算。':'当前没有可用模型或 Relay 线路。';
4062
+ }
4063
+ function renderActivation(){
4064
+ const grant=state.key.grant||{},ownedActive=grant.active===true&&grant.owner===state.auth?.wallet&&!state.key.pending;
4065
+ const exact=ownedActive&&grant.max_per_request===state.key.max_fee_units&&grant.valid_until===0;
4066
+ $('activate').textContent=ownedActive?'启用本地访问':'激活 Key';
4067
+ $('setupAccess').textContent=ownedActive?'启用本地访问':'启用 API 访问';
4068
+ $('activationNotice').textContent=exact?'链上授权已生效。启用此设备的本地访问后即可显示 API 凭证;可调用额度以账户预算为准。':ownedActive?'链上 Key 已生效,启用时将同步访问权限设置。':'首次使用需在钱包中确认一次访问授权。';
4069
+ }
4070
+ function renderBudget(){
4071
+ const panel=$('budgetPanel');panel.hidden=state.protocol_version!==10;if(panel.hidden)return;
4072
+ const decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'TestUSDC',now=Math.floor(Date.now()/1000);
4073
+ $('budgetLocked').textContent='可用 '+units(state.budget_available_units||0,decimals)+' '+symbol+' · 预算池 '+units(state.budget_locked_units||0,decimals)+' '+symbol;
4074
+ $('budgetNote').textContent=state.budget_note||'预算在结算窗口结束前不会撤回;结算按 2 小时或 100 笔先到触发。';
4075
+ const ownAccount=state.account&&state.account.owner===state.auth?.wallet;
4076
+ $('budgetUnallocated').textContent='已充值、尚未分配预算:'+(ownAccount?units(state.account.available_balance_units,decimals)+' '+symbol:'--');
4077
+ $('budgetReadiness').textContent=state.inference_ready?'预算与线路就绪 · 满 2 小时或 100 笔批量结算':state.inference_code==='budget_not_started'?'预算等待启用 · '+new Date(state.budget_available_at*1000).toLocaleString()+';启用前不会派发或扣费':state.inference_code==='budget_unavailable'?'当前没有可用固定预算,无法派发请求;不会扣费。请联系运营方续期开通预算后刷新本页':(state.inference_error||'正在确认预算和可用线路');
4078
+ const list=$('budgetChannels');list.replaceChildren();
4079
+ const channels=state.capacity_channels||[];
4080
+ if(!channels.length){list.textContent='尚未开通固定预算。受控测试期间,由运营方与 Provider 确认预算后开通。';return}
4081
+ for(const c of channels){
4082
+ const box=document.createElement('div');box.className='band';
4083
+ const title=document.createElement('strong');title.textContent='Provider '+short(c.provider_signer);box.appendChild(title);
4084
+ const info=document.createElement('p');const budget=c.budget||{};
4085
+ const phase=({closed:'已释放',releasable:'可释放余额',previous_key:'此前 Key 的预算',different_owner:'属于其他钱包',admission_closed:'已停止新请求,等待结算',admission_window_short:'剩余接单时间不足',claim_window_short:'剩余结算时间不足',request_limit:'单次请求上限超过预算限制',capacity_exhausted:'请求额度已用完',credit_insufficient:'预算余额不足',stake_insufficient:'Provider 线路暂不可用',not_started:'等待启用,约 '+Math.max(0,Math.ceil((c.valid_from-now)/60))+' 分钟',available:'预算可用'})[budget.reason]||'预算待核验';
4086
+ info.textContent=phase+' · 当前可用 '+units(budget.remaining_units||0,decimals)+' '+symbol+(budget.ready?' · 预计还可请求 '+budget.estimated_requests+' 次':'')+' · 最早释放 '+new Date(c.claim_until*1000).toLocaleString();box.appendChild(info);
4087
+ if(!c.closed&&now>c.claim_until){const button=document.createElement('button');button.className='button';button.textContent='释放剩余预算';button.onclick=()=>run(async()=>{const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'close_capacity_channel',wallet,channel_id:c.channel_id})});await sendPlan(plan);await load();toast('预算释放交易已确认')});box.appendChild(button)}
4088
+ list.appendChild(box)
4089
+ }
4090
+ }
4091
+ function renderHistory(){
4092
+ const body=$('history'),items=state.history||[],decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';
4093
+ body.replaceChildren();$('historyEmpty').hidden=items.length>0;$('historyTable').hidden=items.length===0;
4094
+ $('spent').previousElementSibling.textContent='已结算消费';$('spent').textContent=units(state.usage.settled_units||0,decimals)+' '+symbol;
4095
+ const sync=state.history_sync||{},recoverySummary=state.recovery||{},summary='已结算 '+units(state.usage.settled_units||0,decimals)+' · 已知待结算 '+units(state.usage.pending_units||0,decimals)+' · 结算失败 '+units(state.usage.failed_units||0,decimals)+' '+symbol+(state.usage.unknown_fee_count?' · 费用待核实 '+state.usage.unknown_fee_count+' 笔,授权上限 '+units(state.usage.unknown_fee_authorized_maximum_units,decimals)+' '+symbol:'')+(recoverySummary.uncertain_count?' · 待核验 '+recoverySummary.uncertain_count+' 笔':'');
4096
+ $('historySync').hidden=false;$('historySync').textContent=summary+(sync.error?' — '+sync.error:(sync.running?' — 正在同步结算状态…':''));
4097
+ const unknown=items.filter(item=>['dispatching','outcome_unknown','broadcast_unknown'].includes(item.status)||['broadcast_unknown','confirmation_timeout'].includes(item.error_code));
4098
+ const recovery=$('historyRecovery');recovery.hidden=unknown.length===0&&!sync.error;
4099
+ if(unknown.length){const ids=unknown.slice(0,3).map(item=>short(item.request_id)).join('、');$('historyRecoveryText').textContent='有 '+unknown.length+' 笔请求的执行或付款状态尚未核实('+ids+(unknown.length>3?' 等':'')+')。请勿重复提交相同请求;先核验状态,系统只会在链上或 Relay 返回可验证结果后更新账单。'}
4100
+ else if(sync.error)$('historyRecoveryText').textContent='账单状态暂时无法核验,原记录已保留。请重试“立即核验”;在状态明确前不要重复提交相同请求。';
4101
+ for(const item of items){
4102
+ const row=document.createElement('tr'),status=({dispatching:'已发送,结果待核实',outcome_unknown:'结果待核实,请勿重复提交',not_dispatched:'未执行',escrowed:'已托管,争议期内',disputed:'裁决中',released:'已释放',refunded:'已退回余额',dismissed:'举报驳回,已释放',timed_out:'裁决超时,已释放'})[item.status]||(item.status==='confirmed'?'已结算':item.status==='failed'?(item.error_code==='authorization_expired'?'授权过期,未结算':'结算失败'):item.status==='rejected'?'未接受':(item.status==='broadcast_unknown'||item.error_code==='broadcast_unknown'||item.error_code==='confirmation_timeout')?'结果待核实':item.status==='submitted'?'已提交,待确认':'待结算');
4103
+ for(const value of [new Date(item.timestamp*1000).toLocaleString(),item.model||'--',(item.input_tokens??'--')+' / '+(item.output_tokens??'--'),item.actual_fee_units==null?'--':units(item.actual_fee_units,decimals),short(item.provider_signer||item.provider),(item.content_verification==='failed'?'正文核验失败;':'')+status,short(item.session_id)]){
4104
+ const cell=document.createElement('td');cell.textContent=value;row.appendChild(cell)
4105
+ }
4106
+ const dispute=document.createElement('td');
4107
+ if(item.jury_evidence_hash){
4108
+ const evidence=document.createElement('div');evidence.className='mono';evidence.textContent=short(item.jury_evidence_hash);evidence.title=item.jury_evidence_hash;dispute.appendChild(evidence);
4109
+ const bond=item.dispute_bond_units||state.settlement?.reporter_bond_units;
4110
+ const deadline=item.settlement_release_at?new Date(item.settlement_release_at*1000).toLocaleString():'待链上核验';
4111
+ const detail=document.createElement('div');detail.textContent=(bond?'保证金 '+units(bond,decimals)+' '+symbol+' · ':'')+'截止 '+deadline;dispute.appendChild(detail);
4112
+ if(item.dispute_explorer_url){const link=document.createElement('a');link.href=item.dispute_explorer_url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='查看交易';dispute.appendChild(link)}
4113
+ if(item.dispute_eligible){const button=document.createElement('button');button.className='button small danger';button.type='button';button.textContent=item.dispute_stage==='failed'?'重试申诉':'发起申诉';button.onclick=()=>run(()=>openDispute(item));dispute.appendChild(button)}
4114
+ else if(item.dispute_stage){const phase=({planned:'已生成计划',wallet_prompted:'等待钱包结果',submitted:'已提交',uncertain:'结果待核实,请勿重试',failed:'未提交或失败',confirmed:'链上已进入裁决'})[item.dispute_stage]||item.dispute_stage;const stage=document.createElement('div');stage.textContent=phase;dispute.appendChild(stage)}
4115
+ }else dispute.textContent='--';
4116
+ row.appendChild(dispute);
4117
+ body.appendChild(row)
4118
+ }
4119
+ }
1623
4120
  function renderShare(){const share=state.share||{};$('shareStatus').className='status'+(share.active?' ok':'');$('shareStatus').textContent=share.active?'分享中':'未启用';$('shareOutput').hidden=!share.active;if(share.active){$('shareUrl').textContent=share.base_url;$('shareKey').textContent=share.api_key;$('shareExpiry').textContent='到期时间 '+new Date(share.expires_at*1000).toLocaleString()}}
4121
+ function renderModels(items){const list=$('modelList'),status=$('modelStatus'),error=$('modelError');list.replaceChildren();error.hidden=true;if(!Array.isArray(items)||!items.length){status.className='status';status.textContent='暂无可用模型';list.innerHTML='<div><dt>模型目录</dt><dd>暂无通过校验的 Relay 路由</dd></div>';return}const single=items.filter(item=>Number(item.route_count||0)<2);status.className='status'+(single.length?'':' ok');status.textContent=single.length?(items.length+' 个模型 · '+single.length+' 个单 Relay'):(items.length+' 个模型 · 已冗余');for(const item of items){const row=document.createElement('div'),name=document.createElement('dt'),value=document.createElement('dd');name.textContent=String(item.id||'未知模型');value.textContent=Number(item.route_count||0)>1?('双 Relay · '+item.route_count+' 条路由'):'单 Relay · 无自动故障切换';row.append(name,value);list.appendChild(row)}}
4122
+ async function loadModels(){try{const response=await fetch('/v1/models',{cache:'no-store'}),data=await response.json();if(!response.ok)throw new Error(data?.error?.message||'模型目录暂时不可用');renderModels(data.data)}catch(error){$('modelStatus').className='status';$('modelStatus').textContent='读取失败';$('modelError').hidden=false;$('modelError').textContent=error?.message||'模型目录暂时不可用'}}
4123
+ async function syncHistoryNow(){await api('/v1/mycomesh/local/history/sync',{method:'POST'});await load();const sync=state.history_sync||{};toast(sync.error?'暂时无法完成状态核验,原记录已保留':'已完成状态核验')}
1624
4124
  function walletMessage(value){return '0x'+Array.from(new TextEncoder().encode(value),byte=>byte.toString(16).padStart(2,'0')).join('')}
1625
- async function login(){if(!window.ethereum)throw new Error('未检测到浏览器钱包');const accounts=await window.ethereum.request({method:'eth_requestAccounts'});wallet=String(accounts[0]||'').toLowerCase();if(!wallet)throw new Error('钱包未连接');const challenge=await api('/v1/mycomesh/local/wallet/challenge',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});const signature=await window.ethereum.request({method:'personal_sign',params:[walletMessage(challenge.message),wallet]});const result=await api('/v1/mycomesh/local/wallet/authenticate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet,signature})});managementToken=result.token;await load();toast(state.auth.key_ready?'钱包验证完成':'钱包验证完成,请激活 Key')}
1626
- async function ensureChain(){const expected='0x'+Number(state.settlement.chain_id).toString(16),current=await window.ethereum.request({method:'eth_chainId'});if(current.toLowerCase()!==expected.toLowerCase())await window.ethereum.request({method:'wallet_switchEthereumChain',params:[{chainId:expected}]})}
1627
- async function waitReceipt(hash){for(let count=0;count<120;count++){const receipt=await window.ethereum.request({method:'eth_getTransactionReceipt',params:[hash]});if(receipt){if(receipt.status!=='0x1')throw new Error('链上交易失败');return receipt}await new Promise(resolve=>setTimeout(resolve,1500))}throw new Error('等待链上确认超时')}
1628
- async function sendPlan(plan){await ensureChain();for(const transaction of plan.transactions){toast(transaction.label);const hash=await window.ethereum.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]});await waitReceipt(hash)}}
4125
+ function walletProvider(){return window.okxwallet?.ethereum||window.okxwallet||window.ethereum?.providers?.find(p=>p.isOkxWallet||p.isOKExWallet)||window.ethereum}
4126
+ function ownerMismatchMessage(value){return '当前选择的钱包:'+value.selected_wallet+'。本机付款 Key('+value.payment_key_address+')已绑定钱包:'+value.expected_owner+'。请在钱包扩展中切换到该归属钱包后重试;若要使用当前钱包,请另建独立 Consumer 配置目录。不要删除或更换现有 Key,原账单和预算仍属于原钱包。'}
4127
+ function renderLoginOwner(){const owner=state?.key?.grant?.owner,hint=$('loginOwnerHint');hint.textContent=!owner?'暂时无法核验本机付款 Key 的归属,请稍后重试连接。':owner.toLowerCase()==='0x'+'0'.repeat(40)?'本机付款 Key 尚未绑定钱包,可连接你的钱包完成登录,再激活访问。':'本机付款 Key 已绑定钱包:'+owner+'。请在钱包扩展中选择该账户。若要使用其他钱包,请另建独立 Consumer 配置目录,保留原 Key 和账单。'}
4128
+ async function login(){
4129
+ $('loginError').hidden=true;
4130
+ const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');
4131
+ await load();
4132
+ const owner=state?.key?.grant?.owner?.toLowerCase();
4133
+ if(!owner)throw new Error('暂时无法核验本机付款 Key 的链上归属,请稍后重试;本次尚未请求签名。');
4134
+ const accounts=await provider.request({method:'eth_requestAccounts'}),selectedWallet=String(accounts[0]||'').toLowerCase();
4135
+ if(!selectedWallet)throw new Error('钱包未连接');
4136
+ if(owner!=='0x'+'0'.repeat(40)&&owner!==selectedWallet)throw Object.assign(new Error(ownerMismatchMessage({selected_wallet:selectedWallet,expected_owner:owner,payment_key_address:state.key.address})),{code:'payment_key_owner_mismatch'});
4137
+ const challenge=await api('/v1/mycomesh/local/wallet/challenge',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet:selectedWallet})});
4138
+ const signature=await provider.request({method:'personal_sign',params:[walletMessage(challenge.message),selectedWallet]});
4139
+ const result=await api('/v1/mycomesh/local/wallet/authenticate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet:selectedWallet,signature})});
4140
+ managementToken=result.token;wallet=selectedWallet;await load();toast(state.auth.key_ready?'钱包验证完成':'首次使用:点击“启用 API 访问”,并在钱包中确认');
4141
+ }
4142
+ async function ensureChain(){const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');const expected='0x'+Number(state.settlement.chain_id).toString(16),current=await provider.request({method:'eth_chainId'});if(current.toLowerCase()!==expected.toLowerCase())await provider.request({method:'wallet_switchEthereumChain',params:[{chainId:expected}]})}
4143
+ async function waitReceipt(hash){const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');for(let count=0;count<120;count++){const receipt=await provider.request({method:'eth_getTransactionReceipt',params:[hash]});if(receipt){if(receipt.status!=='0x1')throw Object.assign(new Error('链上交易失败'),{code:'transaction_failed'});return receipt}await new Promise(resolve=>setTimeout(resolve,1500))}throw Object.assign(new Error('等待链上确认超时'),{code:'confirmation_timeout'})}
4144
+ async function sendPlan(plan){if(Array.isArray(plan.transactions)&&plan.transactions.length===0)return;const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包,请安装钱包扩展后刷新,或在已安装钱包的浏览器中打开此本机地址');await ensureChain();for(const transaction of plan.transactions){toast(transaction.label+':请在钱包中确认');const hash=await provider.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]});toast(transaction.label+':交易已提交,等待链上确认…');await waitReceipt(hash);toast(transaction.label+':已确认')}}
4145
+ async function disputeProgress(item,stage,kind,hash,errorCode){return api('/v1/mycomesh/local/disputes/progress',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet,request_id:item.request_id,channel_id:item.capacity_channel_id,stage,transaction_kind:kind,...(hash?{tx_hash:hash}:{}),...(errorCode?{error_code:errorCode}:{})})})}
4146
+ async function openDispute(item){
4147
+ const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'open_dispute',wallet,request_id:item.request_id,channel_id:item.capacity_channel_id})});
4148
+ const provider=walletProvider();if(!provider)throw new Error('未检测到浏览器钱包');await ensureChain();
4149
+ for(const transaction of plan.transactions){
4150
+ const kind=transaction.kind;await disputeProgress(item,'wallet_prompted',kind);let hash;
4151
+ try{hash=await provider.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]})}
4152
+ catch(error){await disputeProgress(item,error?.code===4001?'failed':'uncertain',kind,null,error?.code===4001?'wallet_rejected':'wallet_unknown');throw error}
4153
+ await disputeProgress(item,'submitted',kind,hash);toast(transaction.label+':交易已提交');
4154
+ try{await waitReceipt(hash)}catch(error){await disputeProgress(item,error?.code==='transaction_failed'?'failed':'uncertain',kind,hash,error?.code||'confirmation_timeout');throw error}
4155
+ }
4156
+ await api('/v1/mycomesh/local/history/sync',{method:'POST'});await load();toast('申诉交易已确认并进入链上裁决');
4157
+ }
1629
4158
  async function activateCurrent(){const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(plan);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/wallet/activate',{method:'POST'});await load();toast('Key 已激活');return}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}}
1630
- async function rotateKey(){const oldAddress=state.key.address;await api('/v1/mycomesh/local/key/prepare',{method:'POST'});const register=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(register);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});break}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}const revoke=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'revoke_key',wallet,key_address:oldAddress})});await sendPlan(revoke);await load();toast('新 Key 已启用,旧 Key 已撤销')}
1631
- document.querySelectorAll('.tab').forEach(tab=>tab.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(item=>item.classList.toggle('active',item===tab));document.querySelectorAll('.view').forEach(view=>view.hidden=view.id!=='view-'+tab.dataset.view)}));
4159
+ async function rotateKey(){const oldAddress=state.key.address;await api('/v1/mycomesh/local/key/prepare',{method:'POST'});const register=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(register);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});break}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}const revoke=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'revoke_key',wallet,key_address:oldAddress})});await sendPlan(revoke);await load();toast(state.protocol_version===10?'新 Key 已启用;旧通道预算到期后才能释放,新 Key 需另开预算':'新 Key 已启用,旧 Key 已撤销')}
4160
+ document.querySelectorAll('.tab').forEach(tab=>tab.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(item=>item.classList.toggle('active',item===tab));document.querySelectorAll('.view').forEach(view=>view.hidden=view.id!=='view-'+tab.dataset.view);if(tab.dataset.view==='activity'&&managementToken&&!busy)load().catch(error=>toast(error.message,true))}));
4161
+ setInterval(()=>{if(managementToken&&!busy&&!document.hidden)load().catch(()=>{})},15000);
4162
+ document.addEventListener('visibilitychange',()=>{if(!document.hidden&&managementToken&&!busy)load().catch(()=>{})});
1632
4163
  document.addEventListener('click',event=>{const button=event.target.closest('.copy');if(button)navigator.clipboard.writeText($(button.dataset.copy).textContent).then(()=>toast('已复制')).catch(()=>toast('复制失败',true))});
1633
- $('login').onclick=()=>run(login);$('activate').onclick=()=>run(activateCurrent);$('rotate').onclick=()=>run(rotateKey);$('refresh').onclick=()=>run(load);
1634
- $('topup').onclick=()=>run(async()=>{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').value.trim()})});await sendPlan(plan);$('amount').value='';await load();toast('充值已确认')});
4164
+ $('setupAccess').onclick=()=>run(activateCurrent);$('login').onclick=()=>run(login);$('activate').onclick=()=>run(activateCurrent);$('rotate').onclick=()=>run(rotateKey);$('refresh').onclick=()=>run(load);$('historyRecoveryButton').onclick=()=>run(syncHistoryNow);
4165
+ function readTopupAmount(){const value=$('amount').value.trim();if(!/^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(value)||Number(value)<=0)throw new Error('请输入大于 0 且最多 6 位小数的 USDC 金额');return value}
4166
+ $('topup').onclick=()=>run(async()=>{const amount=readTopupAmount();const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'top_up',wallet,amount_usdc:amount})});await sendPlan(plan);$('amount').value='';await load();toast('充值已确认')});
1635
4167
  $('shareStart').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({minutes:Number($('shareMinutes').value)})});state.share=value.share;renderShare();toast('临时分享已开启')});
1636
4168
  $('shareStop').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/stop',{method:'POST'});state.share=value.share;renderShare();toast('临时分享已停止')});
1637
4169
  $('walletButton').onclick=()=>run(async()=>{await api('/v1/mycomesh/local/wallet/lock',{method:'POST'});managementToken=null;wallet=null;await load()});
1638
- window.ethereum?.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))})});
4170
+ walletProvider()?.on?.('accountsChanged',()=>{if(managementToken)api('/v1/mycomesh/local/wallet/lock',{method:'POST'}).catch(()=>{}).finally(()=>{managementToken=null;wallet=null;load().catch(error=>toast(error.message,true))})});
1639
4171
  load().catch(error=>{$('loginError').hidden=false;$('loginError').textContent=error.message});
4172
+ loadModels();
1640
4173
  </script></body></html>`;
1641
4174
  }
1642
4175