@bolloon/bolloon-agent 0.4.31 → 0.4.33

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.
Files changed (55) hide show
  1. package/dist/agents/chain/chain-config.js +504 -0
  2. package/dist/agents/chain/chain-index-query.js +135 -0
  3. package/dist/agents/chain/chain-indexer.js +1045 -0
  4. package/dist/agents/chain/chain-settlement.js +284 -0
  5. package/dist/agents/chain/chain-state-store.js +245 -0
  6. package/dist/agents/chain/chain-wallet.js +198 -0
  7. package/dist/agents/chain/escrow-client.js +433 -0
  8. package/dist/agents/chain/explorer.js +52 -0
  9. package/dist/agents/chain/index.js +22 -0
  10. package/dist/agents/chain/onchain-trade.js +705 -0
  11. package/dist/agents/network-pulse.js +676 -2
  12. package/dist/agents/task/task-onchain-runner.js +302 -0
  13. package/dist/agents/task/task-runner.js +27 -3
  14. package/dist/agents/task-board.js +702 -0
  15. package/dist/agents/task-group.js +567 -0
  16. package/dist/agents/x402/paid-info-store.js +75 -6
  17. package/dist/cli/commands/chain.js +926 -0
  18. package/dist/cli/commands/index.js +12 -2
  19. package/dist/cli/commands/tasks.js +627 -6
  20. package/dist/cli/mcp/server.js +4 -1
  21. package/dist/cli/mcp/tools.js +348 -5
  22. package/dist/cli/protocol-envelope.js +16 -0
  23. package/dist/cli-entry.js +6 -1
  24. package/dist/electron-build/agents/chain/chain-config.js +556 -0
  25. package/dist/electron-build/agents/chain/chain-config.js.map +1 -0
  26. package/dist/electron-build/agents/chain/chain-index-query.js +145 -0
  27. package/dist/electron-build/agents/chain/chain-index-query.js.map +1 -0
  28. package/dist/electron-build/agents/chain/chain-indexer.js +1095 -0
  29. package/dist/electron-build/agents/chain/chain-indexer.js.map +1 -0
  30. package/dist/electron-build/agents/chain/chain-settlement.js +291 -0
  31. package/dist/electron-build/agents/chain/chain-settlement.js.map +1 -0
  32. package/dist/electron-build/agents/chain/escrow-client.js +443 -0
  33. package/dist/electron-build/agents/chain/escrow-client.js.map +1 -0
  34. package/dist/electron-build/agents/chain/explorer.js +59 -0
  35. package/dist/electron-build/agents/chain/explorer.js.map +1 -0
  36. package/dist/electron-build/agents/local-signer.js +101 -0
  37. package/dist/electron-build/agents/local-signer.js.map +1 -0
  38. package/dist/electron-build/agents/network-pulse.js +693 -3
  39. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  40. package/dist/electron-build/agents/task/task-runner.js +27 -3
  41. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  42. package/dist/electron-build/agents/task-board.js +761 -0
  43. package/dist/electron-build/agents/task-board.js.map +1 -0
  44. package/dist/electron-build/agents/task-contract.js +382 -0
  45. package/dist/electron-build/agents/task-contract.js.map +1 -0
  46. package/dist/electron-build/agents/x402/paid-info-store.js +76 -6
  47. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  48. package/dist/web/chain-explorer.js +617 -0
  49. package/dist/web/explorer.html +163 -0
  50. package/dist/web/index.html +5 -0
  51. package/dist/web/routes-chain-index.js +108 -0
  52. package/dist/web/routes-onchain-trade.js +99 -0
  53. package/dist/web/server.js +13 -0
  54. package/package.json +2 -1
  55. package/scripts/build-web.ts +20 -1
@@ -11,6 +11,19 @@
11
11
  * · 快照过期 → `stale` (不许伪装实时); 观察层不可用 → `unavailable`
12
12
  * · 类别小于隐私阈值 → 合并进 other
13
13
  * · 事件数 / 时间窗 / capability 数都有上限
14
+ * · **冻结形状 `confirmed_activity`** (2026-09-22; 2026-09-23 加链上可核验字段): 公开页面要能列出
15
+ * 「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时
16
+ * 退回脉冲事件并用 `confirmed_activity_source` 标注; 任务标识一律 sha256 短写 (前 8 位), 绝不落
17
+ * taskKey / taskId 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
18
+ * 2026-09-23 隐私决定**变更一处** (只放开公开链上事实, 别的一律不变): 交易哈希 (`tx_hash`) 与
19
+ * escrow **合约**地址 (`contract`, 只给索引/诊断) 允许出现; 页面可点的只有**交易**链接 `explorer_tx`
20
+ * (仅当该链有已知公网浏览器)。EOA / 钱包地址 (买方·卖方)、taskKey 原文、taskId、args 里的地址、
21
+ * DID、peer IP/multiaddr、私钥 —— 仍然一个都不许出现; 老字段 `tx` (sha256 短写) 保留不删。
22
+ * · **冻结形状 `open_tasks[]`** (2026-09-23): 公开页要能列「本节点公告板上哪些任务还没被接单」。
23
+ * 数据源 = `~/.bolloon/tasks/board/*.json` 里**未认领且未过期**的公告, 每行只含白名单 7 个字段:
24
+ * `capability` / `budget`(原子单位) / `currency` / `network` / `deadline` / `claimed`(恒 false) /
25
+ * `announcementId`(前 8 位)。**任务正文与它的摘要/预览、买方 DID 与公钥、认领者、公告签名
26
+ * 一律不进公开投影** —— 行由 `buildOpenTaskRow` 逐字段拷贝构造, 不做对象展平。
14
27
  *
15
28
  * 借鉴 (只借产品与工程思想, 不借 Go/Postgres/API 项目):
16
29
  * EigenFlux 的 "注册总量 / 当前活跃 / 最近出现" 时间窗统计、服务端生成匿名活动文本、
@@ -50,7 +63,7 @@ var __importStar = (this && this.__importStar) || (function () {
50
63
  };
51
64
  })();
52
65
  Object.defineProperty(exports, "__esModule", { value: true });
53
- exports.FORBIDDEN_PUBLIC_KEYS = exports.MAX_AGENT_SITES = exports.pulseDir = exports.NETWORK_EVENT_TYPES = exports.PULSE_LIMITS = void 0;
66
+ exports.PUBLIC_HEX_KEYS = exports.FORBIDDEN_PUBLIC_KEYS = exports.OPEN_TASK_KEYS = exports.CHAIN_LABELS = exports.PUBLIC_MAINNET_CHAIN_ID = exports.PUBLIC_TESTNET_CHAIN_ID = exports.PULSE_EVENT_ACTIVITY = exports.CHAIN_EVENT_ACTIVITY = exports.CONFIRMED_ACTIVITY_LIMIT = exports.MAX_OPEN_TASKS = exports.MAX_AGENT_SITES = exports.pulseDir = exports.NETWORK_EVENT_TYPES = exports.PULSE_LIMITS = void 0;
54
67
  exports.nodeDigest = nodeDigest;
55
68
  exports.capabilityGroup = capabilityGroup;
56
69
  exports.bucketOf = bucketOf;
@@ -58,7 +71,22 @@ exports.recordNetworkEvent = recordNetworkEvent;
58
71
  exports.renderActivityText = renderActivityText;
59
72
  exports.normalizeIpns = normalizeIpns;
60
73
  exports.readAgentSites = readAgentSites;
74
+ exports.readOpenTasks = readOpenTasks;
61
75
  exports.emitTradePulse = emitTradePulse;
76
+ exports.anonShortRef = anonShortRef;
77
+ exports.isoSeconds = isoSeconds;
78
+ exports.normalizeActivityGates = normalizeActivityGates;
79
+ exports.finalityFromConfirmations = finalityFromConfirmations;
80
+ exports.buildConfirmedActivityFromIndex = buildConfirmedActivityFromIndex;
81
+ exports.buildConfirmedActivityFromEvents = buildConfirmedActivityFromEvents;
82
+ exports.confirmedActivityFromEvents = confirmedActivityFromEvents;
83
+ exports.resolveConfirmedActivity = resolveConfirmedActivity;
84
+ exports.chainLabelOf = chainLabelOf;
85
+ exports.isPublicChainId = isPublicChainId;
86
+ exports.summarizeActivityRows = summarizeActivityRows;
87
+ exports.buildChainIdScope = buildChainIdScope;
88
+ exports.snapshotConsistencyIssues = snapshotConsistencyIssues;
89
+ exports.openTasksIssues = openTasksIssues;
62
90
  exports.computeSnapshot = computeSnapshot;
63
91
  exports.getNetworkPulse = getNetworkPulse;
64
92
  exports.snapshotStatus = snapshotStatus;
@@ -67,10 +95,18 @@ exports.lastSnapshotSignError = lastSnapshotSignError;
67
95
  exports.signSnapshot = signSnapshot;
68
96
  exports.verifySnapshotSignature = verifySnapshotSignature;
69
97
  exports.assertNoPrivateFields = assertNoPrivateFields;
98
+ exports.auditPublicHexLeaks = auditPublicHexLeaks;
70
99
  const fs = __importStar(require("fs"));
71
100
  const os = __importStar(require("os"));
72
101
  const path = __importStar(require("path"));
73
102
  const crypto = __importStar(require("crypto"));
103
+ // 确认数门槛来自 chain-config (单一实现; 默认 confirmed=1 / finalized=12) —— 只借常量, 不借 RPC
104
+ const chain_config_js_1 = require("./chain/chain-config.js");
105
+ // 区块浏览器映射 (chainId → 公网浏览器); 认不出的链没有链接 —— 宁缺勿错, 不编死链
106
+ const explorer_js_1 = require("./chain/explorer.js");
107
+ // 「待接单任务」的数据源 = 本机公告板目录 (~/.bolloon/tasks/board)。只借**路径常量**,
108
+ // 公告的解析/发布/接单一律留在 task-board.ts (这里不做第二套实现)。
109
+ const task_board_js_1 = require("./task-board.js");
74
110
  // ── 常量 (上限 + 时间边界) ─────────────────────────────────────────────────
75
111
  exports.PULSE_LIMITS = {
76
112
  /** 最多留多少条事件 (超出丢最旧) */
@@ -89,6 +125,8 @@ exports.PULSE_LIMITS = {
89
125
  snapshotTtlMs: 30 * 1000,
90
126
  /** 活动流最多几条 */
91
127
  maxActivity: 8,
128
+ /** confirmed_activity (冻结形状的真实活动行) 上限 */
129
+ maxConfirmedActivity: 25,
92
130
  };
93
131
  /** 事件类型白名单 —— 只允许这些进统计 (其余一律丢弃) */
94
132
  exports.NETWORK_EVENT_TYPES = [
@@ -100,6 +138,8 @@ exports.NETWORK_EVENT_TYPES = [
100
138
  // 经济事件 (P6 扩展; 原五类保持兼容, 老节点发来的事件仍被接受)
101
139
  'task_posted', 'task_accepted', 'task_completed', 'trade_settled', 'trade_verified',
102
140
  'wallet_signed',
141
+ // 2026-09-23 (C1): 任务对外发布 (公告板 publish) —— 只记"有节点公告了一个待接单任务", 正文/ID 原文不落盘
142
+ 'task_announced',
103
143
  ];
104
144
  // ── 存储 ────────────────────────────────────────────────────────────────────
105
145
  const home = (h) => h || process.env.HOME || os.homedir() || '/tmp';
@@ -212,6 +252,7 @@ function renderActivityText(kind) {
212
252
  case 'capability_announced': return { zh: '有节点公开声明了新能力', en: 'A node announced a capability' };
213
253
  case 'peer_connected': return { zh: '两个节点建立了连接', en: 'Two nodes connected' };
214
254
  case 'delegation_completed': return { zh: '一次能力委派完成', en: 'A delegation completed' };
255
+ case 'task_announced': return { zh: '有节点公告了一个待接单任务', en: 'A node announced an open task' };
215
256
  default: return { zh: '网络有活动', en: 'Network activity' };
216
257
  }
217
258
  }
@@ -261,6 +302,82 @@ function readAgentSites(h) {
261
302
  return [];
262
303
  }
263
304
  }
305
+ /** 上限: 公开页最多列几条待接单任务 (超出按最近截止排序取前 N) */
306
+ exports.MAX_OPEN_TASKS = 25;
307
+ /** 公开页只列这条公告的这 7 个字段; 其余(正文/买方/摘要/签名)一律不出这个函数 */
308
+ function buildOpenTaskRow(a) {
309
+ const capability = String(a?.capability ?? '').trim();
310
+ const id = String(a?.announcementId ?? '').trim();
311
+ const deadline = Number(a?.deadline);
312
+ if (!capability || !id || !Number.isFinite(deadline) || deadline <= 0)
313
+ return null;
314
+ const b = a?.budget && typeof a.budget === 'object' ? a.budget : null;
315
+ const maxAmount = b ? String(b.maxAmount ?? '').trim() : '';
316
+ const currency = b ? String(b.currency ?? '').trim() : '';
317
+ const network = b ? String(b.network ?? '').trim() : '';
318
+ return {
319
+ capability,
320
+ budget: maxAmount || null,
321
+ currency: currency || null,
322
+ network: network || null,
323
+ deadline,
324
+ claimed: (Array.isArray(a?.claims) ? a.claims.length : 0) > 0,
325
+ announcementId: id.slice(0, 8), // 前 8 位 (按字面, 不重新格式化)
326
+ };
327
+ }
328
+ /**
329
+ * 读本节点公告板上**未认领且未过期**的公告, 投影成公开「待接单任务」行。
330
+ *
331
+ * 三条筛选 (缺一个都会把不该上的东西露出来):
332
+ * · `protocol= bolloon-task/1` + `kind= task_announcement` (同目录还有 `remote-claims.json`,
333
+ * 它是个数组 —— 不按 kind 判会把认领台账当公告解析)
334
+ * · `status === 'open'` (已认领 / 已取消 → 不是「待接单」)
335
+ * · `deadline > now` (过期只是不能再接单, 文件不删 → 必须在这里判掉)
336
+ *
337
+ * 读不到目录 / 坏 JSON / 缺字段 → 该条跳过 (一条都不编); 整个目录不可读 → 空数组 + 不抛。
338
+ */
339
+ function readOpenTasks(h, now = Date.now()) {
340
+ const out = [];
341
+ try {
342
+ const dir = (0, task_board_js_1.boardDir)(h);
343
+ let names;
344
+ try {
345
+ names = fs.readdirSync(dir);
346
+ }
347
+ catch {
348
+ return [];
349
+ }
350
+ for (const name of names.slice().sort()) {
351
+ if (!name.endsWith('.json') || name === `${task_board_js_1.REMOTE_CLAIMS_FILE}`)
352
+ continue;
353
+ let raw;
354
+ try {
355
+ raw = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
356
+ }
357
+ catch {
358
+ continue;
359
+ }
360
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
361
+ continue; // 认领台账是数组 → 这里就出局
362
+ if (raw.protocol !== 'bolloon-task/1' || raw.kind !== 'task_announcement')
363
+ continue;
364
+ if (String(raw.status) !== 'open')
365
+ continue;
366
+ const deadline = Number(raw.deadline);
367
+ if (!Number.isFinite(deadline) || deadline <= now)
368
+ continue;
369
+ const row = buildOpenTaskRow(raw);
370
+ if (row)
371
+ out.push(row);
372
+ }
373
+ }
374
+ catch {
375
+ return [];
376
+ }
377
+ // 稳定排序: 快到期在前 (读者最需要知道的先看); 同截止按短 id 定序 (排序必须确定, 否则快照每次都"变")
378
+ out.sort((x, y) => (x.deadline - y.deadline) || (x.announcementId < y.announcementId ? -1 : x.announcementId > y.announcementId ? 1 : 0));
379
+ return out.slice(0, exports.MAX_OPEN_TASKS);
380
+ }
264
381
  /**
265
382
  * 把**真实交易活动**投影成脉冲事件 (由交易写路径调用, fire-and-forget)。
266
383
  * 只按事实发: 交付 → task_completed · 真验真 → trade_verified · **链上口径**结算 → trade_settled。
@@ -290,10 +407,472 @@ async function emitTradePulse(before, after, h) {
290
407
  /* 统计失败绝不影响交易主路径 */
291
408
  }
292
409
  }
410
+ // ── 冻结形状: confirmed_activity (真实智能体任务 / 链上活动行) ────────────────
411
+ //
412
+ // 公开网页要能列出「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字。这块就是那批行。
413
+ // 形状**冻结** (老字段名与取值域不许改), 内容按用途分两类:
414
+ // · **匿名短写** (老口径, 保留不删): task = `sha256:` + sha256(域标签|taskKey) 的前 8 位;
415
+ // tx = `sha256:` + sha256(域标签|txHash) 的前 8 位 —— 绝不落 taskKey / taskId 原文
416
+ // · **公开链上事实** (2026-09-23 新增; 同日按 leo 拍板收窄): tx_hash (真交易哈希) /
417
+ // explorer_tx (交易浏览器链接, 仅当该链有已知公网浏览器) —— 网页上的行因此**可核验地**跳回区块
418
+ // 浏览器; contract (escrow 合约地址) 仍保留在行里**供索引/诊断**, 但**不上页面、不生成合约链接**
419
+ // 隐私红线 (违反即失败, **只放开上面那两种 0x 字符串**):
420
+ // · 允许: 交易哈希 (0x+64 hex) 与 escrow 合约地址 (0x+40 hex), 且只出现在 `tx_hash`/`contract`/`explorer_tx` 这 3 个键下
421
+ // · 禁止: EOA / 钱包地址 (买方/卖方/payTo)、taskKey 原文、taskId、args 里的地址、DID、peerId /
422
+ // IP / multiaddr、私钥 —— `assertNoPrivateFields`(键名) + `auditPublicHexLeaks`(越界 0x) 兜底
423
+ // · 拿不到链配置里的 escrow 地址, 或这条索引记录的 address 不是它 → **不填** contract
424
+ // (宁缺勿错: 宁可少一个字段, 也不把可能是别人的地址写成合约)
425
+ // 数据源优先级: ① P5 链上索引 (真链上事实) → ② 索引不可用 → 退回脉冲事件, 并在快照里用
426
+ // `confirmed_activity_source` 如实标注 (chain-index / pulse-events / none)。
427
+ // 确认数口径走 chain-config 门槛 (默认 confirmed=1 / finalized=12): 够不着门槛的行只报
428
+ // `observed`, **绝不冒充** confirmed/finalized。
429
+ /** 冻结形状: 活动行上限 */
430
+ exports.CONFIRMED_ACTIVITY_LIMIT = exports.PULSE_LIMITS.maxConfirmedActivity;
431
+ /**
432
+ * 链上 escrow 事件名 → (kind, state) 的**唯一**映射表 (改这里才对, 别在别处写 switch)。
433
+ *
434
+ * kind 只能取冻结枚举的 5 个值, 链上有 6 类事件 → 按「任务生命周期 / 托管资金路径」归并:
435
+ * · EscrowCreatedV2 委托创建 + 资金入托管 → task_created / active
436
+ * · ProofSubmittedV2 交付物验真摘要落链 (= 该笔交付完成) → task_completed / active (托管仍 ACTIVE, 等结算)
437
+ * · ReleasedV2 结算出金 → trade_settled / released
438
+ * · RefundedV2 托管退款 → trade_settled / refunded
439
+ * · ExpiredV2 托管到期 → trade_settled / expired
440
+ * · DisputedV2 资金冻结进入争议 (托管资金路径) → trade_settled / disputed
441
+ * 真信号在 `state` (kind 只是粗桶); 事件名不在表里 → 该条**不成行** (不猜)。
442
+ */
443
+ exports.CHAIN_EVENT_ACTIVITY = {
444
+ EscrowCreatedV2: { kind: 'task_created', state: 'active' },
445
+ ProofSubmittedV2: { kind: 'task_completed', state: 'active' },
446
+ ReleasedV2: { kind: 'trade_settled', state: 'released' },
447
+ RefundedV2: { kind: 'trade_settled', state: 'refunded' },
448
+ ExpiredV2: { kind: 'trade_settled', state: 'expired' },
449
+ DisputedV2: { kind: 'trade_settled', state: 'disputed' },
450
+ };
451
+ /** 脉冲事件 → 冻结 kind 的映射 (索引不可用时的降级路径; 没有 taskProof 的事件不成行) */
452
+ exports.PULSE_EVENT_ACTIVITY = {
453
+ task_posted: 'task_created',
454
+ task_accepted: 'task_accepted',
455
+ task_completed: 'task_completed',
456
+ trade_settled: 'trade_settled',
457
+ trade_verified: 'trade_verified',
458
+ };
459
+ /** 标识 → `sha256:<前 8 位十六进制>`。域标签隔离用途: 同一原值稳定 (可 join), 不同用途不串, 不可逆。 */
460
+ function anonShortRef(value, domain = 'id') {
461
+ return `sha256:${nodeDigest(`${domain}|${String(value)}`).slice(0, 8)}`;
462
+ }
463
+ /** ISO8601 UTC 秒级 (`2026-09-22T05:31:00Z`) —— 冻结形状里 `at` 的格式。非法时间 → null */
464
+ function isoSeconds(at) {
465
+ const n = Number(at);
466
+ if (!Number.isFinite(n) || n <= 0)
467
+ return null;
468
+ try {
469
+ return new Date(n).toISOString().replace(/\.\d{3}Z$/, 'Z');
470
+ }
471
+ catch {
472
+ return null;
473
+ }
474
+ }
475
+ /** 确认门槛归一化: 缺/非法 → chain-config 默认 (1/12); finalized 不许低于 confirmed */
476
+ function normalizeActivityGates(g) {
477
+ const c = Number(g?.confirmed);
478
+ const f = Number(g?.finalized);
479
+ const confirmed = Number.isInteger(c) && c > 0 ? c : chain_config_js_1.DEFAULT_CONFIRMATIONS.confirmed;
480
+ const finalized = Number.isInteger(f) && f >= confirmed ? f : Math.max(confirmed, chain_config_js_1.DEFAULT_CONFIRMATIONS.finalized);
481
+ return { confirmed, finalized };
482
+ }
483
+ /**
484
+ * finality **只按确认数复算** (单一实现): 够 finalized → finalized; 够 confirmed → confirmed;
485
+ * 不够 / 非法 / 被回退过 → observed。行里原本自报的 finality 一律不复用 —— 不满足就是 observed。
486
+ */
487
+ function finalityFromConfirmations(confirmations, gates, opts = {}) {
488
+ if (opts.suspect)
489
+ return 'observed';
490
+ const g = normalizeActivityGates(gates);
491
+ const c = Number(confirmations);
492
+ if (!Number.isFinite(c) || c < g.confirmed)
493
+ return 'observed';
494
+ return c >= g.finalized ? 'finalized' : 'confirmed';
495
+ }
496
+ function activityLimit(limit) {
497
+ const n = Math.floor(Number(limit));
498
+ if (!Number.isFinite(n))
499
+ return exports.CONFIRMED_ACTIVITY_LIMIT;
500
+ return Math.max(0, Math.min(exports.CONFIRMED_ACTIVITY_LIMIT, n));
501
+ }
502
+ /**
503
+ * 链上索引条目 → 冻结活动行 (**纯函数**, 不读盘不发 RPC, 便于单测)。
504
+ * · suspect (被回退 / 链上已消失) 的记录**不成行** —— 它已经不在规范链上, 不该当活动列出
505
+ * · 不认识的事件名 / 非法 taskKey·txHash / 缺观察时间 → 跳过 (不猜、不臆造)
506
+ * · confirmations: 有 headBlock → head - block + 1 复算 (无 RPC, 用索引快照里的 head); 否则用记录值
507
+ * · finality: 按 chain-config 门槛复算 (不够 → observed)
508
+ * · 新增: `tx_hash` (真交易哈希) 恒给; `contract` 仅当 opts.escrowAddress 合法且 === 本条 address;
509
+ * `explorer_tx` 仅当该 chainId 有**已知公网浏览器** (否则键整个不存在, 不填 null)
510
+ * · 最新在前 (blockNumber desc, logIndex desc), 上限 25 行
511
+ */
512
+ function buildConfirmedActivityFromIndex(entries, opts = {}) {
513
+ const gates = normalizeActivityGates(opts.gates);
514
+ const limit = activityLimit(opts.limit);
515
+ const chainId = Number.isInteger(Number(opts.chainId)) && Number(opts.chainId) >= 0 ? Number(opts.chainId) : 0;
516
+ const head = Number.isInteger(Number(opts.headBlock)) && Number(opts.headBlock) >= 0 ? Number(opts.headBlock) : null;
517
+ // escrow 合约地址白名单 (唯一权威): 只有索引文件自己记的这个地址算数, 别的地址一律不当合约
518
+ const escrow = typeof opts.escrowAddress === 'string' ? opts.escrowAddress.toLowerCase() : '';
519
+ const escrowOk = explorer_js_1.ADDRESS_RE.test(escrow) ? escrow : '';
520
+ const out = [];
521
+ for (const e of Array.isArray(entries) ? entries : []) {
522
+ if (!e || e.suspect === true)
523
+ continue; // 回退过的记录不在规范链上 → 不成行
524
+ const map = exports.CHAIN_EVENT_ACTIVITY[String(e.eventName || '')];
525
+ if (!map)
526
+ continue; // 不认识的事件 → 跳过 (不猜)
527
+ const taskKey = String(e.taskKey || '').toLowerCase();
528
+ const txHash = String(e.txHash || '').toLowerCase();
529
+ if (!/^0x[0-9a-f]{64}$/.test(taskKey) || !/^0x[0-9a-f]{64}$/.test(txHash))
530
+ continue;
531
+ const block = Number(e.blockNumber);
532
+ const logIndex = Number(e.logIndex);
533
+ if (!Number.isInteger(block) || block < 0 || !Number.isInteger(logIndex) || logIndex < 0)
534
+ continue;
535
+ const atMs = Number(e.firstSeenAt);
536
+ const at = isoSeconds(atMs);
537
+ if (!at)
538
+ continue; // 没观察时间 → 不臆造时间, 不成行
539
+ const confirmations = head == null ? Math.max(0, Number(e.confirmations) || 0) : Math.max(0, head - block + 1);
540
+ // 合约地址: 只有「本条记录的 address 就是链配置里的 escrow 地址」才填 (宁缺勿错)
541
+ const entryAddr = String(e.address || '').toLowerCase();
542
+ const contract = escrowOk && entryAddr === escrowOk ? escrowOk : '';
543
+ // 浏览器链接: 该链没有已知公网浏览器 → 返回 null → 键整个不出现 (不编 href="#")
544
+ // 只有**交易**链接 (explorer_tx); 合约地址只在数据里 (contract), 不生成链接 (2026-09-23 收窄)
545
+ const explorerTx = (0, explorer_js_1.explorerTxUrl)(chainId, txHash);
546
+ out.push({
547
+ block, logIndex,
548
+ row: {
549
+ task: anonShortRef(taskKey, 'task'),
550
+ kind: map.kind,
551
+ state: map.state,
552
+ chain_id: chainId,
553
+ block,
554
+ tx: anonShortRef(txHash, 'tx'),
555
+ confirmations,
556
+ finality: finalityFromConfirmations(confirmations, gates),
557
+ at,
558
+ // 公开链上事实 (白名单键; 老字段 tx 已在上方保留)
559
+ tx_hash: txHash,
560
+ ...(contract ? { contract } : {}),
561
+ ...(explorerTx ? { explorer_tx: explorerTx } : {}),
562
+ },
563
+ });
564
+ }
565
+ out.sort((a, b) => b.block - a.block || b.logIndex - a.logIndex);
566
+ return out.slice(0, limit).map((x) => x.row);
567
+ }
568
+ /**
569
+ * 脉冲事件 → 冻结活动行 (**纯函数**, 索引不可用时的降级路径)。
570
+ * · 只有带 taskProof 的经济事件成行; state 一律 `unknown` —— 脉冲事件不带 escrow 结局, 不推
571
+ * · 没有链上事实: chain_id / block / confirmations = 0, finality = observed (绝不冒充已确认)
572
+ * · tx = 事件摘要短写 (不是交易哈希 —— 降级行没有 txHash, 字段名冻结只能这样填)
573
+ * · 同一 (task, kind) 只留最新一条; 最新在前, 上限 25 行
574
+ */
575
+ function buildConfirmedActivityFromEvents(events, opts = {}) {
576
+ const limit = activityLimit(opts.limit);
577
+ if (limit <= 0)
578
+ return [];
579
+ const all = [];
580
+ for (const e of Array.isArray(events) ? events : []) {
581
+ if (!isValidEvent(e))
582
+ continue;
583
+ const kind = exports.PULSE_EVENT_ACTIVITY[e.type];
584
+ if (!kind)
585
+ continue; // 非经济事件 (入网/能力/连接…) → 不成行
586
+ const proof = String(e.taskProof || '').toLowerCase();
587
+ if (!/^[0-9a-f]{16}$/.test(proof))
588
+ continue; // 没有任务摘要 → 不成行
589
+ const at = isoSeconds(e.occurredAt);
590
+ if (!at)
591
+ continue;
592
+ all.push({
593
+ at: e.occurredAt,
594
+ row: {
595
+ task: `sha256:${proof.slice(0, 8)}`,
596
+ kind,
597
+ state: 'unknown',
598
+ chain_id: 0,
599
+ block: 0,
600
+ tx: anonShortRef(`pulse|${e.sourceProof}|${e.occurredAt}|${e.type}`, 'tx'),
601
+ confirmations: 0,
602
+ finality: 'observed',
603
+ at,
604
+ },
605
+ });
606
+ }
607
+ all.sort((a, b) => b.at - a.at); // 最新在前
608
+ const seen = new Set();
609
+ const rows = [];
610
+ for (const x of all) {
611
+ const key = `${x.row.task}|${x.row.kind}`;
612
+ if (seen.has(key))
613
+ continue; // 同一任务同一动作只留最新一条
614
+ seen.add(key);
615
+ rows.push(x.row);
616
+ if (rows.length >= limit)
617
+ break;
618
+ }
619
+ return rows;
620
+ }
621
+ /** 纯函数降级: 直接从脉冲事件算活动行 (没有索引时用, source 如实写 pulse-events / none) */
622
+ function confirmedActivityFromEvents(events, limit) {
623
+ const rows = buildConfirmedActivityFromEvents(events, { limit });
624
+ return { source: rows.length ? 'pulse-events' : 'none', rows, gates: normalizeActivityGates(chain_config_js_1.DEFAULT_CONFIRMATIONS) };
625
+ }
626
+ /** 惰性加载链上索引只读查询模块 (只读索引文件, 不发 RPC; 加载/读取失败 → 降级) */
627
+ let chainQueryModule = null;
628
+ async function readChainIndex(home) {
629
+ try {
630
+ if (!chainQueryModule)
631
+ chainQueryModule = (await Promise.resolve().then(() => __importStar(require('./chain/chain-index-query.js'))));
632
+ return chainQueryModule.readIndexFile({ home });
633
+ }
634
+ catch {
635
+ return null;
636
+ }
637
+ }
638
+ /**
639
+ * 解析活动行 (唯一入口): **先链上索引, 再脉冲降级**, 并如实报出来源。
640
+ * 索引文件不存在/读不出/没有任何可用行 → 退回脉冲事件; 两边都没有 → none (不编行)。
641
+ */
642
+ async function resolveConfirmedActivity(q) {
643
+ const now = Number(q.now ?? Date.now());
644
+ const limit = activityLimit(q.limit);
645
+ const windowed = (Array.isArray(q.events) ? q.events : [])
646
+ .filter((e) => isValidEvent(e) && e.occurredAt >= now - exports.PULSE_LIMITS.windowMs);
647
+ // ① 链上索引优先 (真链上事实)
648
+ const reader = q.readIndex ?? readChainIndex;
649
+ try {
650
+ const file = await reader(q.home);
651
+ const entries = Array.isArray(file?.entries) ? file.entries : [];
652
+ if (entries.length > 0) {
653
+ const gates = normalizeActivityGates(file?.confirmations);
654
+ const rows = buildConfirmedActivityFromIndex(entries, {
655
+ gates,
656
+ headBlock: file?.headBlock,
657
+ chainId: file?.chainId,
658
+ limit,
659
+ escrowAddress: file?.escrowAddress, // 合约地址白名单 = 索引文件自己的部署身份
660
+ });
661
+ if (rows.length > 0)
662
+ return { source: 'chain-index', rows, gates };
663
+ }
664
+ }
665
+ catch { /* 索引不可用 → 降级 (来源会在快照里标明) */ }
666
+ // ② 降级: 脉冲事件
667
+ return confirmedActivityFromEvents(windowed, limit);
668
+ }
669
+ // ── 同源计数 · 口径说明 · 链归属 (2026-09-22: 公开页数字不许自相矛盾) ──────────
670
+ //
671
+ // 起因: 真快照里 `totals.tasks=0` 而 `confirmed_activity` 有 25 行真实任务 —— 两块数据来自
672
+ // **两套口径** (totals = 24h 脉冲事件窗口; confirmed_activity = 链上索引全量), 但页面上它们同屏,
673
+ // 读者只会读成"自相矛盾/在撒谎"。修法不是把数字改漂亮, 而是:
674
+ // ① `activity_totals` = 与 confirmed_activity **同源**的计数 (rows 恒等于该数组长度);
675
+ // ② `totals_scope` = totals 的口径说明 + `differs_from_activity` 标记;
676
+ // ③ `chain_id_scope` = 上表各行属于哪条链 (本机 31337 ≠ 真网 Base Sepolia 84532);
677
+ // ④ `snapshotConsistencyIssues` = 导出前的自检 (两个数字打架就拒绝导出)。
678
+ /** 公网测试网 (Base Sepolia) chainId —— 只做**归属说明**, 不代表快照观察到了公网事件 */
679
+ exports.PUBLIC_TESTNET_CHAIN_ID = 84532;
680
+ /** 公网主网 (Base) chainId —— 同上, 只做归属说明 */
681
+ exports.PUBLIC_MAINNET_CHAIN_ID = 8453;
682
+ /**
683
+ * chainId → 展示用网络名 (只回答"这一屏的行属于哪条链")。
684
+ * 认不出的 chain id **不编名字** (chainLabelOf → null), 也不许算成公网。
685
+ */
686
+ exports.CHAIN_LABELS = {
687
+ [chain_config_js_1.LOCAL_DEV_CHAIN_ID]: { zh: '本机隔离开发链', en: 'local isolated dev chain', publicNetwork: false },
688
+ [exports.PUBLIC_TESTNET_CHAIN_ID]: { zh: 'Base Sepolia 测试网', en: 'Base Sepolia testnet', publicNetwork: true },
689
+ [exports.PUBLIC_MAINNET_CHAIN_ID]: { zh: 'Base 主网', en: 'Base mainnet', publicNetwork: true },
690
+ };
691
+ /** chainId 的展示名; 认不出的 chain id → null (不编名字) */
692
+ function chainLabelOf(chainId) {
693
+ const id = Number(chainId);
694
+ return Number.isInteger(id) ? exports.CHAIN_LABELS[id] ?? null : null;
695
+ }
696
+ /** 是不是公网链 id (白名单; 认不出的一律**不算**公网 —— 不替读者认领归属) */
697
+ function isPublicChainId(chainId) {
698
+ return chainLabelOf(chainId)?.publicNetwork === true;
699
+ }
700
+ /**
701
+ * 活动行 → 同源计数 (**纯函数**; 输入就是 `confirmed_activity` 本身)。
702
+ * 存在的唯一理由: 公开页同时显示 totals 与本表行数, 两个数字肉眼可能"打架" ——
703
+ * 那就让它们同源可核: `activity_totals.rows` 恒等于 `confirmed_activity.length`。
704
+ */
705
+ function summarizeActivityRows(rows, opts) {
706
+ const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
707
+ const tasks = new Set();
708
+ const completed = new Set();
709
+ const settled = new Set();
710
+ const by_finality = { observed: 0, confirmed: 0, finalized: 0 };
711
+ for (const r of list) {
712
+ const t = String(r.task || '');
713
+ if (t)
714
+ tasks.add(t);
715
+ if (t && r.kind === 'task_completed')
716
+ completed.add(t);
717
+ if (t && r.kind === 'trade_settled')
718
+ settled.add(t);
719
+ if (r.finality === 'observed' || r.finality === 'confirmed' || r.finality === 'finalized') {
720
+ by_finality[r.finality] += 1;
721
+ }
722
+ }
723
+ return {
724
+ source: opts.source,
725
+ rows: list.length,
726
+ tasks: tasks.size,
727
+ tasks_completed: completed.size,
728
+ tasks_settled: settled.size,
729
+ by_finality,
730
+ gates: normalizeActivityGates(opts.gates),
731
+ };
732
+ }
733
+ /** 活动行 → 链归属 (**纯函数**)。所有结论都从行本身推, 不引用外部状态 → 不可能与行数打架。 */
734
+ function buildChainIdScope(rows) {
735
+ const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
736
+ const counts = new Map();
737
+ for (const r of list) {
738
+ const id = Number(r.chain_id);
739
+ if (!Number.isInteger(id) || id < 0)
740
+ continue;
741
+ counts.set(id, (counts.get(id) || 0) + 1);
742
+ }
743
+ const chain_ids = Array.from(counts.keys()).sort((a, b) => a - b);
744
+ let primary = 0;
745
+ let best = 0;
746
+ for (const id of chain_ids) { // 升序遍历 + 严格大于 → 并列取小
747
+ const c = counts.get(id);
748
+ if (c > best) {
749
+ best = c;
750
+ primary = id;
751
+ }
752
+ }
753
+ const label = chain_ids.length ? chainLabelOf(primary) : null;
754
+ const publicIds = chain_ids.filter(isPublicChainId);
755
+ const publicRows = publicIds.reduce((n, id) => n + (counts.get(id) || 0), 0);
756
+ const pub = exports.CHAIN_LABELS[exports.PUBLIC_TESTNET_CHAIN_ID];
757
+ // 点名口径: 提示里写出的公网链必须是**行里真出现过的**; 一条都没有才退回对照链
758
+ // (否则会出现「上表 3 行来自 chainId 8453(Base 主网)」却说「公网链(Base Sepolia 84532) 3 行」的自相矛盾)
759
+ const pubZh = publicIds.length
760
+ ? publicIds.map((id) => `${exports.CHAIN_LABELS[id].zh} ${id}`).join('、')
761
+ : `${pub.zh} ${exports.PUBLIC_TESTNET_CHAIN_ID}`;
762
+ const pubEn = publicIds.length
763
+ ? publicIds.map((id) => `${exports.CHAIN_LABELS[id].en} ${id}`).join(', ')
764
+ : `${pub.en} ${exports.PUBLIC_TESTNET_CHAIN_ID}`;
765
+ const rows_ = list.length;
766
+ const multi = chain_ids.length > 1 ? `(上表共 ${chain_ids.length} 条链)` : '';
767
+ const note = rows_ === 0
768
+ ? {
769
+ zh: 'chain_id 归属: 本快照没有链上活动行 —— 不是"链上没事件", 而是这一轮没有可列出的行',
770
+ en: 'chain_id scope: no on-chain activity row in this snapshot — not "nothing happened on chain", just no listable row this round',
771
+ }
772
+ : {
773
+ zh: `chain_id 归属: 上表 ${rows_} 行来自 chainId ${primary}${label ? `(${label.zh})` : ''}${multi} · ` +
774
+ `公网链(${pubZh})${publicRows} 行` +
775
+ `${publicRows === 0 ? ' —— 这不是公网活动' : ''}`,
776
+ en: `chain_id scope: all ${rows_} rows above come from chainId ${primary}${label ? ` (${label.en})` : ''}${chain_ids.length > 1 ? ` (${chain_ids.length} chains in total)` : ''} · ` +
777
+ `public network (${pubEn}) rows: ${publicRows}` +
778
+ `${publicRows === 0 ? ' — this is not public-network activity' : ''}`,
779
+ };
780
+ return {
781
+ chain_ids,
782
+ activity_chain_id: primary,
783
+ activity_chain_label: label ? { zh: label.zh, en: label.en } : null,
784
+ is_public_network: chain_ids.length > 0 ? isPublicChainId(primary) : false,
785
+ public_network_rows: publicRows,
786
+ public_network: { chain_id: exports.PUBLIC_TESTNET_CHAIN_ID, label: { zh: pub.zh, en: pub.en } },
787
+ note,
788
+ };
789
+ }
790
+ /**
791
+ * 快照自检: 公开页会**同屏展示**的数字之间不许自相矛盾。返回问题清单 (空 = 通过)。
792
+ * ① `activity_totals` 必须在 (与行同源的计数不能缺)
793
+ * ② `activity_totals.rows` === `confirmed_activity.length`
794
+ * ③ `by_finality` 三档之和 === 行数 (没有第四条腿)
795
+ * ④ `activity_totals.source` === `confirmed_activity_source` (两块来源标注必须一致)
796
+ * ⑤ 行里有任务 而 `totals.tasks === 0` 时, 必须有口径说明 note
797
+ * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
798
+ * ⑥ 0x 长 hex 越界 (2026-09-23): 只许出现在 tx_hash/contract/explorer_* 四个白名单键下 ——
799
+ * EOA 地址跑到别的键 (或白名单键形状不对) 就拒绝导出
800
+ */
801
+ function snapshotConsistencyIssues(snap) {
802
+ const issues = [];
803
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
804
+ const at = snap?.activity_totals;
805
+ if (!at || typeof at !== 'object') {
806
+ return ['activity_totals 缺失 (与 confirmed_activity 同源的计数必须一起给)'];
807
+ }
808
+ if (Number(at.rows) !== rows.length)
809
+ issues.push(`activity_totals.rows=${at.rows} ≠ confirmed_activity.length=${rows.length}`);
810
+ const bf = at.by_finality || {};
811
+ const sum = ['observed', 'confirmed', 'finalized'].reduce((n, k) => n + (Number(bf[k]) || 0), 0);
812
+ if (sum !== rows.length)
813
+ issues.push(`by_finality 三档之和=${sum} ≠ 行数=${rows.length}`);
814
+ if (String(at.source || '') !== String(snap?.confirmed_activity_source || '')) {
815
+ issues.push(`activity_totals.source=${at.source} ≠ confirmed_activity_source=${snap?.confirmed_activity_source}`);
816
+ }
817
+ const tasks = Number(at.tasks) || 0;
818
+ const pulseTasks = Number(snap?.totals?.tasks) || 0;
819
+ if (rows.length > 0 && tasks > 0 && pulseTasks === 0) {
820
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
821
+ const explained = notes.includes('脉冲事件') && notes.includes(String(rows.length)) &&
822
+ (notes.includes('链上索引') || notes.includes('chain-index'));
823
+ if (!explained)
824
+ issues.push(`totals.tasks=0 与 ${rows.length} 行任务并存, 却没有口径说明 note`);
825
+ }
826
+ issues.push(...auditPublicHexLeaks(snap)); // ⑥ 0x 长 hex 越界 → 拒绝导出 (不静默放行)
827
+ issues.push(...openTasksIssues(snap)); // ⑦ 待接单任务的字段白名单与「只含未认领」不变式
828
+ return issues;
829
+ }
830
+ /** open_tasks 行的字段白名单 (与 `OpenTaskRow` 逐字一致; 多一个键 = 可能把正文/身份带出去了) */
831
+ exports.OPEN_TASK_KEYS = ['capability', 'budget', 'currency', 'network', 'deadline', 'claimed', 'announcementId'];
832
+ /**
833
+ * 待接单任务行的自检 (空 = 通过)。这不是格式洁癖, 每条都对着一个真泄漏路径:
834
+ * · 键集 ≠ 白名单 → 有人把 instruction / buyerDid / claims 之类**展平**进公开投影了;
835
+ * · `claimed !== false` → 「只导出未认领」这个筛选坏了 (页面会把已接单的当待接单);
836
+ * · `announcementId` 长于 8 位 → 短写丢了, 原始公告 id 上公开页 (页面上的 id 会被读者当完整 id 用);
837
+ * · capability 空 / deadline 非正 → 页面上会出现一条说不出是什么、也不知道何时截止的行。
838
+ */
839
+ function openTasksIssues(snap) {
840
+ const out = [];
841
+ const rows = snap?.open_tasks;
842
+ if (!Array.isArray(rows))
843
+ return ['open_tasks 缺失 (公开「待接单任务」必须是数组, 没有就给空数组)'];
844
+ const allow = exports.OPEN_TASK_KEYS;
845
+ rows.forEach((r, i) => {
846
+ const at = `open_tasks[${i}]`;
847
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
848
+ out.push(`${at} 不是对象`);
849
+ return;
850
+ }
851
+ const extra = Object.keys(r).filter((k) => !allow.includes(k));
852
+ if (extra.length)
853
+ out.push(`${at} 出现白名单外的键: ${extra.join(', ')} (公开投影只许 ${allow.join('/')})`);
854
+ if (typeof r.capability !== 'string' || !r.capability.trim())
855
+ out.push(`${at}.capability 缺失/为空`);
856
+ if (r.claimed !== false)
857
+ out.push(`${at}.claimed=${JSON.stringify(r.claimed)} —— 公开页只许列**未认领**的公告`);
858
+ if (typeof r.announcementId !== 'string' || r.announcementId.length === 0 || r.announcementId.length > 8) {
859
+ out.push(`${at}.announcementId 必须是 announcementId 的**前 8 位**(1..8 字符), 实得 ${JSON.stringify(r.announcementId)}`);
860
+ }
861
+ if (!Number.isFinite(r.deadline) || r.deadline <= 0)
862
+ out.push(`${at}.deadline 必须是正的 ms 时间戳`);
863
+ for (const k of ['budget', 'currency', 'network']) {
864
+ if (r[k] !== null && typeof r[k] !== 'string')
865
+ out.push(`${at}.${k} 只许字符串或 null (不替公告猜值)`);
866
+ }
867
+ });
868
+ return out;
869
+ }
293
870
  function computeSnapshot(events, opts) {
294
871
  const now = opts.now;
295
872
  const fresh_until = now + exports.PULSE_LIMITS.snapshotTtlMs;
296
873
  if (opts.unavailable) {
874
+ const emptyRows = [];
875
+ const gates = normalizeActivityGates(chain_config_js_1.DEFAULT_CONFIRMATIONS);
297
876
  return {
298
877
  status: 'unavailable',
299
878
  generated_at: now,
@@ -301,8 +880,23 @@ function computeSnapshot(events, opts) {
301
880
  scope: 'observed',
302
881
  scope_label: { zh: '当前节点观察到', en: 'Observed by this node' },
303
882
  totals: { nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0, tasks: 0, tasks_completed: 0, tasks_verified: 0, signatures: 0 },
883
+ totals_scope: {
884
+ source: 'pulse-events', window_ms: exports.PULSE_LIMITS.windowMs,
885
+ label: {
886
+ zh: `只统计本节点 ${exports.PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
887
+ en: `Only pulse events received by this node within the ${exports.PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
888
+ },
889
+ differs_from_activity: false,
890
+ },
304
891
  capabilities: [],
305
892
  recent_activity: [],
893
+ confirmed_activity: emptyRows,
894
+ confirmed_activity_source: 'none',
895
+ activity_totals: summarizeActivityRows(emptyRows, { source: 'none', gates }),
896
+ chain_id_scope: buildChainIdScope(emptyRows),
897
+ // 观察层不可用 ≠ 公告板读不到: 这一支只说明「脉冲事件层」挂了, 待接单任务照实带出来
898
+ // (调用方没给就空数组 —— 不拿空数组冒充「板上没有」: 页面空态说的是「暂未观察到」)
899
+ open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
306
900
  notes: ['观察层暂不可用 — 这不是"网络为空"'],
307
901
  };
308
902
  }
@@ -365,6 +959,39 @@ function computeSnapshot(events, opts) {
365
959
  notes.push('单节点观察: 这是本节点能看到的部分网络, 不是全网精确总量');
366
960
  else
367
961
  notes.push(`多签名来源汇总 (${signedNodes} 个签名节点)`);
962
+ // 活动行来源如实标注 (chain-index = 真链上事实; pulse-events = 降级, 非链上确认)
963
+ const activity = opts.confirmedActivity ?? confirmedActivityFromEvents(window);
964
+ // 同源计数 + 链归属: **输入就是上表那批行**, 所以不可能与行数/链 id 打架
965
+ const activity_totals = summarizeActivityRows(activity.rows, { source: activity.source, gates: activity.gates });
966
+ const chain_id_scope = buildChainIdScope(activity.rows);
967
+ const totals_scope = {
968
+ source: 'pulse-events',
969
+ window_ms: exports.PULSE_LIMITS.windowMs,
970
+ label: {
971
+ zh: `只统计本节点 ${exports.PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
972
+ en: `Only pulse events received by this node within the ${exports.PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
973
+ },
974
+ differs_from_activity: activity_totals.rows > 0 &&
975
+ (tasks.size !== activity_totals.tasks || tasksCompleted.size !== activity_totals.tasks_completed),
976
+ };
977
+ if (activity.source === 'chain-index') {
978
+ notes.push(`confirmed_activity 来自链上索引 (chain-index): ${activity_totals.rows} 行 · ${activity_totals.tasks} 个不同任务 · ` +
979
+ `finality 分布 observed=${activity_totals.by_finality.observed} / confirmed=${activity_totals.by_finality.confirmed} / ` +
980
+ `finalized=${activity_totals.by_finality.finalized} (三档之和 = 行数); 最新在前, 上限 ${exports.CONFIRMED_ACTIVITY_LIMIT} 行; ` +
981
+ `确认门槛 confirmed=${activity.gates.confirmed} · finalized=${activity.gates.finalized}`);
982
+ }
983
+ else if (activity.source === 'pulse-events') {
984
+ notes.push('confirmed_activity 降级为脉冲事件 (pulse-events): 非链上确认 —— chain_id/block/confirmations=0 且 finality=observed');
985
+ }
986
+ // 两套计数口径不同时**必须**解释 —— 页面不许出现「0 个任务」与「N 行任务」并存而不解释
987
+ if (totals_scope.differs_from_activity) {
988
+ notes.push(`口径不同, 不是数据丢失: totals.tasks/tasks_completed/tasks_verified/signatures 只数本节点 ` +
989
+ `${exports.PULSE_LIMITS.windowMs / 3600000}h 窗口内的脉冲事件 (本快照 tasks=${tasks.size} · tasks_completed=${tasksCompleted.size} · ` +
990
+ `tasks_verified=${tasksVerified.size} · signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自链上索引 ` +
991
+ `(全量, 不是 ${exports.PULSE_LIMITS.windowMs / 3600000}h 窗口) —— 同源计数见 activity_totals`);
992
+ }
993
+ if (activity_totals.rows > 0)
994
+ notes.push(chain_id_scope.note.zh);
368
995
  return {
369
996
  status: 'live',
370
997
  generated_at: now,
@@ -383,8 +1010,14 @@ function computeSnapshot(events, opts) {
383
1010
  tasks_verified: tasksVerified.size,
384
1011
  signatures: signatureKeys.size, // 真实签名次数 (只计数)
385
1012
  },
1013
+ totals_scope,
386
1014
  capabilities,
387
1015
  recent_activity: recent,
1016
+ confirmed_activity: activity.rows,
1017
+ confirmed_activity_source: activity.source,
1018
+ activity_totals,
1019
+ chain_id_scope,
1020
+ open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
388
1021
  notes,
389
1022
  };
390
1023
  }
@@ -396,7 +1029,16 @@ async function getNetworkPulse(opts = {}) {
396
1029
  if (!opts.force) {
397
1030
  try {
398
1031
  const cached = JSON.parse(fs.readFileSync(snapshotFile(opts.home), 'utf-8'));
399
- if (cached && Number.isFinite(cached.generated_at) && cached.generated_at + exports.PULSE_LIMITS.snapshotTtlMs > now)
1032
+ // 老版本写的缓存缺冻结字段 `confirmed_activity` (或后来的 totals_scope / activity_totals /
1033
+ // chain_id_scope) → 视为**过期形状**, 重算 (不能把缺字段的快照发出去 ——
1034
+ // 缺 activity_totals 就会在页面上重新变成"两个数字打架"
1035
+ const shapeOk = Array.isArray(cached?.confirmed_activity)
1036
+ && typeof cached?.confirmed_activity_source === 'string'
1037
+ && !!cached?.totals_scope
1038
+ && !!cached?.activity_totals
1039
+ && !!cached?.chain_id_scope
1040
+ && Array.isArray(cached?.open_tasks); // 老缓存没有待接单任务 → 过期形状, 重算
1041
+ if (shapeOk && cached && Number.isFinite(cached.generated_at) && cached.generated_at + exports.PULSE_LIMITS.snapshotTtlMs > now)
400
1042
  return cached;
401
1043
  }
402
1044
  catch { /* 无缓存 */ }
@@ -408,7 +1050,10 @@ async function getNetworkPulse(opts = {}) {
408
1050
  catch (e) {
409
1051
  return computeSnapshot([], { now, unavailable: true });
410
1052
  }
411
- const snap = computeSnapshot(events, { now });
1053
+ // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
1054
+ const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
1055
+ // 待接单任务: 只读本机公告板目录 (未认领且未过期), 投影成白名单 7 字段 —— 与脉冲事件层无关
1056
+ const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now) });
412
1057
  try {
413
1058
  fs.mkdirSync((0, exports.pulseDir)(opts.home), { recursive: true });
414
1059
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');
@@ -501,4 +1146,49 @@ function assertNoPrivateFields(value, at = '$') {
501
1146
  walk(value, at);
502
1147
  return issues;
503
1148
  }
1149
+ /**
1150
+ * 允许出现 0x 长 hex 的键 (**唯一白名单**, 2026-09-23 leo 拍板; 同日收窄): 交易哈希 / escrow 合约地址 /
1151
+ * 交易浏览器链接 —— 都是公开链上事实。除了这 3 个键, 别处出现 0x 长 hex (尤其 EOA 钱包地址) 一律算泄露。
1152
+ * 注意 `contract` **只在数据里**(供索引/诊断), 页面不渲染合约地址也不给合约链接 ⇒ 没有 `explorer_contract` 这个键。
1153
+ */
1154
+ exports.PUBLIC_HEX_KEYS = ['tx_hash', 'contract', 'explorer_tx'];
1155
+ /**
1156
+ * 0x 长 hex 越界审计 (**粒度比 `assertNoPrivateFields` 更细**: 那个只看键名, 这个看值形态)。
1157
+ * · 白名单键下: 形状必须精确 —— `tx_hash` = 0x+64 hex; `contract` = 0x+40 hex;
1158
+ * `explorer_tx` = 我们造的**交易**浏览器链接形状 (`EXPLORER_URL_RE`)
1159
+ * · **其他任何键**的值里出现 `0x` + 40/64 位 hex → 报出来 (买方·卖方 EOA、taskKey 原文、
1160
+ * args 里的地址都属这一类); `explorer_contract` 之类不在白名单的键**同样**按泄露处理
1161
+ * 返回越界说明列表 (空 = 干净)。这条尺子**只收紧不放松**: 老口径下「任何 0x 长 hex 都不许」
1162
+ * 现在等价于「除了这 3 个白名单键, 任何 0x 长 hex 都不许」。
1163
+ */
1164
+ function auditPublicHexLeaks(value, at = '$') {
1165
+ const issues = [];
1166
+ const allow = exports.PUBLIC_HEX_KEYS;
1167
+ const walk = (v, p) => {
1168
+ if (Array.isArray(v))
1169
+ return v.forEach((x, i) => walk(x, `${p}[${i}]`));
1170
+ if (v && typeof v === 'object') {
1171
+ for (const [k, val] of Object.entries(v))
1172
+ walk(val, `${p}.${k}`);
1173
+ return;
1174
+ }
1175
+ if (typeof v !== 'string')
1176
+ return;
1177
+ const s = v;
1178
+ const key = p.slice(p.lastIndexOf('.') + 1);
1179
+ // 白名单键: 形状必须精确 —— 不管值长短都查 (拿短串/怪串冒充同样拒绝)
1180
+ if (allow.includes(key)) {
1181
+ const ok = (key === 'tx_hash' && explorer_js_1.TX_HASH_RE.test(s))
1182
+ || (key === 'contract' && explorer_js_1.ADDRESS_RE.test(s))
1183
+ || (key === 'explorer_tx' && explorer_js_1.EXPLORER_URL_RE.test(s));
1184
+ if (!ok)
1185
+ issues.push(`${p} = 白名单键 ${key} 下的形状不对 (只认精确形状, 不认"含 0x 就算")`);
1186
+ return;
1187
+ }
1188
+ if (/0x[0-9a-fA-F]{40}/.test(s))
1189
+ issues.push(`${p} = 0x 长 hex (只许出现在 ${allow.join('/')} 下) → 按泄露处理`);
1190
+ };
1191
+ walk(value, at);
1192
+ return issues;
1193
+ }
504
1194
  //# sourceMappingURL=network-pulse.js.map