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