mycomesh-consumer 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  "stablecoin": "0xbce27efad4191277167fe76819581a97959048cf",
7
7
  "registry": "0x0ff3210e8e8abf5f5bfa73a39e60db7416b6c8c7",
8
8
  "relay_directory": "0xa0aaad9393f51cb5786c69f152133a174588bdab",
9
- "probe_ledger": "0x30055eae6645cce8754f834a17689d20a3795ca3",
9
+ "probe_ledger": "0x1f26a2285eb366d0fd201bea6969abd1aaacb62b",
10
10
  "emission": "0x85ee0e8f63400041f0c76010c590c330bc135a8d",
11
11
  "token": "0x620d026ad854fb17c7b16534a0f28ef5499e2d4a",
12
12
  "emission_block": 11815544,
@@ -15,7 +15,8 @@
15
15
  "name": "OpenAI frontier",
16
16
  "models": [
17
17
  "gpt-5.5"
18
- ]
18
+ ],
19
+ "capability_floor": 0.85
19
20
  },
20
21
  "2": {
21
22
  "name": "Anthropic Claude",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/chain.mjs CHANGED
@@ -30,14 +30,27 @@ export async function availableBalance(network, owner) {
30
30
  return ethCallWord(network.rpc_urls, network.settlement, encodeCall("availableBalance(address)", [["address", owner]]));
31
31
  }
32
32
 
33
+ /**
34
+ * Twice the latest base fee plus the suggested tip, as EIP-1559 wallets cap a fee. Some nodes (anvil)
35
+ * derive eth_gasPrice from recent transactions, so pricing above it compounds block after block.
36
+ */
37
+ export async function suggestedGasPrice(urls) {
38
+ try {
39
+ const [block, tip] = await Promise.all([rpcCall(urls, "eth_getBlockByNumber", ["latest", false]),
40
+ rpcCall(urls, "eth_maxPriorityFeePerGas", [])]);
41
+ if (block?.baseFeePerGas) return quantity(block.baseFeePerGas) * 2n + quantity(tip);
42
+ } catch {}
43
+ return quantity(await rpcCall(urls, "eth_gasPrice", [])) * 12n / 10n;
44
+ }
45
+
33
46
  export async function sendTransaction(urls, privateKey, { to, data, gasLimit }) {
34
47
  const from = addressOf(privateKey);
35
48
  const [chainId, nonce, gasPrice] = await Promise.all([
36
- rpcCall(urls, "eth_chainId", []), rpcCall(urls, "eth_getTransactionCount", [from, "pending"]), rpcCall(urls, "eth_gasPrice", []),
49
+ rpcCall(urls, "eth_chainId", []), rpcCall(urls, "eth_getTransactionCount", [from, "pending"]), suggestedGasPrice(urls),
37
50
  ]);
38
51
  const limit = gasLimit ?? (quantity(await rpcCall(urls, "eth_estimateGas", [{ from, to, data }])) * 12n / 10n + 10000n);
39
52
  const raw = signLegacyTransaction(privateKey, {
40
- nonce: quantity(nonce), gasPrice: quantity(gasPrice) * 12n / 10n, gasLimit: limit, to, data, chainId: quantity(chainId),
53
+ nonce: quantity(nonce), gasPrice, gasLimit: limit, to, data, chainId: quantity(chainId),
41
54
  });
42
55
  const hash = await rpcCall(urls, "eth_sendRawTransaction", [raw]);
43
56
  for (let i = 0; i < 180; i += 1) {
package/src/probes.mjs CHANGED
@@ -4,9 +4,139 @@ import { verifySignedReceipt } from "./eip712.mjs";
4
4
 
5
5
  export const PROBE_EVIDENCE_SCHEMA = "mycomesh.v11.probe-evidence.v1";
6
6
  const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
7
- const numbers = (text) => (text.match(/\d{1,3}(?:[,\s_]\d{3})+(?!\d)|\d+/g) || []).map((match) => match.replace(/[,\s_]/g, ""));
7
+ const numbers = (text) => (text.match(/[0-9]{1,3}(?:[,\s_][0-9]{3})+(?![0-9])|[0-9]+/g) || []).map((match) => match.replace(/[,\s_]/g, ""));
8
+
9
+ // ---------------- capability probes (mirrors mycomesh/capability.py) ----------------
10
+
11
+ export const CAPABILITY_KINDS = ["trace", "crt", "long_multiply", "path", "kth", "letters", "calendar"];
12
+ /** Default per-tier pass-rate floors; a manifest tier's capability_floor overrides them. */
13
+ export const CAPABILITY_FLOORS = { 1: 0.85 }; // tier 2 (Claude) has none until calibrated
14
+
15
+ export function ordinal(n) {
16
+ const suffix = n % 100 >= 10 && n % 100 <= 20 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" })[n % 10] || "th";
17
+ return `${n}${suffix}`;
18
+ }
19
+
20
+ const LINE_BREAKS = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/;
21
+ const weekdays = (text) => WEEKDAYS.filter((day) => text.toLowerCase().includes(day.toLowerCase()));
22
+
23
+ function weekdayAfter(start, offset) {
24
+ const day = new Date(`${start}T00:00:00Z`);
25
+ day.setUTCDate(day.getUTCDate() + Number(offset));
26
+ return WEEKDAYS[(day.getUTCDay() + 6) % 7];
27
+ }
28
+
29
+ function capabilityTask(kind, p) {
30
+ if (kind === "trace") {
31
+ let [x, y] = [Number(p.x), Number(p.y)];
32
+ const [a, m, n] = [Number(p.a), Number(p.m), Number(p.n)];
33
+ const question = `Start with x = ${x} and y = ${y}. Repeat the following ${n} times: first set x to (x * ${a} + y) mod ${m}, `
34
+ + `then set y to (y + 2 * x) mod ${m}. What is x + y at the end?`;
35
+ for (let i = 0; i < n; i += 1) { x = (x * a + y) % m; y = (y + 2 * x) % m; }
36
+ return { question, reference: String(x + y) };
37
+ }
38
+ if (kind === "crt") {
39
+ const moduli = p.moduli.map(Number); const residues = p.residues.map(Number);
40
+ const parts = moduli.map((m, i) => `remainder ${residues[i]} when divided by ${m}`);
41
+ const question = `What is the smallest positive integer that leaves ${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}?`;
42
+ // Constructive CRT (the moduli are distinct primes): the least positive solution.
43
+ const big = moduli.map(BigInt);
44
+ const product = big.reduce((a, b) => a * b, 1n);
45
+ const power = (base, exponent, m) => { let r = 1n; base %= m; for (; exponent > 0n; exponent >>= 1n, base = base * base % m) if (exponent & 1n) r = r * base % m; return r; };
46
+ const x = big.reduce((sum, m, i) => sum + BigInt(residues[i]) * (product / m) * power(product / m, m - 2n, m), 0n) % product;
47
+ return { question, reference: String(x || product) };
48
+ }
49
+ if (kind === "long_multiply") return { question: `What is ${p.a} times ${p.b}?`, reference: String(BigInt(p.a) * BigInt(p.b)) };
50
+ if (kind === "path") {
51
+ const edges = p.edges.map(([u, v, w]) => [String(u), String(v), Number(w)]);
52
+ const question = `In a road network the two-way roads and their lengths are: ${edges.map(([u, v, w]) => `${u}-${v} ${w}`).join(", ")}. `
53
+ + `What is the length of the shortest route from ${p.start} to ${p.end}?`;
54
+ const distance = new Map([[String(p.start), 0]]); const done = new Set();
55
+ for (;;) {
56
+ const open = [...distance].filter(([node]) => !done.has(node)).sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : 1));
57
+ if (!open.length) break;
58
+ const [node, d] = open[0];
59
+ done.add(node);
60
+ for (const [u, v, w] of edges) {
61
+ for (const [here, there] of [[u, v], [v, u]]) {
62
+ if (here === node && d + w < (distance.get(there) ?? Infinity)) distance.set(there, d + w);
63
+ }
64
+ }
65
+ }
66
+ return { question, reference: String(distance.get(String(p.end))) };
67
+ }
68
+ if (kind === "kth") {
69
+ const values = p.values.map(Number); const k = Number(p.k);
70
+ return { question: `What is the ${ordinal(k)} smallest number in this list: ${values.join(", ")}?`,
71
+ reference: String([...values].sort((a, b) => a - b)[k - 1]) };
72
+ }
73
+ if (kind === "letters") {
74
+ return { question: `How many times does the letter "${p.letter}" appear in the following text: "${p.text}"?`,
75
+ reference: String(String(p.text).split(String(p.letter)).length - 1) };
76
+ }
77
+ if (kind === "calendar") {
78
+ return { question: `What day of the week will it be ${p.offset} days after ${p.start}?`, reference: weekdayAfter(p.start, p.offset) };
79
+ }
80
+ throw new Error(`unknown capability kind ${kind}`);
81
+ }
82
+
83
+ // Hunters' own questions: {question, reference, grader} graded "number", "choice" or "text".
84
+ function customTask(p) {
85
+ const question = String(p.question); const reference = String(p.reference).trim(); const grader = String(p.grader);
86
+ if (!["number", "choice", "text"].includes(grader) || !question || !reference || question.length > 20_000 || reference.length > 200) {
87
+ throw new Error("invalid custom task");
88
+ }
89
+ if ((grader === "number" && !/^[0-9]+$/.test(reference)) || (grader === "choice" && !/^[A-J]$/.test(reference))) {
90
+ throw new Error("invalid custom task reference");
91
+ }
92
+ return { question, reference, grader };
93
+ }
94
+
95
+ const normal = (text) => text.toLowerCase().split(/\s+/).filter(Boolean).join(" ");
96
+
97
+ function gradeCustom(task, answer) {
98
+ const text = String(answer);
99
+ if (task.grader === "number") return gradeCapability({ ...task, kind: "custom-number" }, text);
100
+ if (task.grader === "choice") {
101
+ const pick = (part) => [...part.matchAll(/\b([A-J])\b/g)].map((m) => m[1]);
102
+ const boxed = [...text.matchAll(/\\boxed\{([^{}]*)\}/g)];
103
+ let stated = [];
104
+ if (boxed.length) stated = pick(boxed[boxed.length - 1][1]);
105
+ else for (const line of text.split(LINE_BREAKS).reverse()) { const found = pick(line); if (found.length) { stated = found; break; } }
106
+ return stated.length === 1 && stated[0] === task.reference ? "pass" : "wrong";
107
+ }
108
+ const lines = text.split(LINE_BREAKS).filter((line) => line.trim());
109
+ const final = lines.length ? normal(lines[lines.length - 1]) : "";
110
+ return final.includes(normal(task.reference)) || (text.length <= 200 && normal(text).includes(normal(task.reference))) ? "pass" : "wrong";
111
+ }
112
+
113
+ function gradeCapability(task, answer) {
114
+ // The answer finally stated decides: the last \boxed{...}, else the last line that states a value.
115
+ // A handful of values at most: listing candidates is not answering.
116
+ const text = String(answer);
117
+ const pick = task.kind === "calendar" ? weekdays : numbers;
118
+ const boxed = [...text.matchAll(/\\boxed\{([^{}]*)\}/g)];
119
+ let stated = [];
120
+ if (boxed.length) stated = pick(boxed[boxed.length - 1][1]);
121
+ else for (const line of text.split(LINE_BREAKS).reverse()) { const found = pick(line); if (found.length) { stated = found; break; } }
122
+ return stated.length <= (task.kind === "calendar" ? 1 : 3) && stated.includes(task.reference) ? "pass" : "wrong";
123
+ }
124
+
125
+ /** Upper bound of a pass rate at 99% one-sided confidence (Wilson). */
126
+ export function wilsonUpper(passes, total, z = 2.326) {
127
+ if (!total) return 1;
128
+ const p = passes / total;
129
+ const centre = p + (z * z) / (2 * total);
130
+ const spread = z * Math.sqrt((p * (1 - p)) / total + (z * z) / (4 * total * total));
131
+ return Math.min(1, (centre + spread) / (1 + (z * z) / total));
132
+ }
133
+
134
+ /** 99% confident the pass rate is below the floor, after enough probes. */
135
+ export const capabilityFlagged = (passes, total, floor, minimum = 20) => total >= minimum && wilsonUpper(passes, total) < floor;
8
136
 
9
137
  export function buildTask(kind, params) {
138
+ if (CAPABILITY_KINDS.includes(kind)) return { kind, capability: true, ...capabilityTask(kind, params) };
139
+ if (kind === "custom") return { kind, capability: true, ...customTask(params) };
10
140
  if (kind === "multiply") return { kind, question: `What is ${params.a} multiplied by ${params.b}?`, reference: String(BigInt(params.a) * BigInt(params.b)), numeric: true };
11
141
  if (kind === "sum") {
12
142
  return { kind, question: `What is the sum of ${params.values.join(", ")}?`, reference: String(params.values.reduce((a, b) => a + Number(b), 0)), numeric: true };
@@ -26,6 +156,8 @@ export function buildTask(kind, params) {
26
156
  }
27
157
 
28
158
  export function grade(task, answer) {
159
+ if (task.kind === "custom") return gradeCustom(task, answer);
160
+ if (task.capability) return gradeCapability(task, answer);
29
161
  const text = String(answer).trim();
30
162
  if (!text) return "unrelated";
31
163
  if (task.numeric) {
@@ -66,5 +198,5 @@ export function verifyProbeEvidence(evidence, deployment) {
66
198
  }
67
199
  const verdict = grade(task, outputText(JSON.parse(response.toString("utf8")).output));
68
200
  if (verdict !== evidence.verdict || verdict === "unrelated") throw new Error(`re-grading gives ${verdict}`);
69
- return { provider: signed.authorization.provider_signer, verdict };
201
+ return { provider: signed.authorization.provider_signer, verdict, capability: Boolean(task.capability) };
70
202
  }
@@ -2,14 +2,18 @@
2
2
  import { encodeCall, keccak, hex } from "./eip712.mjs";
3
3
  import { rpcCall } from "./chain.mjs";
4
4
  import { evidenceHash } from "./disputes.mjs";
5
- import { verifyProbeEvidence } from "./probes.mjs";
5
+ import { CAPABILITY_FLOORS, capabilityFlagged, verifyProbeEvidence } from "./probes.mjs";
6
6
 
7
+ // Recorded by the hunter that voided the probe: a Relay or any third party (open probing).
7
8
  const PROBE_RECORDED = hex(keccak(Buffer.from("ProbeRecorded(address,address,bytes32,bytes32,uint8)")));
8
9
  const LOOKBACK_BLOCKS = 50_400n; // about 7 days of 12-second blocks
9
10
  const LOG_CHUNK = 10_000n;
10
11
  const FRAUD_COOLDOWN = 30 * 86_400;
11
12
  const MAX_VERIFIED = 20; // evidence downloads per refresh
12
13
 
14
+ // Ledger codes: basic probes 1/2, capability probes (is the advertised model really served?) 3/4.
15
+ const VERDICT_CODES = { 1: { verdict: "pass", capability: false }, 2: { verdict: "wrong", capability: false },
16
+ 3: { verdict: "pass", capability: true }, 4: { verdict: "wrong", capability: true } };
13
17
  const word = (raw, index) => BigInt(`0x${raw.slice(2 + index * 64, 2 + (index + 1) * 64)}`);
14
18
  const address = (topic) => `0x${topic.slice(26)}`.toLowerCase();
15
19
 
@@ -30,8 +34,8 @@ async function ledgerEvents(network) {
30
34
  const to = from + LOG_CHUNK - 1n < head ? from + LOG_CHUNK - 1n : head;
31
35
  for (const log of await rpcCall(network.rpc_urls, "eth_getLogs", [{ address: network.probe_ledger, topics: [PROBE_RECORDED],
32
36
  fromBlock: `0x${from.toString(16)}`, toBlock: `0x${to.toString(16)}` }])) {
33
- events.push({ provider: address(log.topics[1]), relay: address(log.topics[2]), key: log.topics[3],
34
- evidence: `0x${log.data.slice(2, 66)}`, verdict: Number(BigInt(`0x${log.data.slice(66, 130)}`)) === 1 ? "pass" : "wrong" });
37
+ events.push({ provider: address(log.topics[1]), hunter: address(log.topics[2]), key: log.topics[3],
38
+ evidence: `0x${log.data.slice(2, 66)}`, ...VERDICT_CODES[Number(BigInt(`0x${log.data.slice(66, 130)}`))] });
35
39
  }
36
40
  }
37
41
  return events;
@@ -50,34 +54,55 @@ export async function reputation(consumer, descriptors, relaysByOwner) {
50
54
  let events = [];
51
55
  try { events = await ledgerEvents(network); } catch {}
52
56
  const byOwner = {};
53
- for (const record of Object.values(records)) byOwner[record.owner] = { ...record, probes_passed: 0, probes_failed_verified: 0 };
57
+ for (const record of Object.values(records)) {
58
+ byOwner[record.owner] = { ...record, probes_passed: 0, probes_failed_verified: 0, capability_passed: 0, capability_failed_verified: 0 };
59
+ }
54
60
  let verified = 0;
55
61
  for (const event of events) {
56
62
  const entry = byOwner[event.provider];
57
- if (!entry) continue;
58
- if (event.verdict === "pass") { entry.probes_passed += 1; continue; }
59
- const relay = relaysByOwner[event.relay];
60
- if (!relay || verified >= MAX_VERIFIED) continue;
63
+ if (!entry || !event.verdict) continue;
64
+ if (event.verdict === "pass") { entry[event.capability ? "capability_passed" : "probes_passed"] += 1; continue; }
65
+ if (verified >= MAX_VERIFIED) continue;
61
66
  verified += 1;
62
- try {
63
- const { status, body } = await consumer.fetchJson(`${relay.url}/v11/evidence/${event.evidence}`,
64
- { ca: network.tls_ca, pin: relay.pin, timeoutMs: 8_000 });
65
- if (status === 200 && evidenceHash(body) === event.evidence && verifyProbeEvidence(body, consumer.deployment).verdict === "wrong") {
66
- entry.probes_failed_verified += 1;
67
- }
68
- } catch {}
67
+ // Hunters publish their evidence to every Relay's desk; a hunter that is a Relay serves its own first.
68
+ const relays = [relaysByOwner[event.hunter], ...Object.values(relaysByOwner)].filter(Boolean);
69
+ for (const relay of [...new Set(relays)].slice(0, 3)) {
70
+ try {
71
+ const { status, body } = await consumer.fetchJson(`${relay.url}/v11/evidence/${event.evidence}`,
72
+ { ca: network.tls_ca, pin: relay.pin, timeoutMs: 8_000 });
73
+ if (status !== 200 || evidenceHash(body) !== event.evidence) continue;
74
+ const checked = verifyProbeEvidence(body, consumer.deployment);
75
+ if (checked.verdict === "wrong" && checked.capability === event.capability) {
76
+ entry[event.capability ? "capability_failed_verified" : "probes_failed_verified"] += 1;
77
+ }
78
+ break;
79
+ } catch {}
80
+ }
69
81
  }
70
- return Object.fromEntries(Object.entries(records).map(([signer, record]) => [signer, byOwner[record.owner]]));
82
+ return Object.fromEntries(descriptors.filter((d) => records[d.provider_signer]).map((descriptor) => {
83
+ const entry = { ...byOwner[records[descriptor.provider_signer].owner] };
84
+ const tier = Number(descriptor.tier || 0);
85
+ const floor = network.tiers?.[tier]?.capability_floor ?? CAPABILITY_FLOORS[tier];
86
+ const graded = entry.capability_passed + entry.capability_failed_verified;
87
+ // Passes are counted as reported; failures only once re-graded here, so no Relay can frame a Provider.
88
+ entry.downgrade_suspected = floor !== undefined && capabilityFlagged(entry.capability_passed, graded, floor);
89
+ entry.capability_floor = floor ?? null;
90
+ return [descriptor.provider_signer, entry];
91
+ }));
71
92
  }
72
93
 
73
- /** Lower is better: recent confirmed fraud last, then verified probe failure rate, then reputation, then price. */
94
+ /**
95
+ * Lower is better: recent confirmed fraud last, then a suspected model downgrade, then the verified probe
96
+ * failure rate, then reputation, then price.
97
+ */
74
98
  export function rankKey(descriptor, record, now = Math.floor(Date.now() / 1000)) {
75
99
  const fraud = record && record.last_fraud_at && now < record.last_fraud_at + FRAUD_COOLDOWN ? 1 : 0;
100
+ const downgrade = record?.downgrade_suspected ? 1 : 0;
76
101
  const probes = record ? record.probes_passed + record.probes_failed_verified : 0;
77
102
  const failure = record && probes ? Math.round((10 * record.probes_failed_verified) / probes) : 0;
78
103
  const volume = record ? -Number(BigInt(record.counted_volume) / 1000n) : 0;
79
104
  const price = descriptor.prices.output_per_1k * 1e6 + descriptor.prices.input_per_1k;
80
- return [fraud, failure, volume, price];
105
+ return [fraud, downgrade, failure, volume, price];
81
106
  }
82
107
 
83
108
  export function compareRank(a, b) {
@@ -381,13 +381,21 @@ async function refreshNetwork() {
381
381
  const card = document.createElement("div"); card.className = "card"; card.style.marginBottom = "12px";
382
382
  const reputation = (r) => r ? `${fmt(r.counted_volume)} · ${r.counterparties} 个付款方` : "—";
383
383
  const probes = (r) => r ? `通过 ${r.probes_passed} · 验证失败 ${r.probes_failed_verified}` : "—";
384
+ // Capability probes: hard tasks the advertised model passes and a cheaper substitute tends to miss.
385
+ const capability = (r) => {
386
+ if (!r) return "—";
387
+ const graded = r.capability_passed + r.capability_failed_verified;
388
+ if (!graded) return `<span class="muted">暂无</span>`;
389
+ const text = `${r.capability_passed}/${graded}`;
390
+ return r.downgrade_suspected ? `<span class="chip disputed" title="通过率在 99% 置信度下低于该档位下限 ${Math.round(r.capability_floor * 100)}%">疑似降级 ${text}</span>` : text;
391
+ };
384
392
  const fraud = (r) => r && r.last_fraud_at && Date.now() / 1000 < r.last_fraud_at + 30 * 86400 ? `<span class="chip disputed">近期欺诈</span>` : "无";
385
393
  card.innerHTML = `<div class="row" style="margin:0"><strong>${relay.url}</strong><span class="chip ${relay.healthy ? "released" : "disputed"}">${relay.healthy ? "在线" : "离线"}</span>
386
394
  <span class="muted mono">签名 ${short(relay.signer)}</span>${relay.pinned ? `<span class="chip">证书指纹已上链</span>` : ""}</div>
387
- <table style="margin-top:8px"><thead><tr><th>Provider</th><th>模型</th><th>输入</th><th>输出</th><th>最低</th><th>信誉(计入成交)</th><th>探针</th><th>欺诈</th></tr></thead><tbody>${relay.providers.map((p) =>
395
+ <table style="margin-top:8px"><thead><tr><th>Provider</th><th>模型</th><th>输入</th><th>输出</th><th>最低</th><th>信誉(计入成交)</th><th>探针</th><th>能力探针</th><th>欺诈</th></tr></thead><tbody>${relay.providers.map((p) =>
388
396
  `<tr><td class="mono">${short(p.signer)}</td><td>${p.models.join(", ")}</td><td>${fmt(p.prices.input_per_1k)}</td><td>${fmt(p.prices.output_per_1k)}</td><td>${fmt(p.prices.minimum_fee)}</td>
389
- <td>${reputation(p.reputation)}</td><td>${probes(p.reputation)}</td><td>${fraud(p.reputation)}</td></tr>`).join("")
390
- || `<tr><td colspan="8" class="muted">没有已连接的 Provider</td></tr>`}</tbody></table>`;
397
+ <td>${reputation(p.reputation)}</td><td>${probes(p.reputation)}</td><td>${capability(p.reputation)}</td><td>${fraud(p.reputation)}</td></tr>`).join("")
398
+ || `<tr><td colspan="9" class="muted">没有已连接的 Provider</td></tr>`}</tbody></table>`;
391
399
  $("network").append(card);
392
400
  }
393
401
  }