@bolloon/bolloon-agent 0.4.32 → 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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * explorer.ts — 区块浏览器映射 (chainId → 公网浏览器) + **交易**链接构造
3
+ *
4
+ * 只回答一件事: **这条链有没有已知的公网浏览器**。
5
+ * · 有 → `https://<explorer>/tx/0x…` (公开可核验的链上事实)
6
+ * · 认不出 / 本机隔离开发链 (31337) → **null** —— 没有公网浏览器就**不给链接**,
7
+ * 绝不编造 `href="#"`、死链或猜一个域名 (宁缺勿错)。
8
+ *
9
+ * 隐私红线 (2026-09-23 leo 拍板; 同日收窄):
10
+ * · 允许出现在公开快照里的 0x 字符串**只有两种**: 交易哈希 (0x+64 hex) 与 escrow **合约**地址
11
+ * (0x+40 hex) —— 它们本来就是公开链上事实。
12
+ * · 但**页面只可点交易**: escrow 合约地址保留在行数据里供索引/诊断, **不渲染、不生成合约链接**
13
+ * (所以本文件**没有** address 链接构造器 —— 少一个能凭空造出合约链接的入口)。
14
+ * · EOA / 钱包地址 (买方·卖方·payTo 之类)、taskKey 原文、taskId、args 里的地址、DID、
15
+ * peer IP / multiaddr、私钥 —— **一个都不许**经这里或别处进快照。
16
+ * · 本文件只构造 URL, 不读盘不发 RPC, 也从不接受「调用方说它是合约地址」的自我声明。
17
+ */
18
+ /** chainId → 公网浏览器 base URL (白名单; 认不出的一律没有) */
19
+ export const EXPLORER_BY_CHAIN = Object.freeze({
20
+ 8453: 'https://basescan.org', // Base 主网
21
+ 84532: 'https://sepolia.basescan.org', // Base Sepolia 测试网
22
+ 1: 'https://etherscan.io', // Ethereum 主网
23
+ 11155111: 'https://sepolia.etherscan.io', // Sepolia 测试网
24
+ });
25
+ /** 交易哈希: 小写 0x + 64 位十六进制 (词法唯一形状) */
26
+ export const TX_HASH_RE = /^0x[0-9a-f]{64}$/;
27
+ /** 地址: 小写 0x + 40 位十六进制 (合约地址同样长这样 —— 是**谁**由调用方的白名单决定) */
28
+ export const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
29
+ /**
30
+ * 浏览器**交易**链接形状 (反向校验用): https + 已知浏览器域名 + `/tx/0x64hex`。
31
+ * 给守卫 / 单测一把「这不是我们造的链接」的尺子 —— 端口 / 凭据 / 别的路径 (含 `/address/`) 一律不认。
32
+ */
33
+ export const EXPLORER_URL_RE = /^https:\/\/(?:[a-z0-9-]+\.)*(?:basescan\.org|etherscan\.io)\/tx\/0x[0-9a-f]{64}$/;
34
+ /** chainId 有没有已知公网浏览器 → base URL; 认不出 (含本机 31337) → null */
35
+ export function explorerBaseUrl(chainId) {
36
+ const id = Number(chainId);
37
+ if (!Number.isInteger(id) || id < 0)
38
+ return null;
39
+ return EXPLORER_BY_CHAIN[id] ?? null;
40
+ }
41
+ /** 交易链接; 链没浏览器 / 哈希非法 → null (调用方**不要**填 null 之外的占位) */
42
+ export function explorerTxUrl(chainId, txHash) {
43
+ const base = explorerBaseUrl(chainId);
44
+ const h = String(txHash ?? '').toLowerCase();
45
+ if (!base || !TX_HASH_RE.test(h))
46
+ return null;
47
+ return `${base}/tx/${h}`;
48
+ }
49
+ /** 这个字符串是不是我们亲手造的那种**交易**浏览器链接 (守卫 / 单测用) */
50
+ export function isExplorerUrl(url) {
51
+ return typeof url === 'string' && EXPLORER_URL_RE.test(url);
52
+ }
@@ -9,6 +9,7 @@
9
9
  * · chain-state-store — 落盘 / 重启恢复 / 重组对账
10
10
  * · chain-indexer — P5: v2 事件索引器 (分页扫 / 增量 / 去重 / 重组回退 / 全量重建)
11
11
  * · chain-index-query — P5: 只读查询 (时间线 / cursor 增量 / 统计 / 索引高度)
12
+ * · explorer — chainId → 公网区块浏览器 (**只造交易链接**: 合约地址不页面化; 认不出的链不给链接)
12
13
  */
13
14
  export * from './chain-config.js';
14
15
  export * from './escrow-client.js';
@@ -18,3 +19,4 @@ export * from './chain-state-store.js';
18
19
  export * from './onchain-trade.js';
19
20
  export * from './chain-indexer.js';
20
21
  export * from './chain-index-query.js';
22
+ export * from './explorer.js';
@@ -116,7 +116,7 @@ function emptyStep(stage, partial) {
116
116
  async function judge(req, taskKey, txHash, expect, recorded) {
117
117
  const gate = req.gate || 'confirmed';
118
118
  const verify = req.verifyOnChain || (await defaultVerifyPaymentOnChain());
119
- return verify({
119
+ const run = () => verify({
120
120
  txHash,
121
121
  chainSettlement: {
122
122
  verifier: createChainSettlementVerifier(req.client, { gate }),
@@ -125,6 +125,20 @@ async function judge(req, taskKey, txHash, expect, recorded) {
125
125
  recorded,
126
126
  },
127
127
  });
128
+ let v = await run();
129
+ // ★ 未定/读偏的有界重读: 交易已经发出去了 (钱/证明在链上), 一次读偏不该定案 —— 只重读, 不重发。
130
+ // 两种读偏形态都要覆盖:
131
+ // · 'pending' —— eth_blockNumber 还停在前一个块 → 确认数数成 0
132
+ // · 'event_mismatch' —— 事件读到了 (receipt 按 txHash 取, 一定新), 但合约状态 eth_call 读回旧值
133
+ // 真 mismatch (event/taskKey 真对不上) 重读 3 次后结论不变 → 不会把错的判成对的。
134
+ const attempts = req.settleRetryAttempts ?? 3;
135
+ const delayMs = req.settleRetryDelayMs ?? 1500;
136
+ for (let i = 1; i <= attempts && !v.chainSettled && v.rpcAvailable !== false
137
+ && (v.status === 'pending' || v.status === 'event_mismatch'); i++) {
138
+ await new Promise((r) => setTimeout(r, delayMs));
139
+ v = await run();
140
+ }
141
+ return v;
128
142
  }
129
143
  /** 判定落盘 (P3 chain-state-store) */
130
144
  async function persist(req, method, taskKey, txHash, amountAtomic, verdict) {
@@ -10,10 +10,19 @@
10
10
  * · 快照过期 → `stale` (不许伪装实时); 观察层不可用 → `unavailable`
11
11
  * · 类别小于隐私阈值 → 合并进 other
12
12
  * · 事件数 / 时间窗 / capability 数都有上限
13
- * · **冻结形状 `confirmed_activity`** (2026-09-22): 公开页面要能列出「哪个任务 · 什么状态 ·
14
- * 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时退回脉冲事件并
15
- * 用 `confirmed_activity_source` 标注; 标识一律 sha256 短写 (前 8 位), 绝不落 taskKey /
16
- * taskId / txHash 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
13
+ * · **冻结形状 `confirmed_activity`** (2026-09-22; 2026-09-23 加链上可核验字段): 公开页面要能列出
14
+ * 「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时
15
+ * 退回脉冲事件并用 `confirmed_activity_source` 标注; 任务标识一律 sha256 短写 (前 8 位), 绝不落
16
+ * taskKey / taskId 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
17
+ * 2026-09-23 隐私决定**变更一处** (只放开公开链上事实, 别的一律不变): 交易哈希 (`tx_hash`) 与
18
+ * escrow **合约**地址 (`contract`, 只给索引/诊断) 允许出现; 页面可点的只有**交易**链接 `explorer_tx`
19
+ * (仅当该链有已知公网浏览器)。EOA / 钱包地址 (买方·卖方)、taskKey 原文、taskId、args 里的地址、
20
+ * DID、peer IP/multiaddr、私钥 —— 仍然一个都不许出现; 老字段 `tx` (sha256 短写) 保留不删。
21
+ * · **冻结形状 `open_tasks[]`** (2026-09-23): 公开页要能列「本节点公告板上哪些任务还没被接单」。
22
+ * 数据源 = `~/.bolloon/tasks/board/*.json` 里**未认领且未过期**的公告, 每行只含白名单 7 个字段:
23
+ * `capability` / `budget`(原子单位) / `currency` / `network` / `deadline` / `claimed`(恒 false) /
24
+ * `announcementId`(前 8 位)。**任务正文与它的摘要/预览、买方 DID 与公钥、认领者、公告签名
25
+ * 一律不进公开投影** —— 行由 `buildOpenTaskRow` 逐字段拷贝构造, 不做对象展平。
17
26
  *
18
27
  * 借鉴 (只借产品与工程思想, 不借 Go/Postgres/API 项目):
19
28
  * EigenFlux 的 "注册总量 / 当前活跃 / 最近出现" 时间窗统计、服务端生成匿名活动文本、
@@ -25,6 +34,11 @@ import * as path from 'path';
25
34
  import * as crypto from 'crypto';
26
35
  // 确认数门槛来自 chain-config (单一实现; 默认 confirmed=1 / finalized=12) —— 只借常量, 不借 RPC
27
36
  import { DEFAULT_CONFIRMATIONS, LOCAL_DEV_CHAIN_ID } from './chain/chain-config.js';
37
+ // 区块浏览器映射 (chainId → 公网浏览器); 认不出的链没有链接 —— 宁缺勿错, 不编死链
38
+ import { ADDRESS_RE, EXPLORER_URL_RE, TX_HASH_RE, explorerTxUrl } from './chain/explorer.js';
39
+ // 「待接单任务」的数据源 = 本机公告板目录 (~/.bolloon/tasks/board)。只借**路径常量**,
40
+ // 公告的解析/发布/接单一律留在 task-board.ts (这里不做第二套实现)。
41
+ import { boardDir, REMOTE_CLAIMS_FILE } from './task-board.js';
28
42
  // ── 常量 (上限 + 时间边界) ─────────────────────────────────────────────────
29
43
  export const PULSE_LIMITS = {
30
44
  /** 最多留多少条事件 (超出丢最旧) */
@@ -56,6 +70,8 @@ export const NETWORK_EVENT_TYPES = [
56
70
  // 经济事件 (P6 扩展; 原五类保持兼容, 老节点发来的事件仍被接受)
57
71
  'task_posted', 'task_accepted', 'task_completed', 'trade_settled', 'trade_verified',
58
72
  'wallet_signed',
73
+ // 2026-09-23 (C1): 任务对外发布 (公告板 publish) —— 只记"有节点公告了一个待接单任务", 正文/ID 原文不落盘
74
+ 'task_announced',
59
75
  ];
60
76
  // ── 存储 ────────────────────────────────────────────────────────────────────
61
77
  const home = (h) => h || process.env.HOME || os.homedir() || '/tmp';
@@ -167,6 +183,7 @@ export function renderActivityText(kind) {
167
183
  case 'capability_announced': return { zh: '有节点公开声明了新能力', en: 'A node announced a capability' };
168
184
  case 'peer_connected': return { zh: '两个节点建立了连接', en: 'Two nodes connected' };
169
185
  case 'delegation_completed': return { zh: '一次能力委派完成', en: 'A delegation completed' };
186
+ case 'task_announced': return { zh: '有节点公告了一个待接单任务', en: 'A node announced an open task' };
170
187
  default: return { zh: '网络有活动', en: 'Network activity' };
171
188
  }
172
189
  }
@@ -216,6 +233,82 @@ export function readAgentSites(h) {
216
233
  return [];
217
234
  }
218
235
  }
236
+ /** 上限: 公开页最多列几条待接单任务 (超出按最近截止排序取前 N) */
237
+ export const MAX_OPEN_TASKS = 25;
238
+ /** 公开页只列这条公告的这 7 个字段; 其余(正文/买方/摘要/签名)一律不出这个函数 */
239
+ function buildOpenTaskRow(a) {
240
+ const capability = String(a?.capability ?? '').trim();
241
+ const id = String(a?.announcementId ?? '').trim();
242
+ const deadline = Number(a?.deadline);
243
+ if (!capability || !id || !Number.isFinite(deadline) || deadline <= 0)
244
+ return null;
245
+ const b = a?.budget && typeof a.budget === 'object' ? a.budget : null;
246
+ const maxAmount = b ? String(b.maxAmount ?? '').trim() : '';
247
+ const currency = b ? String(b.currency ?? '').trim() : '';
248
+ const network = b ? String(b.network ?? '').trim() : '';
249
+ return {
250
+ capability,
251
+ budget: maxAmount || null,
252
+ currency: currency || null,
253
+ network: network || null,
254
+ deadline,
255
+ claimed: (Array.isArray(a?.claims) ? a.claims.length : 0) > 0,
256
+ announcementId: id.slice(0, 8), // 前 8 位 (按字面, 不重新格式化)
257
+ };
258
+ }
259
+ /**
260
+ * 读本节点公告板上**未认领且未过期**的公告, 投影成公开「待接单任务」行。
261
+ *
262
+ * 三条筛选 (缺一个都会把不该上的东西露出来):
263
+ * · `protocol= bolloon-task/1` + `kind= task_announcement` (同目录还有 `remote-claims.json`,
264
+ * 它是个数组 —— 不按 kind 判会把认领台账当公告解析)
265
+ * · `status === 'open'` (已认领 / 已取消 → 不是「待接单」)
266
+ * · `deadline > now` (过期只是不能再接单, 文件不删 → 必须在这里判掉)
267
+ *
268
+ * 读不到目录 / 坏 JSON / 缺字段 → 该条跳过 (一条都不编); 整个目录不可读 → 空数组 + 不抛。
269
+ */
270
+ export function readOpenTasks(h, now = Date.now()) {
271
+ const out = [];
272
+ try {
273
+ const dir = boardDir(h);
274
+ let names;
275
+ try {
276
+ names = fs.readdirSync(dir);
277
+ }
278
+ catch {
279
+ return [];
280
+ }
281
+ for (const name of names.slice().sort()) {
282
+ if (!name.endsWith('.json') || name === `${REMOTE_CLAIMS_FILE}`)
283
+ continue;
284
+ let raw;
285
+ try {
286
+ raw = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
287
+ }
288
+ catch {
289
+ continue;
290
+ }
291
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
292
+ continue; // 认领台账是数组 → 这里就出局
293
+ if (raw.protocol !== 'bolloon-task/1' || raw.kind !== 'task_announcement')
294
+ continue;
295
+ if (String(raw.status) !== 'open')
296
+ continue;
297
+ const deadline = Number(raw.deadline);
298
+ if (!Number.isFinite(deadline) || deadline <= now)
299
+ continue;
300
+ const row = buildOpenTaskRow(raw);
301
+ if (row)
302
+ out.push(row);
303
+ }
304
+ }
305
+ catch {
306
+ return [];
307
+ }
308
+ // 稳定排序: 快到期在前 (读者最需要知道的先看); 同截止按短 id 定序 (排序必须确定, 否则快照每次都"变")
309
+ out.sort((x, y) => (x.deadline - y.deadline) || (x.announcementId < y.announcementId ? -1 : x.announcementId > y.announcementId ? 1 : 0));
310
+ return out.slice(0, MAX_OPEN_TASKS);
311
+ }
219
312
  /**
220
313
  * 把**真实交易活动**投影成脉冲事件 (由交易写路径调用, fire-and-forget)。
221
314
  * 只按事实发: 交付 → task_completed · 真验真 → trade_verified · **链上口径**结算 → trade_settled。
@@ -248,10 +341,18 @@ export async function emitTradePulse(before, after, h) {
248
341
  // ── 冻结形状: confirmed_activity (真实智能体任务 / 链上活动行) ────────────────
249
342
  //
250
343
  // 公开网页要能列出「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字。这块就是那批行。
251
- // 形状**冻结** (字段名与取值域不许改), 内容一律**匿名短写**:
252
- // · task = `sha256:` + sha256(域标签|taskKey) 的前 8 位十六进制 —— 绝不落 taskKey / taskId 原文
253
- // · tx = `sha256:` + sha256(域标签|txHash) 的前 8 位十六进制 —— 绝不落 txHash 原文
254
- // · 不出现 DID / peerId / IP / 完整钱包地址 / 任务正文 —— `assertNoPrivateFields` 兜底
344
+ // 形状**冻结** (老字段名与取值域不许改), 内容按用途分两类:
345
+ // · **匿名短写** (老口径, 保留不删): task = `sha256:` + sha256(域标签|taskKey) 的前 8 位;
346
+ // tx = `sha256:` + sha256(域标签|txHash) 的前 8 位 —— 绝不落 taskKey / taskId 原文
347
+ // · **公开链上事实** (2026-09-23 新增; 同日按 leo 拍板收窄): tx_hash (真交易哈希) /
348
+ // explorer_tx (交易浏览器链接, 仅当该链有已知公网浏览器) —— 网页上的行因此**可核验地**跳回区块
349
+ // 浏览器; contract (escrow 合约地址) 仍保留在行里**供索引/诊断**, 但**不上页面、不生成合约链接**
350
+ // 隐私红线 (违反即失败, **只放开上面那两种 0x 字符串**):
351
+ // · 允许: 交易哈希 (0x+64 hex) 与 escrow 合约地址 (0x+40 hex), 且只出现在 `tx_hash`/`contract`/`explorer_tx` 这 3 个键下
352
+ // · 禁止: EOA / 钱包地址 (买方/卖方/payTo)、taskKey 原文、taskId、args 里的地址、DID、peerId /
353
+ // IP / multiaddr、私钥 —— `assertNoPrivateFields`(键名) + `auditPublicHexLeaks`(越界 0x) 兜底
354
+ // · 拿不到链配置里的 escrow 地址, 或这条索引记录的 address 不是它 → **不填** contract
355
+ // (宁缺勿错: 宁可少一个字段, 也不把可能是别人的地址写成合约)
255
356
  // 数据源优先级: ① P5 链上索引 (真链上事实) → ② 索引不可用 → 退回脉冲事件, 并在快照里用
256
357
  // `confirmed_activity_source` 如实标注 (chain-index / pulse-events / none)。
257
358
  // 确认数口径走 chain-config 门槛 (默认 confirmed=1 / finalized=12): 够不着门槛的行只报
@@ -335,6 +436,8 @@ function activityLimit(limit) {
335
436
  * · 不认识的事件名 / 非法 taskKey·txHash / 缺观察时间 → 跳过 (不猜、不臆造)
336
437
  * · confirmations: 有 headBlock → head - block + 1 复算 (无 RPC, 用索引快照里的 head); 否则用记录值
337
438
  * · finality: 按 chain-config 门槛复算 (不够 → observed)
439
+ * · 新增: `tx_hash` (真交易哈希) 恒给; `contract` 仅当 opts.escrowAddress 合法且 === 本条 address;
440
+ * `explorer_tx` 仅当该 chainId 有**已知公网浏览器** (否则键整个不存在, 不填 null)
338
441
  * · 最新在前 (blockNumber desc, logIndex desc), 上限 25 行
339
442
  */
340
443
  export function buildConfirmedActivityFromIndex(entries, opts = {}) {
@@ -342,6 +445,9 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
342
445
  const limit = activityLimit(opts.limit);
343
446
  const chainId = Number.isInteger(Number(opts.chainId)) && Number(opts.chainId) >= 0 ? Number(opts.chainId) : 0;
344
447
  const head = Number.isInteger(Number(opts.headBlock)) && Number(opts.headBlock) >= 0 ? Number(opts.headBlock) : null;
448
+ // escrow 合约地址白名单 (唯一权威): 只有索引文件自己记的这个地址算数, 别的地址一律不当合约
449
+ const escrow = typeof opts.escrowAddress === 'string' ? opts.escrowAddress.toLowerCase() : '';
450
+ const escrowOk = ADDRESS_RE.test(escrow) ? escrow : '';
345
451
  const out = [];
346
452
  for (const e of Array.isArray(entries) ? entries : []) {
347
453
  if (!e || e.suspect === true)
@@ -362,6 +468,12 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
362
468
  if (!at)
363
469
  continue; // 没观察时间 → 不臆造时间, 不成行
364
470
  const confirmations = head == null ? Math.max(0, Number(e.confirmations) || 0) : Math.max(0, head - block + 1);
471
+ // 合约地址: 只有「本条记录的 address 就是链配置里的 escrow 地址」才填 (宁缺勿错)
472
+ const entryAddr = String(e.address || '').toLowerCase();
473
+ const contract = escrowOk && entryAddr === escrowOk ? escrowOk : '';
474
+ // 浏览器链接: 该链没有已知公网浏览器 → 返回 null → 键整个不出现 (不编 href="#")
475
+ // 只有**交易**链接 (explorer_tx); 合约地址只在数据里 (contract), 不生成链接 (2026-09-23 收窄)
476
+ const explorerTx = explorerTxUrl(chainId, txHash);
365
477
  out.push({
366
478
  block, logIndex,
367
479
  row: {
@@ -374,6 +486,10 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
374
486
  confirmations,
375
487
  finality: finalityFromConfirmations(confirmations, gates),
376
488
  at,
489
+ // 公开链上事实 (白名单键; 老字段 tx 已在上方保留)
490
+ tx_hash: txHash,
491
+ ...(contract ? { contract } : {}),
492
+ ...(explorerTx ? { explorer_tx: explorerTx } : {}),
377
493
  },
378
494
  });
379
495
  }
@@ -471,6 +587,7 @@ export async function resolveConfirmedActivity(q) {
471
587
  headBlock: file?.headBlock,
472
588
  chainId: file?.chainId,
473
589
  limit,
590
+ escrowAddress: file?.escrowAddress, // 合约地址白名单 = 索引文件自己的部署身份
474
591
  });
475
592
  if (rows.length > 0)
476
593
  return { source: 'chain-index', rows, gates };
@@ -609,6 +726,8 @@ export function buildChainIdScope(rows) {
609
726
  * ④ `activity_totals.source` === `confirmed_activity_source` (两块来源标注必须一致)
610
727
  * ⑤ 行里有任务 而 `totals.tasks === 0` 时, 必须有口径说明 note
611
728
  * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
729
+ * ⑥ 0x 长 hex 越界 (2026-09-23): 只许出现在 tx_hash/contract/explorer_* 四个白名单键下 ——
730
+ * EOA 地址跑到别的键 (或白名单键形状不对) 就拒绝导出
612
731
  */
613
732
  export function snapshotConsistencyIssues(snap) {
614
733
  const issues = [];
@@ -635,8 +754,50 @@ export function snapshotConsistencyIssues(snap) {
635
754
  if (!explained)
636
755
  issues.push(`totals.tasks=0 与 ${rows.length} 行任务并存, 却没有口径说明 note`);
637
756
  }
757
+ issues.push(...auditPublicHexLeaks(snap)); // ⑥ 0x 长 hex 越界 → 拒绝导出 (不静默放行)
758
+ issues.push(...openTasksIssues(snap)); // ⑦ 待接单任务的字段白名单与「只含未认领」不变式
638
759
  return issues;
639
760
  }
761
+ /** open_tasks 行的字段白名单 (与 `OpenTaskRow` 逐字一致; 多一个键 = 可能把正文/身份带出去了) */
762
+ export const OPEN_TASK_KEYS = ['capability', 'budget', 'currency', 'network', 'deadline', 'claimed', 'announcementId'];
763
+ /**
764
+ * 待接单任务行的自检 (空 = 通过)。这不是格式洁癖, 每条都对着一个真泄漏路径:
765
+ * · 键集 ≠ 白名单 → 有人把 instruction / buyerDid / claims 之类**展平**进公开投影了;
766
+ * · `claimed !== false` → 「只导出未认领」这个筛选坏了 (页面会把已接单的当待接单);
767
+ * · `announcementId` 长于 8 位 → 短写丢了, 原始公告 id 上公开页 (页面上的 id 会被读者当完整 id 用);
768
+ * · capability 空 / deadline 非正 → 页面上会出现一条说不出是什么、也不知道何时截止的行。
769
+ */
770
+ export function openTasksIssues(snap) {
771
+ const out = [];
772
+ const rows = snap?.open_tasks;
773
+ if (!Array.isArray(rows))
774
+ return ['open_tasks 缺失 (公开「待接单任务」必须是数组, 没有就给空数组)'];
775
+ const allow = OPEN_TASK_KEYS;
776
+ rows.forEach((r, i) => {
777
+ const at = `open_tasks[${i}]`;
778
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
779
+ out.push(`${at} 不是对象`);
780
+ return;
781
+ }
782
+ const extra = Object.keys(r).filter((k) => !allow.includes(k));
783
+ if (extra.length)
784
+ out.push(`${at} 出现白名单外的键: ${extra.join(', ')} (公开投影只许 ${allow.join('/')})`);
785
+ if (typeof r.capability !== 'string' || !r.capability.trim())
786
+ out.push(`${at}.capability 缺失/为空`);
787
+ if (r.claimed !== false)
788
+ out.push(`${at}.claimed=${JSON.stringify(r.claimed)} —— 公开页只许列**未认领**的公告`);
789
+ if (typeof r.announcementId !== 'string' || r.announcementId.length === 0 || r.announcementId.length > 8) {
790
+ out.push(`${at}.announcementId 必须是 announcementId 的**前 8 位**(1..8 字符), 实得 ${JSON.stringify(r.announcementId)}`);
791
+ }
792
+ if (!Number.isFinite(r.deadline) || r.deadline <= 0)
793
+ out.push(`${at}.deadline 必须是正的 ms 时间戳`);
794
+ for (const k of ['budget', 'currency', 'network']) {
795
+ if (r[k] !== null && typeof r[k] !== 'string')
796
+ out.push(`${at}.${k} 只许字符串或 null (不替公告猜值)`);
797
+ }
798
+ });
799
+ return out;
800
+ }
640
801
  export function computeSnapshot(events, opts) {
641
802
  const now = opts.now;
642
803
  const fresh_until = now + PULSE_LIMITS.snapshotTtlMs;
@@ -664,6 +825,9 @@ export function computeSnapshot(events, opts) {
664
825
  confirmed_activity_source: 'none',
665
826
  activity_totals: summarizeActivityRows(emptyRows, { source: 'none', gates }),
666
827
  chain_id_scope: buildChainIdScope(emptyRows),
828
+ // 观察层不可用 ≠ 公告板读不到: 这一支只说明「脉冲事件层」挂了, 待接单任务照实带出来
829
+ // (调用方没给就空数组 —— 不拿空数组冒充「板上没有」: 页面空态说的是「暂未观察到」)
830
+ open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
667
831
  notes: ['观察层暂不可用 — 这不是"网络为空"'],
668
832
  };
669
833
  }
@@ -784,6 +948,7 @@ export function computeSnapshot(events, opts) {
784
948
  confirmed_activity_source: activity.source,
785
949
  activity_totals,
786
950
  chain_id_scope,
951
+ open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
787
952
  notes,
788
953
  };
789
954
  }
@@ -802,7 +967,8 @@ export async function getNetworkPulse(opts = {}) {
802
967
  && typeof cached?.confirmed_activity_source === 'string'
803
968
  && !!cached?.totals_scope
804
969
  && !!cached?.activity_totals
805
- && !!cached?.chain_id_scope;
970
+ && !!cached?.chain_id_scope
971
+ && Array.isArray(cached?.open_tasks); // 老缓存没有待接单任务 → 过期形状, 重算
806
972
  if (shapeOk && cached && Number.isFinite(cached.generated_at) && cached.generated_at + PULSE_LIMITS.snapshotTtlMs > now)
807
973
  return cached;
808
974
  }
@@ -817,7 +983,8 @@ export async function getNetworkPulse(opts = {}) {
817
983
  }
818
984
  // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
819
985
  const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
820
- const snap = computeSnapshot(events, { now, confirmedActivity });
986
+ // 待接单任务: 只读本机公告板目录 (未认领且未过期), 投影成白名单 7 字段 —— 与脉冲事件层无关
987
+ const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now) });
821
988
  try {
822
989
  fs.mkdirSync(pulseDir(opts.home), { recursive: true });
823
990
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');
@@ -910,3 +1077,48 @@ export function assertNoPrivateFields(value, at = '$') {
910
1077
  walk(value, at);
911
1078
  return issues;
912
1079
  }
1080
+ /**
1081
+ * 允许出现 0x 长 hex 的键 (**唯一白名单**, 2026-09-23 leo 拍板; 同日收窄): 交易哈希 / escrow 合约地址 /
1082
+ * 交易浏览器链接 —— 都是公开链上事实。除了这 3 个键, 别处出现 0x 长 hex (尤其 EOA 钱包地址) 一律算泄露。
1083
+ * 注意 `contract` **只在数据里**(供索引/诊断), 页面不渲染合约地址也不给合约链接 ⇒ 没有 `explorer_contract` 这个键。
1084
+ */
1085
+ export const PUBLIC_HEX_KEYS = ['tx_hash', 'contract', 'explorer_tx'];
1086
+ /**
1087
+ * 0x 长 hex 越界审计 (**粒度比 `assertNoPrivateFields` 更细**: 那个只看键名, 这个看值形态)。
1088
+ * · 白名单键下: 形状必须精确 —— `tx_hash` = 0x+64 hex; `contract` = 0x+40 hex;
1089
+ * `explorer_tx` = 我们造的**交易**浏览器链接形状 (`EXPLORER_URL_RE`)
1090
+ * · **其他任何键**的值里出现 `0x` + 40/64 位 hex → 报出来 (买方·卖方 EOA、taskKey 原文、
1091
+ * args 里的地址都属这一类); `explorer_contract` 之类不在白名单的键**同样**按泄露处理
1092
+ * 返回越界说明列表 (空 = 干净)。这条尺子**只收紧不放松**: 老口径下「任何 0x 长 hex 都不许」
1093
+ * 现在等价于「除了这 3 个白名单键, 任何 0x 长 hex 都不许」。
1094
+ */
1095
+ export function auditPublicHexLeaks(value, at = '$') {
1096
+ const issues = [];
1097
+ const allow = PUBLIC_HEX_KEYS;
1098
+ const walk = (v, p) => {
1099
+ if (Array.isArray(v))
1100
+ return v.forEach((x, i) => walk(x, `${p}[${i}]`));
1101
+ if (v && typeof v === 'object') {
1102
+ for (const [k, val] of Object.entries(v))
1103
+ walk(val, `${p}.${k}`);
1104
+ return;
1105
+ }
1106
+ if (typeof v !== 'string')
1107
+ return;
1108
+ const s = v;
1109
+ const key = p.slice(p.lastIndexOf('.') + 1);
1110
+ // 白名单键: 形状必须精确 —— 不管值长短都查 (拿短串/怪串冒充同样拒绝)
1111
+ if (allow.includes(key)) {
1112
+ const ok = (key === 'tx_hash' && TX_HASH_RE.test(s))
1113
+ || (key === 'contract' && ADDRESS_RE.test(s))
1114
+ || (key === 'explorer_tx' && EXPLORER_URL_RE.test(s));
1115
+ if (!ok)
1116
+ issues.push(`${p} = 白名单键 ${key} 下的形状不对 (只认精确形状, 不认"含 0x 就算")`);
1117
+ return;
1118
+ }
1119
+ if (/0x[0-9a-fA-F]{40}/.test(s))
1120
+ issues.push(`${p} = 0x 长 hex (只许出现在 ${allow.join('/')} 下) → 按泄露处理`);
1121
+ };
1122
+ walk(value, at);
1123
+ return issues;
1124
+ }
@@ -24,7 +24,7 @@ import { resolveTaskBudget } from './task-budget.js';
24
24
  import { adviseResource } from './resource-advisor.js';
25
25
  import { defaultRequestId, installBundle, deriveSkillInput, extractSources, extractConclusion } from './task-runner.js';
26
26
  import { buildReportCard, renderReportCard } from './report-card.js';
27
- import { loadResourceContract, executeContractSkill, validateResourceOutput, verifyInstallFidelity, } from '../x402/resource-contract.js';
27
+ import { loadResourceContract, executeContractSkill, validateResourceOutput, validateResourceInput, verifyInstallFidelity, } from '../x402/resource-contract.js';
28
28
  import { beginTransaction, readTransaction, updateTransaction } from '../x402/transaction-store.js';
29
29
  import { writeDeliveryContent } from '../x402/settlement-state.js';
30
30
  import { computeContentHash } from '../x402/paid-info-protocol.js';
@@ -88,6 +88,23 @@ export async function runTaskOnchain(opts) {
88
88
  };
89
89
  }
90
90
  mark('acquire', `选中 ${chosen.name} (${(chosen.why || []).slice(0, 2).join(' / ') || '名字匹配'})`);
91
+ // ②.5 输入预检 (不花钱的前置门): 输入建不出来/不合 inputSchema → **绝不先注资**
92
+ // 教训: 先注资再执行, 执行不达标 → 钱卡在托管里 (只能 dispute+refund 才拿得回)。
93
+ // 契约的 inputSchema 在这里就能判, 所以把这道门放在任何链上写操作之前。
94
+ const preContract = chosen.contract || (await loadResourceContract(chosen.dir)).contract;
95
+ const preInput = opts.input ?? deriveSkillInput(preContract, opts.task);
96
+ const preChk = preContract ? validateResourceInput(preContract, preInput) : { ok: true, issues: [] };
97
+ if (!preChk.ok) {
98
+ const blocker = `输入不符合 inputSchema (${preChk.issues[0] || '未知'}) → 未开托管, 没花钱`;
99
+ const card = buildReportCard({ task: opts.task, executed: false, paid: false, evidenceRef: {}, blocker });
100
+ mark('report', '输入不完整 → 不开托管, 不花钱 (给 --input 或把数字写进任务文本)');
101
+ return {
102
+ ok: false, taskKey, requestId, transactionId: '', card, text: renderReportCard(card), budget,
103
+ skill: { name: chosen.name, version: chosen.version, dir: chosen.dir },
104
+ chain: { verified: false, reason: blocker, statuses: [] }, outputIssues: [...preChk.issues, blocker],
105
+ recovery: recoverOnchainTrade({ home: opts.home, taskKey }), stages,
106
+ };
107
+ }
91
108
  // ③ 金额 = 单次购买上限 (与 M1 硬约束一致); 转原子单位
92
109
  const amountAtomic = BigInt(Math.round(budget.perPurchase * 10 ** decimals));
93
110
  const amountChk = checkOnchainAmount({ amountAtomic, decimals, budget: { taskBudget: budget.taskBudget, perPurchase: budget.perPurchase } });
@@ -124,6 +141,7 @@ export async function runTaskOnchain(opts) {
124
141
  network: opts.network || 'unknown', tokenDecimals: decimals,
125
142
  buyerSigner: opts.buyerSigner, sellerSigner: opts.sellerSigner,
126
143
  env: opts.env, persistState: opts.persistState, verifyOnChain: opts.verifyOnChain,
144
+ settleRetryAttempts: opts.settleRetryAttempts, settleRetryDelayMs: opts.settleRetryDelayMs,
127
145
  };
128
146
  // ⑤ 幂等/恢复: 已经链上走过的任务**不新开托管** (先读盘重建)
129
147
  const before = recoverOnchainTrade({ home: opts.home, taskKey });
@@ -55,6 +55,30 @@ function faultPoint(name) {
55
55
  process.exit(97); // 模拟进程被杀
56
56
  process.kill(process.pid, 'SIGKILL');
57
57
  }
58
+ /**
59
+ * 数值字段的锚点词: 字段名本身 + 常见别名 —— 只有「锚点词紧邻数字」才算, 数字不搬家。
60
+ * 为什么需要: 早先的实现把「任务文本里第一个数字」塞给**每一个**数值字段。任务里出现
61
+ * 技术符号 (如 `μ0H_P`) 时第一个数字就是那个 `0` → field_T/tc_K/factor 全被编成 0 →
62
+ * 技能判「输入不合格→未知」→ 输出缺 required 字段 → 托管已注资但拿不到 proof (钱卡住)。
63
+ * 编造数字比缺输入更糟: 宁可让 inputSchema 报「缺字段」, 也不给一个看着像真值的假数字。
64
+ */
65
+ const NUMBER_ANCHORS = {
66
+ budgetusd: ['预算', 'budget'], budget: ['预算', 'budget'],
67
+ price: ['价格', '单价', 'price'], amount: ['金额', 'amount'], usd: ['美元', 'usd'],
68
+ count: ['数量', 'count'], qty: ['数量', 'qty'], quantity: ['数量', 'quantity'],
69
+ tol: ['容差', 'tol'], tolerance: ['容差', 'tolerance'],
70
+ };
71
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ /** 只在任务里该字段有明确锚点 (字段名/别名 后紧跟数字) 时才取数; 取不到 = 不填, 不编 */
73
+ function anchoredNumber(task, key) {
74
+ const anchors = [key, ...(NUMBER_ANCHORS[key.toLowerCase()] || [])].filter(Boolean);
75
+ for (const a of anchors) {
76
+ const m = task.match(new RegExp(`${escapeRe(a)}[^0-9\\n]{0,6}?(\\d+(?:\\.\\d+)?)`, 'i'));
77
+ if (m)
78
+ return Number(m[1]);
79
+ }
80
+ return undefined;
81
+ }
58
82
  /** 从任务文本 + 契约 inputSchema 推导输入 (确定性; M1 不做语义抽取, 用 --input 兜底) */
59
83
  export function deriveSkillInput(contract, task) {
60
84
  const schema = contract?.inputSchema || {};
@@ -75,9 +99,9 @@ export function deriveSkillInput(contract, task) {
75
99
  const isRequired = required.includes(key);
76
100
  const type = String(p.type || 'string');
77
101
  if (type === 'number' || type === 'integer') {
78
- const n = task.match(/\d+(\.\d+)?/);
79
- if (n)
80
- out[key] = Number(n[0]); // 任务里真有数字才填 (可选字段也不硬塞)
102
+ const n = anchoredNumber(task, key); // 任务里该字段有明确锚点才填 (可选字段也不硬塞)
103
+ if (n !== undefined)
104
+ out[key] = n;
81
105
  continue;
82
106
  }
83
107
  if (/market|国家|市场|地区|country|region/i.test(key)) {