@bolloon/bolloon-agent 0.5.2 → 0.5.3

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 (53) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/chain/transfer-classify.js +99 -0
  3. package/dist/agents/chain/transfer-index.js +354 -0
  4. package/dist/agents/identity/address-binding.js +613 -0
  5. package/dist/agents/network-pulse.js +706 -27
  6. package/dist/agents/x402/direct-payment.js +689 -0
  7. package/dist/agents/x402/order-identity.js +408 -0
  8. package/dist/agents/x402/paid-info-store.js +17 -0
  9. package/dist/agents/x402/seller-signing.js +614 -0
  10. package/dist/agents/x402/seller-summary.js +398 -0
  11. package/dist/cli/identity-command.js +327 -2
  12. package/dist/cli/protocol-envelope.js +4 -0
  13. package/dist/cli/update-commands.js +41 -25
  14. package/dist/cli/x402-buyer-command.js +463 -0
  15. package/dist/cli/x402-seller-command.js +289 -0
  16. package/dist/cli-entry.js +21 -5
  17. package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
  18. package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
  19. package/dist/electron-build/agents/chain/transfer-index.js +400 -0
  20. package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
  21. package/dist/electron-build/agents/identity/address-binding.js +672 -0
  22. package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
  23. package/dist/electron-build/agents/network-pulse.js +714 -28
  24. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  25. package/dist/electron-build/agents/x402/direct-payment.js +744 -0
  26. package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
  27. package/dist/electron-build/agents/x402/order-identity.js +458 -0
  28. package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
  29. package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
  30. package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
  31. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  32. package/dist/electron-build/agents/x402/seller-signing.js +682 -0
  33. package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
  34. package/dist/electron-build/utils/auto-update.js +2 -2
  35. package/dist/electron-build/utils/auto-update.js.map +1 -1
  36. package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
  37. package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
  38. package/dist/electron-build/utils/update-manager.js +3 -3
  39. package/dist/electron-build/utils/update-manager.js.map +1 -1
  40. package/dist/electron-build/utils/version-identity.js +1 -1
  41. package/dist/electron-build/utils/version-identity.js.map +1 -1
  42. package/dist/electron-build/web/routes-x402-info.js +28 -1
  43. package/dist/electron-build/web/routes-x402-info.js.map +1 -1
  44. package/dist/index.js +3 -3
  45. package/dist/utils/auto-update.js +2 -2
  46. package/dist/utils/runtime-bootstrap.js +1 -1
  47. package/dist/utils/update-health.js +2 -2
  48. package/dist/utils/update-manager.js +3 -3
  49. package/dist/utils/version-identity.js +1 -1
  50. package/dist/web/bolloon-web.json +3 -3
  51. package/dist/web/routes-x402-info.js +28 -1
  52. package/dist/web/sw.js +1 -1
  53. package/package.json +2 -1
@@ -36,6 +36,8 @@ import * as crypto from 'crypto';
36
36
  import { DEFAULT_CONFIRMATIONS, LOCAL_DEV_CHAIN_ID } from './chain/chain-config.js';
37
37
  // 区块浏览器映射 (chainId → 公网浏览器); 认不出的链没有链接 —— 宁缺勿错, 不编死链
38
38
  import { ADDRESS_RE, EXPLORER_URL_RE, TX_HASH_RE, explorerTxUrl } from './chain/explorer.js';
39
+ // 纯分类/折算 (零依赖: 不拖 ethers 进网页进程) —— 付款行的分类与折算都来自这里
40
+ import { classifyTransfer, formatUnits } from './chain/transfer-classify.js';
39
41
  // 「待接单任务」的数据源 = 本机公告板目录 (~/.bolloon/tasks/board)。只借**路径常量**,
40
42
  // 公告的解析/发布/接单一律留在 task-board.ts (这里不做第二套实现)。
41
43
  import { boardDir, REMOTE_CLAIMS_FILE } from './task-board.js';
@@ -386,6 +388,45 @@ export async function emitTradePulse(before, after, h) {
386
388
  // `observed`, **绝不冒充** confirmed/finalized。
387
389
  /** 冻结形状: 活动行上限 */
388
390
  export const CONFIRMED_ACTIVITY_LIMIT = PULSE_LIMITS.maxConfirmedActivity;
391
+ /** 对外口径用的协议名与来源 (**唯一字面量**; 页面/快照/门都读它) */
392
+ export const PAYER_IDENTITY_METHOD = 'diap-address-binding/1';
393
+ export const PAYER_IDENTITY_SOURCE = 'off-chain-signed';
394
+ /** 名字短写允许的形状 (与 address-binding 的 NAME_SHORT_RE 同规则: 不含 0x / did: / 空格) */
395
+ export const PAYER_NAME_SHORT_RE = /^[A-Za-z0-9._\-\u4e00-\u9fff]{1,24}$/;
396
+ /** DID 短写形状 (`did:key:` 之后的前 12 字符; **不含 did: 前缀**) */
397
+ export const PAYER_DID_SHORT_RE = /^[1-9A-HJ-NP-Za-km-z]{4,16}$/;
398
+ /**
399
+ * 一条身份短写**能不能用** (**硬闸**, 三关缺一不可):
400
+ * ① 必须自称 `verified === true` (未验签的一律作废 —— 不静默降级为"可信");
401
+ * ② `method`/`source` 必须是本协议与 `off-chain-signed` (别的来源不许冒充这一路);
402
+ * ③ `name_short` / `did_short` 的形状必须精确 (含 0x / did: / 空格 → 直接丢掉, 宁可不出名字)。
403
+ * 返回 null = **这一行不出身份** (不是"出个空名字")。
404
+ */
405
+ export function usablePayerIdentity(v) {
406
+ if (!v || typeof v !== 'object')
407
+ return null;
408
+ const o = v;
409
+ if (o.verified !== true)
410
+ return null;
411
+ if (o.method !== PAYER_IDENTITY_METHOD)
412
+ return null;
413
+ if (o.source !== PAYER_IDENTITY_SOURCE)
414
+ return null;
415
+ const name = typeof o.name_short === 'string' ? o.name_short.trim() : '';
416
+ const did = typeof o.did_short === 'string' ? o.did_short.trim() : '';
417
+ if (!PAYER_NAME_SHORT_RE.test(name))
418
+ return null;
419
+ if (!PAYER_DID_SHORT_RE.test(did))
420
+ return null;
421
+ if (/0x/i.test(name) || /did:/i.test(name) || /0x/i.test(did) || /did:/i.test(did))
422
+ return null;
423
+ return { name_short: name, did_short: did, verified: true, method: PAYER_IDENTITY_METHOD, source: PAYER_IDENTITY_SOURCE };
424
+ }
425
+ /** 地址小写化 (只认 0x+40 的形状; 别的形状 → 空串 = 不匹配任何绑定) */
426
+ function normalizeLookupAddress(a) {
427
+ const s = String(a || '').trim().toLowerCase();
428
+ return /^0x[0-9a-f]{40}$/.test(s) ? s : '';
429
+ }
389
430
  /**
390
431
  * 链上 escrow 事件名 → (kind, state) 的**唯一**映射表 (改这里才对, 别在别处写 switch)。
391
432
  *
@@ -475,6 +516,9 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
475
516
  // escrow 合约地址白名单 (唯一权威): 只有索引文件自己记的这个地址算数, 别的地址一律不当合约
476
517
  const escrow = typeof opts.escrowAddress === 'string' ? opts.escrowAddress.toLowerCase() : '';
477
518
  const escrowOk = ADDRESS_RE.test(escrow) ? escrow : '';
519
+ const token = typeof opts.tokenAddress === 'string' ? opts.tokenAddress.toLowerCase() : '';
520
+ const tokenOk = ADDRESS_RE.test(token) ? token : '';
521
+ const decimals = Number.isInteger(Number(opts.tokenDecimals)) && Number(opts.tokenDecimals) >= 0 ? Number(opts.tokenDecimals) : 6;
478
522
  const out = [];
479
523
  for (const e of Array.isArray(entries) ? entries : []) {
480
524
  if (!e || e.suspect === true)
@@ -501,6 +545,11 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
501
545
  // 浏览器链接: 该链没有已知公网浏览器 → 返回 null → 键整个不出现 (不编 href="#")
502
546
  // 只有**交易**链接 (explorer_tx); 合约地址只在数据里 (contract), 不生成链接 (2026-09-23 收窄)
503
547
  const explorerTx = explorerTxUrl(chainId, txHash);
548
+ // 金额 (2026-09-29): 只从 args.amount 取 (链上事实); 计价地址先折成**符号**再出行 —— 地址不进快照
549
+ const argsAmt = String(e.args?.amount ?? '');
550
+ const assetAddr = String(e.args?.paymentAsset ?? '').toLowerCase();
551
+ const currency = tokenOk && assetAddr === tokenOk ? 'USDC' : '';
552
+ const amountAtomic = /^[0-9]+$/.test(argsAmt) ? argsAmt : '';
504
553
  out.push({
505
554
  block, logIndex,
506
555
  row: {
@@ -517,6 +566,8 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
517
566
  tx_hash: txHash,
518
567
  ...(contract ? { contract } : {}),
519
568
  ...(explorerTx ? { explorer_tx: explorerTx } : {}),
569
+ ...(amountAtomic ? { amount_atomic: amountAtomic, amount_display: formatUnits(amountAtomic, decimals) } : {}),
570
+ ...(amountAtomic && currency ? { currency } : {}),
520
571
  },
521
572
  });
522
573
  }
@@ -576,6 +627,71 @@ export function buildConfirmedActivityFromEvents(events, opts = {}) {
576
627
  }
577
628
  return rows;
578
629
  }
630
+ /**
631
+ * 转账索引条目 → 付款行 (**纯函数**)。
632
+ * · 只出**转入**行 (to ∈ 关注地址集); 出账行不进「链上转入」计数
633
+ * · 分类走 classifyTransfer (from == escrow → escrow 退款/释放; from ∈ 自己地址集 → 转入(非销售); 其余 → 外部付款)
634
+ * · x402 标记只在**台账拿得到**时才写 (true/false); 台账拿不到 → 键整个不出现 (口径未知)
635
+ * · task 恒为空串: 一笔 ERC-20 转账链上**不带** taskKey ⇒ 不猜是哪个任务 (页面/表格靠 tx 定位)
636
+ * · tx_hash / explorer_tx 照旧 (公开可核验); from/to 地址一律不出行 (隐私门也不放行)
637
+ */
638
+ export function buildPaymentRowsFromTransfers(entries, opts = {}) {
639
+ const gates = normalizeActivityGates(opts.gates);
640
+ const limit = activityLimit(opts.limit);
641
+ const chainId = Number.isInteger(Number(opts.chainId)) && Number(opts.chainId) >= 0 ? Number(opts.chainId) : 0;
642
+ const head = Number.isInteger(Number(opts.headBlock)) && Number(opts.headBlock) >= 0 ? Number(opts.headBlock) : null;
643
+ const decimals = Number.isInteger(Number(opts.tokenDecimals)) && Number(opts.tokenDecimals) >= 0 ? Number(opts.tokenDecimals) : 6;
644
+ const symbol = String(opts.tokenSymbol || '');
645
+ const cfg = { watchAddresses: opts.watchAddresses || [], ownAddresses: opts.ownAddresses || [], escrowAddress: opts.escrowAddress || '' };
646
+ const out = [];
647
+ for (const e of Array.isArray(entries) ? entries : []) {
648
+ if (!e || e.suspect === true)
649
+ continue;
650
+ const txHash = String(e.txHash || '').toLowerCase();
651
+ if (!/^0x[0-9a-f]{64}$/.test(txHash))
652
+ continue;
653
+ const block = Number(e.blockNumber);
654
+ const logIndex = Number(e.logIndex);
655
+ if (!Number.isInteger(block) || block < 0 || !Number.isInteger(logIndex) || logIndex < 0)
656
+ continue;
657
+ const at = isoSeconds(Number(e.firstSeenAt));
658
+ if (!at)
659
+ continue;
660
+ const cls = classifyTransfer({ from: String(e.from || ''), to: String(e.to || ''), direction: e.direction }, cfg);
661
+ if (cls === 'outbound')
662
+ continue; // 出账不进「链上转入」
663
+ // ★ 付款方智能体身份 (链下登记): **只认已验签**的短写; 没匹配 = 这个键整个不出现
664
+ // (分类为 outbound 的行上面就 continue 了 ⇒ 「付款方」只会出现在真正的转入行上)
665
+ const payerIdentity = usablePayerIdentity(opts.payerIdentities ? opts.payerIdentities[normalizeLookupAddress(e.from)] : null);
666
+ const confirmations = head == null ? Math.max(0, Number(e.confirmations) || 0) : Math.max(0, head - block + 1);
667
+ const explorerTx = explorerTxUrl(chainId, txHash);
668
+ const value = /^[0-9]+$/.test(String(e.value || '')) ? String(e.value) : '';
669
+ const x402Known = !!opts.x402Txs && typeof opts.x402Txs === 'object';
670
+ out.push({
671
+ block, logIndex,
672
+ row: {
673
+ task: '', // ERC-20 转账不带 taskKey → 不猜
674
+ kind: 'payment_in',
675
+ state: 'paid',
676
+ chain_id: chainId,
677
+ block,
678
+ tx: anonShortRef(txHash, 'tx'),
679
+ confirmations,
680
+ finality: finalityFromConfirmations(confirmations, gates), // suspect 行在上面就 continue 了
681
+ at,
682
+ tx_hash: txHash,
683
+ ...(explorerTx ? { explorer_tx: explorerTx } : {}),
684
+ class: cls,
685
+ ...(value ? { amount_atomic: value, amount_display: formatUnits(value, decimals) } : {}),
686
+ ...(value && symbol ? { currency: symbol } : {}),
687
+ ...(x402Known ? { x402: Object.prototype.hasOwnProperty.call(opts.x402Txs, txHash) } : {}),
688
+ ...(payerIdentity ? { payer_identity: payerIdentity } : {}),
689
+ },
690
+ });
691
+ }
692
+ out.sort((a, b) => b.block - a.block || b.logIndex - a.logIndex);
693
+ return out.slice(0, limit).map((x) => x.row);
694
+ }
579
695
  /** 纯函数降级: 直接从脉冲事件算活动行 (没有索引时用, source 如实写 pulse-events / none) */
580
696
  export function confirmedActivityFromEvents(events, limit) {
581
697
  const rows = buildConfirmedActivityFromEvents(events, { limit });
@@ -593,8 +709,54 @@ async function readChainIndex(home) {
593
709
  return null;
594
710
  }
595
711
  }
712
+ /** 惰性加载转账索引 (fs + RPC provider; 只读已落盘索引, 不发 RPC) */
713
+ let transferQueryModule = null;
714
+ async function readTransferIndexFile(home) {
715
+ try {
716
+ if (!transferQueryModule)
717
+ transferQueryModule = (await import('./chain/transfer-index.js'));
718
+ const m = transferQueryModule;
719
+ const cfg = m.readTransferWatchConfig(home);
720
+ const state = m.readTransferIndex(home);
721
+ return { state, cfg, summary: m.summarizeTransfers(state, cfg) };
722
+ }
723
+ catch {
724
+ return null;
725
+ }
726
+ }
727
+ /** 卖方端点只读汇总的**缓存** (由 scripts/x402-seller-summary.ts 取数落盘; 这里只读盘, 不发网络) */
728
+ export function readSellerSummaryCacheFile(home) {
729
+ try {
730
+ const raw = JSON.parse(fs.readFileSync(path.join(home || os.homedir(), '.bolloon', 'x402-seller-summary.json'), 'utf8'));
731
+ return raw && typeof raw === 'object' ? raw : null;
732
+ }
733
+ catch {
734
+ return null;
735
+ }
736
+ }
737
+ /**
738
+ * 惰性加载**付款方身份表** (地址↔DID 绑定库)。
739
+ * 为什么惰性: `address-binding.ts` 要 ethers (验 EIP-191) 与文件系统 —— 本文件在主站快照/网页
740
+ * 路径上被静态引入, 加载代价敏感; 而且这一块**失败必须能降级**(没有绑定库 ≠ 快照不可用)。
741
+ * **重验发生在 address-binding 内部** (`loadPayerIdentityIndex` 每条都当场验一遍),
742
+ * 本文件拿到的 `byAddress` 只含**已验签**的短写。
743
+ */
744
+ let payerIdentityModule = null;
745
+ async function readPayerIdentityIndexFile(home) {
746
+ try {
747
+ if (!payerIdentityModule)
748
+ payerIdentityModule = (await import('./identity/address-binding.js'));
749
+ return await payerIdentityModule.loadPayerIdentityIndex({ home });
750
+ }
751
+ catch {
752
+ return null;
753
+ }
754
+ }
596
755
  /**
597
- * 解析活动行 (唯一入口): **先链上索引, 再脉冲降级**, 并如实报出来源。
756
+ * 解析活动行 (唯一入口): **先链上索引 (escrow 事件 + 关注地址集转账), 再脉冲降级**,
757
+ * 并如实报出来源。
758
+ * 2026-09-29: 表里现在有**两类行** —— 任务行 (escrow 事件) 与付款行 (USDC 转账),
759
+ * 同一张表、同一套排序 (块号/时间 新→旧), 不拆成两块。
598
760
  * 索引文件不存在/读不出/没有任何可用行 → 退回脉冲事件; 两边都没有 → none (不编行)。
599
761
  */
600
762
  export async function resolveConfirmedActivity(q) {
@@ -607,17 +769,101 @@ export async function resolveConfirmedActivity(q) {
607
769
  try {
608
770
  const file = await reader(q.home);
609
771
  const entries = Array.isArray(file?.entries) ? file.entries : [];
610
- if (entries.length > 0) {
772
+ // ①b 转账索引 (关注地址集内收款) + 卖方端点汇总缓存 (x402 交叉核)
773
+ const tReader = q.readTransfers ?? readTransferIndexFile;
774
+ let transfers = null;
775
+ try {
776
+ transfers = await tReader(q.home);
777
+ }
778
+ catch {
779
+ transfers = null;
780
+ }
781
+ const sellerCache = (q.readSellerSummary ?? readSellerSummaryCacheFile)(q.home);
782
+ const sellerOk = !!sellerCache && sellerCache.available === true && sellerCache.txs && typeof sellerCache.txs === 'object';
783
+ const x402Txs = sellerOk ? sellerCache.txs : null;
784
+ // ★ 付款方身份 (链下登记, 2026-09-29): 只有**已验签**的绑定才会让付款行多出 `payer_identity`。
785
+ // 读不到/没有绑定库 ⇒ 空表 (行里没有这个键), 快照的 payer_identity_scope 如实说原因。
786
+ const tPayer = q.readPayerIdentities ?? readPayerIdentityIndexFile;
787
+ let payer = null;
788
+ try {
789
+ payer = await tPayer(q.home);
790
+ }
791
+ catch {
792
+ payer = null;
793
+ }
794
+ if (entries.length > 0 || (transfers && transfers.state?.entries?.length > 0)) {
611
795
  const gates = normalizeActivityGates(file?.confirmations);
612
- const rows = buildConfirmedActivityFromIndex(entries, {
613
- gates,
614
- headBlock: file?.headBlock,
615
- chainId: file?.chainId,
616
- limit,
617
- escrowAddress: file?.escrowAddress, // 合约地址白名单 = 索引文件自己的部署身份
618
- });
619
- if (rows.length > 0)
620
- return { source: 'chain-index', rows, gates };
796
+ const taskRows = entries.length > 0
797
+ ? buildConfirmedActivityFromIndex(entries, {
798
+ gates,
799
+ headBlock: file?.headBlock,
800
+ chainId: file?.chainId,
801
+ limit,
802
+ escrowAddress: file?.escrowAddress, // 合约地址白名单 = 索引文件自己的部署身份
803
+ tokenAddress: (file?.tokenAddress ?? transfers?.cfg?.tokenAddress) || null,
804
+ tokenDecimals: (file?.tokenDecimals ?? transfers?.cfg?.tokenDecimals) ?? null,
805
+ })
806
+ : [];
807
+ const paymentRows = transfers && transfers.state?.entries?.length > 0
808
+ ? buildPaymentRowsFromTransfers(transfers.state.entries, {
809
+ gates,
810
+ headBlock: transfers.state.headBlock ?? file?.headBlock,
811
+ chainId: transfers.state.chainId ?? file?.chainId,
812
+ limit,
813
+ watchAddresses: transfers.state.watchAddresses,
814
+ ownAddresses: transfers.cfg.ownAddresses,
815
+ escrowAddress: transfers.state.chainId ? (transfers.cfg.escrowAddress || file?.escrowAddress) : transfers.cfg.escrowAddress,
816
+ tokenSymbol: transfers.state.tokenSymbol,
817
+ tokenDecimals: transfers.state.tokenDecimals,
818
+ x402Txs, // null = 台账拿不到 → 行里不写 x402 键 (口径未知)
819
+ payerIdentities: payer && payer.byAddress && typeof payer.byAddress === 'object' ? payer.byAddress : null,
820
+ })
821
+ : [];
822
+ // 两类行合并: 同一张表、同一套排序 (块号 desc, logIndex desc); 上限就是冻结的 25 行
823
+ const merged = [...taskRows, ...paymentRows]
824
+ .sort((a, b) => (Number(b.block) - Number(a.block)) || (String(b.tx).localeCompare(String(a.tx))))
825
+ .slice(0, limit);
826
+ if (merged.length > 0) {
827
+ return {
828
+ source: 'chain-index',
829
+ rows: merged,
830
+ gates,
831
+ transfers: transfers ? transfers.summary : null,
832
+ payer_identity: payer
833
+ ? {
834
+ loaded: Number(payer.loaded) || 0,
835
+ verified: Number(payer.verified) || 0,
836
+ rejected: Array.isArray(payer.rejected) ? payer.rejected : [],
837
+ byAddress: payer.byAddress && typeof payer.byAddress === 'object' ? payer.byAddress : {},
838
+ reason: String(payer.reason || ''),
839
+ dir: String(payer.dir || ''),
840
+ label: payer.label || { zh: '链下登记(可离线验签)', en: 'off-chain registration (verifiable offline)' },
841
+ note: payer.note || { zh: '', en: '' },
842
+ }
843
+ : null,
844
+ index_info: entries.length > 0
845
+ ? {
846
+ chainId: Number(file?.chainId) || 0,
847
+ networkName: String(file?.networkName || 'unknown'),
848
+ escrowAddress: String(file?.escrowAddress || ''),
849
+ deploymentBlock: Number(file?.deploymentBlock) || 0,
850
+ lastSyncedBlock: Number(file?.lastSyncedBlock) || 0,
851
+ headBlock: Number.isInteger(Number(file?.headBlock)) ? Number(file.headBlock) : null,
852
+ rows: entries.length,
853
+ pageSize: Number(file?.pageSize) || 2000,
854
+ }
855
+ : null,
856
+ seller_summary: sellerCache
857
+ ? {
858
+ available: sellerOk,
859
+ reason: String(sellerCache.reason || ''),
860
+ fetched_at: Number.isFinite(Number(sellerCache.fetched_at)) ? Number(sellerCache.fetched_at) : null,
861
+ endpoint: sellerCache.endpoint ? String(sellerCache.endpoint) : null,
862
+ count: sellerOk ? Number(sellerCache.count) : null,
863
+ }
864
+ : { available: false, reason: '本机没有卖方端点汇总缓存 (~/.bolloon/x402-seller-summary.json 不存在) → 「经 x402 流程」口径未知', fetched_at: null, endpoint: null, count: null },
865
+ };
866
+ }
621
867
  }
622
868
  }
623
869
  catch { /* 索引不可用 → 降级 (来源会在快照里标明) */ }
@@ -664,16 +910,50 @@ export function summarizeActivityRows(rows, opts) {
664
910
  const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
665
911
  const tasks = new Set();
666
912
  const completed = new Set();
667
- const settled = new Set();
913
+ const settled = new Set(); // 只有 released (钱真释放给卖方)
914
+ const refunded = new Set();
915
+ const disputed = new Set();
916
+ const expired = new Set();
668
917
  const by_finality = { observed: 0, confirmed: 0, finalized: 0 };
918
+ let paymentsIn = 0, paymentsExternal = 0, paymentsSelf = 0, paymentsEscrow = 0;
919
+ let paidAtomic = 0n, x402Atomic = 0n;
920
+ let x402Count = 0, x402Known = 0;
669
921
  for (const r of list) {
670
922
  const t = String(r.task || '');
671
923
  if (t)
672
924
  tasks.add(t);
673
925
  if (t && r.kind === 'task_completed')
674
926
  completed.add(t);
675
- if (t && r.kind === 'trade_settled')
676
- settled.add(t);
927
+ if (t && r.kind === 'trade_settled') {
928
+ // ★ 2026-09-29: 按 **state** 分桶 —— 只有 released 才是"已结算(释放给卖方)";
929
+ // 退款/争议/过期各算各的 (旧实现把四类并成一个 5 ⇒ 出现「已结算 5 > 已完成 3」的假读数)
930
+ if (r.state === 'released')
931
+ settled.add(t);
932
+ else if (r.state === 'refunded')
933
+ refunded.add(t);
934
+ else if (r.state === 'disputed')
935
+ disputed.add(t);
936
+ else if (r.state === 'expired')
937
+ expired.add(t);
938
+ }
939
+ if (r.kind === 'payment_in') {
940
+ paymentsIn++;
941
+ const v = /^[0-9]+$/.test(String(r.amount_atomic ?? '')) ? BigInt(r.amount_atomic) : 0n;
942
+ paidAtomic += v;
943
+ if (r.class === 'external_payment')
944
+ paymentsExternal++;
945
+ else if (r.class === 'self_transfer')
946
+ paymentsSelf++;
947
+ else if (r.class === 'escrow_settlement')
948
+ paymentsEscrow++;
949
+ if (Object.prototype.hasOwnProperty.call(r, 'x402')) {
950
+ x402Known++;
951
+ if (r.x402 === true) {
952
+ x402Count++;
953
+ x402Atomic += v;
954
+ }
955
+ }
956
+ }
677
957
  if (r.finality === 'observed' || r.finality === 'confirmed' || r.finality === 'finalized') {
678
958
  by_finality[r.finality] += 1;
679
959
  }
@@ -684,10 +964,83 @@ export function summarizeActivityRows(rows, opts) {
684
964
  tasks: tasks.size,
685
965
  tasks_completed: completed.size,
686
966
  tasks_settled: settled.size,
967
+ tasks_refunded: refunded.size,
968
+ tasks_disputed: disputed.size,
969
+ tasks_expired: expired.size,
970
+ payments_in: paymentsIn,
971
+ payments_in_total_atomic: paidAtomic.toString(),
972
+ payments_in_external: paymentsExternal,
973
+ payments_in_self: paymentsSelf,
974
+ payments_in_escrow: paymentsEscrow,
975
+ // x402 口径: **所有**付款行都带 x402 键才算"知道"; 否则 null (未知 ≠ 0)
976
+ payments_in_x402: x402Known === paymentsIn ? x402Count : null,
977
+ payments_in_x402_total_atomic: x402Known === paymentsIn ? x402Atomic.toString() : null,
687
978
  by_finality,
688
979
  gates: normalizeActivityGates(opts.gates),
689
980
  };
690
981
  }
982
+ /** 覆盖口径 (纯函数; 不发网络: liveHeadBlock 由调用方(导出脚本)读好传入) */
983
+ export function buildIndexScope(input) {
984
+ const srcs = [];
985
+ if (input.escrow && Number.isFinite(Number(input.escrow.deploymentBlock))) {
986
+ srcs.push({
987
+ kind: 'escrow-contract',
988
+ label: { zh: 'AgentEscrow 合约事件 (创建/提交证明/释放/争议/退款/到期)', en: 'AgentEscrow contract events (created / proof / released / disputed / refunded / expired)' },
989
+ from_block: Number(input.escrow.deploymentBlock),
990
+ last_scanned_block: Number(input.escrow.lastSyncedBlock),
991
+ rows: Number(input.escrow.rows) || 0,
992
+ scan_chunk_blocks: Number(input.escrow.pageSize) || 2000,
993
+ });
994
+ }
995
+ const t = input.transfers || null;
996
+ // 只有**真同步过**的转账索引才算一个源: 从未同步 (from_block 0 / last_synced_block -1) 时不列它
997
+ // —— 列出来会出现「扫到哪 (-1) 比起块 (0) 还早」这种说不出覆盖范围的源 (导出前的自检就该判红)。
998
+ if (t && t.configured && Number(t.from_block) > 0 && Number(t.last_synced_block) >= Number(t.from_block)) {
999
+ srcs.push({
1000
+ kind: 'erc20-transfers',
1001
+ label: { zh: `关注地址集内的 ${t.token_symbol || 'ERC20'} 转账 (转入/转出)`, en: `${t.token_symbol || 'ERC20'} transfers in/out of the watched address set` },
1002
+ from_block: t.from_block,
1003
+ last_scanned_block: t.last_synced_block,
1004
+ rows: t.entries,
1005
+ token_symbol: t.token_symbol,
1006
+ token_decimals: t.token_decimals,
1007
+ watch_addresses_count: t.watch_addresses_count,
1008
+ });
1009
+ }
1010
+ if (!srcs.length)
1011
+ return null;
1012
+ const fromBlock = Math.min(...srcs.map((s) => s.from_block));
1013
+ const lastScanned = Math.min(...srcs.map((s) => s.last_scanned_block));
1014
+ const live = Number.isFinite(Number(input.liveHeadBlock)) && Number(input.liveHeadBlock) > 0 ? Number(input.liveHeadBlock) : null;
1015
+ const lag = live == null ? null : Math.max(0, live - lastScanned);
1016
+ const networkName = String(input.networkName || 'unknown');
1017
+ const label = chainLabelOf(Number(input.chainId)) || null;
1018
+ const netZh = label?.zh || networkName;
1019
+ return {
1020
+ source: 'chain-index',
1021
+ chain_id: Number(input.chainId) || 0,
1022
+ network_name: networkName,
1023
+ network_label: label ? { zh: label.zh, en: label.en } : null,
1024
+ coverage: {
1025
+ zh: `本索引覆盖 ${netZh}上该合约 (AgentEscrow 事件) 与该地址集内 (USDC 转账) 的全部交互 —— 消息/心跳/任务报文不上链, 不在本口径内`,
1026
+ en: `This index covers all interactions on ${label?.en || networkName} for that contract (AgentEscrow events) and within the watched address set (USDC transfers). Messages/heartbeats/task envelopes are not on-chain and are outside this scope.`,
1027
+ },
1028
+ sources: srcs,
1029
+ from_block: fromBlock,
1030
+ last_scanned_block: lastScanned,
1031
+ head_block_at_sync: input.escrow && Number.isFinite(Number(input.escrow.headBlock)) ? Number(input.escrow.headBlock) : (t?.head_block ?? null),
1032
+ head_block_live: live,
1033
+ lag_blocks: lag,
1034
+ note: {
1035
+ zh: live == null
1036
+ ? `索引起止: ${fromBlock} → ${lastScanned}; 真链 head **未读到** (RPC 不可达) ⇒ 落后多少块未知 (不拿同步时的高度冒充)`
1037
+ : `索引起止: ${fromBlock} → ${lastScanned}; 真链 head ${live} (读于导出时刻) ⇒ 落后 ${lag} 块 —— 这不是"实时全网"`,
1038
+ en: live == null
1039
+ ? `Index range: ${fromBlock} → ${lastScanned}; live chain head NOT read (RPC unreachable) so the lag is unknown (we do not pass off the sync-time height as the live head)`
1040
+ : `Index range: ${fromBlock} → ${lastScanned}; live chain head ${live} (read at export time) ⇒ ${lag} blocks behind — this is not a real-time view of the whole chain`,
1041
+ },
1042
+ };
1043
+ }
691
1044
  /** 活动行 → 链归属 (**纯函数**)。所有结论都从行本身推, 不引用外部状态 → 不可能与行数打架。 */
692
1045
  export function buildChainIdScope(rows) {
693
1046
  const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
@@ -786,14 +1139,180 @@ export function snapshotConsistencyIssues(snap) {
786
1139
  issues.push(...auditPublicHexLeaks(snap)); // ⑥ 0x 长 hex 越界 → 拒绝导出 (不静默放行)
787
1140
  issues.push(...openTasksIssues(snap)); // ⑦ 待接单任务的字段白名单与「只含未认领」不变式
788
1141
  issues.push(...totalsScopeIssues(snap)); // ⑧ ★ 同一概念不得并排两个数 (顶部 vs 表格)
1142
+ issues.push(...transferTotalsIssues(snap)); // ⑨ ★ 转入分桶必须与同行数算出来的一致
1143
+ issues.push(...indexScopeIssues(snap)); // ⑩ ★ 覆盖口径/起止块/落后块数必须自洽
1144
+ issues.push(...payerIdentityIssues(snap)); // ⑪ ★ 付款方身份只许是**已验签**的短写 (链下登记)
789
1145
  return issues;
790
1146
  }
1147
+ /**
1148
+ * ⑨ 链上转入分桶自检 (2026-09-29): `transfer_totals` 与 `activity_totals` 是**同一批行**的两种投影,
1149
+ * 对不上就是有人手改了其中一个 (页面上会出现"合计 4 笔 / 分桶 3 笔"这种读不通的数)。
1150
+ * 另外: 未配置关注地址集时**不许**出现一个 0 —— 那会被读成"一笔都没有"。
1151
+ */
1152
+ export function transferTotalsIssues(snap) {
1153
+ const out = [];
1154
+ const tt = snap?.transfer_totals;
1155
+ const at = snap?.activity_totals || {};
1156
+ const t = snap?.totals || {};
1157
+ if (tt == null)
1158
+ return out; // 没有转账源 → 这一块不出现 (合法)
1159
+ if (typeof tt !== 'object')
1160
+ return ['transfer_totals 必须是对象或 null'];
1161
+ if (tt.configured !== true) {
1162
+ // 未配置/没同步: 不许拿 0 冒充 —— 注意判据是「**有没有值**」而不是「值是不是数字 0」:
1163
+ // null 才是"没有源"的正确写法 (Number(null)===0 会把"没源"误判成"报了 0")。
1164
+ const hasVal = (v) => v !== null && v !== undefined;
1165
+ // 只看**页面上真要显示**的那两个字段 (totals.payments_in / _total_atomic) 与分桶笔数:
1166
+ // activity_totals.payments_in 是「这一批行里有几笔 payment_in」的**同源行数**, 它天生是数字
1167
+ // (没有付款行时就是 0), 页面不显示它 —— 拿它来判「报了 0」是判错了对象。
1168
+ if (hasVal(t.payments_in) || hasVal(t.payments_in_total_atomic) || Number(tt.inbound) > 0) {
1169
+ out.push('transfer_totals.configured=false 却报出了转入笔数/金额 —— 未配置不是「一笔都没有」, 必须是 null (页面写「未接入」)');
1170
+ }
1171
+ return out;
1172
+ }
1173
+ if (Number(tt.inbound) !== Number(at.payments_in)) {
1174
+ out.push(`transfer_totals.inbound=${tt.inbound} ≠ activity_totals.payments_in=${at.payments_in} (同一批行必须相等)`);
1175
+ }
1176
+ const cls = ['escrow_settlement', 'self_transfer', 'external_payment'];
1177
+ const sumCls = cls.reduce((n, k) => n + (Number(tt.by_class?.[k]) || 0), 0);
1178
+ if (sumCls !== Number(tt.inbound)) {
1179
+ out.push(`transfer_totals.by_class 三桶之和=${sumCls} ≠ inbound=${tt.inbound} (每一笔转入都必须落进恰好一个桶)`);
1180
+ }
1181
+ let sumAtomic = 0n;
1182
+ for (const k of cls)
1183
+ sumAtomic += BigInt(String(tt.by_class_atomic?.[k] ?? '0'));
1184
+ if (sumAtomic.toString() !== String(tt.inbound_total_atomic)) {
1185
+ out.push(`transfer_totals.by_class_atomic 之和=${sumAtomic} ≠ inbound_total_atomic=${tt.inbound_total_atomic}`);
1186
+ }
1187
+ if (String(tt.inbound_total_atomic) !== String(at.payments_in_total_atomic)) {
1188
+ out.push(`transfer_totals.inbound_total_atomic=${tt.inbound_total_atomic} ≠ activity_totals.payments_in_total_atomic=${at.payments_in_total_atomic}`);
1189
+ }
1190
+ return out;
1191
+ }
1192
+ /**
1193
+ * ⑩ 覆盖口径自检 (2026-09-29): 「本索引覆盖…起止块…落后多少块」这句话必须能被数字复算 ——
1194
+ * · 每个源 from_block ≤ last_scanned_block;
1195
+ * · 顶层 last_scanned_block = 各源的最小值 (覆盖到哪以**最落后**的那个源为准);
1196
+ * · lag_blocks = head_block_live − last_scanned_block; 真链 head 读不到 ⇒ lag **必须是 null**
1197
+ * (拿同步时的高度冒充真链 head 就是"暗示实时")。
1198
+ */
1199
+ export function indexScopeIssues(snap) {
1200
+ const out = [];
1201
+ const is = snap?.index_scope;
1202
+ if (is == null)
1203
+ return out; // 没有链上源 → 不出现 (合法)
1204
+ if (typeof is !== 'object')
1205
+ return ['index_scope 必须是对象或 null'];
1206
+ const srcs = Array.isArray(is.sources) ? is.sources : [];
1207
+ if (!srcs.length)
1208
+ out.push('index_scope.sources 不能是空数组 (要么整块不出现, 要么至少一个源)');
1209
+ for (const [i, s] of srcs.entries()) {
1210
+ const f = Number(s?.from_block), l = Number(s?.last_scanned_block);
1211
+ if (!Number.isFinite(f) || !Number.isFinite(l)) {
1212
+ out.push(`index_scope.sources[${i}] 缺 from_block/last_scanned_block`);
1213
+ continue;
1214
+ }
1215
+ if (l < f)
1216
+ out.push(`index_scope.sources[${i}].last_scanned_block=${l} < from_block=${f} (扫到哪不能比起块还早)`);
1217
+ if (l > Number(is.head_block_live ?? l))
1218
+ out.push(`index_scope.sources[${i}].last_scanned_block=${l} 比真链 head=${is.head_block_live} 还高`);
1219
+ }
1220
+ if (srcs.length) {
1221
+ const minFrom = Math.min(...srcs.map((s) => Number(s.from_block)));
1222
+ const minLast = Math.min(...srcs.map((s) => Number(s.last_scanned_block)));
1223
+ if (Number(is.from_block) !== minFrom)
1224
+ out.push(`index_scope.from_block=${is.from_block} ≠ 各源最小起块=${minFrom}`);
1225
+ if (Number(is.last_scanned_block) !== minLast)
1226
+ out.push(`index_scope.last_scanned_block=${is.last_scanned_block} ≠ 各源最小 last_scanned_block=${minLast} (覆盖到哪以最落后的源为准)`);
1227
+ }
1228
+ const live = is.head_block_live;
1229
+ if (live == null) {
1230
+ if (is.lag_blocks != null)
1231
+ out.push('index_scope.head_block_live 读不到 (null) 却给了 lag_blocks —— 落后块数必须未知, 不许拿同步时的高度顶替');
1232
+ }
1233
+ else {
1234
+ const expect = Math.max(0, Number(live) - Number(is.last_scanned_block));
1235
+ if (Number(is.lag_blocks) !== expect)
1236
+ out.push(`index_scope.lag_blocks=${is.lag_blocks} ≠ head_block_live−last_scanned_block=${expect}`);
1237
+ }
1238
+ if (!is.coverage?.zh || !is.coverage?.en)
1239
+ out.push('index_scope.coverage 必须双语都给 (口径句是这一块的核心)');
1240
+ return out;
1241
+ }
1242
+ /**
1243
+ * ⑪ 付款方身份自检 (2026-09-29, `diap-address-binding/1`) —— **只收紧不放松**:
1244
+ * · 行里出现 `payer_identity` 就必须有 `payer_identity_scope` (口径不能缺);
1245
+ * · 每条身份必须是**已验签**的短写: `verified===true` · `method`/`source` 逐字对 · 键集恰好 5 个 ·
1246
+ * `name_short`/`did_short` 形状精确且**不含** `0x` / `did:` / 空格 (出了就是泄漏形态);
1247
+ * · `payer_identity` 只许出现在 `payment_in` 行上 (别的行没有"付款方"这一说);
1248
+ * · `rows_with_identity` 必须等于行里**真数出来**的条数 (同一批行, 同一时刻);
1249
+ * · 口径说 `verified=0` 却行里有身份 ⇒ 自相矛盾。
1250
+ * 过了这道门, 快照里"出名字"这件事就只剩一种可能: 本机有一份**加载时重验通过**的绑定。
1251
+ */
1252
+ export function payerIdentityIssues(snap) {
1253
+ const out = [];
1254
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
1255
+ const withId = rows.filter((r) => r && r.payer_identity);
1256
+ const scope = snap?.payer_identity_scope;
1257
+ if (withId.length > 0 && (!scope || typeof scope !== 'object')) {
1258
+ out.push(`有 ${withId.length} 行带 payer_identity 却没有 payer_identity_scope —— 口径不能缺`);
1259
+ }
1260
+ if (scope != null && typeof scope !== 'object')
1261
+ out.push('payer_identity_scope 必须是对象或 null');
1262
+ const KEYS = ['did_short', 'method', 'name_short', 'source', 'verified'];
1263
+ for (const [i, r] of rows.entries()) {
1264
+ const id = r?.payer_identity;
1265
+ if (!id)
1266
+ continue;
1267
+ if (id.verified !== true)
1268
+ out.push(`confirmed_activity[${i}].payer_identity.verified=${String(id.verified)} —— 只有已验签的绑定才许出行`);
1269
+ if (id.method !== PAYER_IDENTITY_METHOD)
1270
+ out.push(`confirmed_activity[${i}].payer_identity.method=${String(id.method)} ≠ ${PAYER_IDENTITY_METHOD}`);
1271
+ if (id.source !== PAYER_IDENTITY_SOURCE)
1272
+ out.push(`confirmed_activity[${i}].payer_identity.source=${String(id.source)} ≠ ${PAYER_IDENTITY_SOURCE}`);
1273
+ const keys = Object.keys(id).sort();
1274
+ if (JSON.stringify(keys) !== JSON.stringify(KEYS))
1275
+ out.push(`confirmed_activity[${i}].payer_identity 键集=${JSON.stringify(keys)} ≠ ${JSON.stringify(KEYS)}`);
1276
+ if (!PAYER_NAME_SHORT_RE.test(String(id.name_short || '')))
1277
+ out.push(`confirmed_activity[${i}].payer_identity.name_short 形状非法: ${JSON.stringify(id.name_short)}`);
1278
+ if (!PAYER_DID_SHORT_RE.test(String(id.did_short || '')))
1279
+ out.push(`confirmed_activity[${i}].payer_identity.did_short 形状非法: ${JSON.stringify(id.did_short)}`);
1280
+ for (const [k, v] of Object.entries(id)) {
1281
+ if (typeof v === 'string' && (/0x/i.test(v) || /did:/i.test(v))) {
1282
+ out.push(`confirmed_activity[${i}].payer_identity.${k} 含 0x/did: 形态 —— 短写里不许出现`);
1283
+ }
1284
+ }
1285
+ if (String(r.kind || '') !== 'payment_in') {
1286
+ out.push(`confirmed_activity[${i}] kind=${String(r.kind)} 却带 payer_identity —— 只有付款行才有"付款方"`);
1287
+ }
1288
+ }
1289
+ if (scope && typeof scope === 'object') {
1290
+ if (!scope.label?.zh || !scope.label?.en)
1291
+ out.push('payer_identity_scope.label 必须双语 (口径句不能只写一种语言)');
1292
+ if (Number(scope.rows_with_identity) !== withId.length) {
1293
+ out.push(`payer_identity_scope.rows_with_identity=${scope.rows_with_identity} ≠ 行里真数出来的 ${withId.length} (同一批行必须相等)`);
1294
+ }
1295
+ if (Number(scope.verified) === 0 && withId.length > 0) {
1296
+ out.push('payer_identity_scope.verified=0 却行里有身份 —— 自相矛盾');
1297
+ }
1298
+ if (Number(scope.rejected) !== (Array.isArray(scope.rejected_reasons) ? scope.rejected_reasons.length : 0)) {
1299
+ out.push(`payer_identity_scope.rejected=${scope.rejected} ≠ rejected_reasons 条数=${Array.isArray(scope.rejected_reasons) ? scope.rejected_reasons.length : 0}`);
1300
+ }
1301
+ if (scope.method !== PAYER_IDENTITY_METHOD || scope.source !== PAYER_IDENTITY_SOURCE) {
1302
+ out.push(`payer_identity_scope.method/source 必须是 ${PAYER_IDENTITY_METHOD} / ${PAYER_IDENTITY_SOURCE}`);
1303
+ }
1304
+ }
1305
+ return out;
1306
+ }
791
1307
  /** 顶部逐字段口径的键 (与 `TotalsFieldKey` 逐字一致; 少一个 = 那个数没有来源说明) */
792
1308
  export const TOTALS_FIELD_KEYS = [
793
1309
  'nodes', 'agents', 'active_agents', 'seen_last_24h',
794
- 'tasks', 'tasks_completed', 'tasks_verified', 'tasks_settled', 'signatures',
1310
+ 'tasks', 'tasks_completed', 'tasks_verified', 'tasks_settled',
1311
+ 'tasks_refunded', 'tasks_disputed',
1312
+ 'payments_in', 'payments_in_total_atomic', 'payments_in_x402', 'payments_in_x402_total_atomic',
1313
+ 'signatures',
795
1314
  ];
796
- const TOTALS_FIELD_SOURCES = ['pulse-events', 'chain-index', 'signature-audit', 'none'];
1315
+ const TOTALS_FIELD_SOURCES = ['pulse-events', 'chain-index', 'signature-audit', 'x402-ledger', 'none'];
797
1316
  const TOTALS_FIELD_WINDOWS = ['window-24h', 'full', 'unknown'];
798
1317
  /** 「未接入」类措辞 (无源时必须出现, 否则等于没说清为什么没有数) */
799
1318
  const UNAVAILABLE_WORDS = /未接入|not connected|无可用源|no available source/;
@@ -870,6 +1389,10 @@ export function totalsScopeIssues(snap) {
870
1389
  ['tasks', t.tasks, at.tasks],
871
1390
  ['tasks_completed', t.tasks_completed, at.tasks_completed],
872
1391
  ['tasks_settled', t.tasks_settled, at.tasks_settled],
1392
+ ['tasks_refunded', t.tasks_refunded, at.tasks_refunded],
1393
+ ['tasks_disputed', t.tasks_disputed, at.tasks_disputed],
1394
+ ['payments_in', t.payments_in, at.payments_in],
1395
+ ['payments_in_total_atomic', t.payments_in_total_atomic, at.payments_in_total_atomic],
873
1396
  ];
874
1397
  const tableRows = Number(at.rows) || rows.length;
875
1398
  const sameSourceChain = String(at.source) === 'chain-index';
@@ -1005,6 +1528,22 @@ function noneField(why) {
1005
1528
  unavailable: true,
1006
1529
  };
1007
1530
  }
1531
+ /**
1532
+ * 「卖方端点只读汇总」口径 (2026-09-29): 链上**没有** x402 事件 —— 付款就是一笔普通 ERC-20
1533
+ * USDC 转账, 所以单看链分不出「走 x402 下单流程的付款」与「别人随手转来的账」。这一格的口径 =
1534
+ * 拿卖方端点台账里的 txHash 列表与链上扫到的收款**交叉核** (对上的才加标「经 x402 流程」)。
1535
+ */
1536
+ function x402LedgerField(what) {
1537
+ return {
1538
+ source: 'x402-ledger',
1539
+ window: 'full',
1540
+ short: { zh: '经 x402 台账', en: 'via x402 ledger' },
1541
+ label: {
1542
+ zh: `卖方端点只读汇总的 txHash 列表与链上收款交叉核 (链上没有 x402 事件, 只能这样判) · ${what.zh}`,
1543
+ en: `cross-checked against the seller endpoint's read-only txHash list (there is no x402 event on chain, this is the only way) · ${what.en}`,
1544
+ },
1545
+ };
1546
+ }
1008
1547
  export function computeSnapshot(events, opts) {
1009
1548
  const now = opts.now;
1010
1549
  const fresh_until = now + PULSE_LIMITS.snapshotTtlMs;
@@ -1022,6 +1561,11 @@ export function computeSnapshot(events, opts) {
1022
1561
  totals: {
1023
1562
  nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0,
1024
1563
  tasks: null, tasks_completed: null, tasks_verified: null, signatures: null, tasks_settled: null,
1564
+ // 2026-09-29: 不可用态下这些链上计数一律 null (未接入), 不给 0
1565
+ tasks_refunded: null, tasks_disputed: null,
1566
+ payments_in: null, payments_in_total_atomic: null,
1567
+ payments_in_x402: null, payments_in_x402_total_atomic: null,
1568
+ payments_in_total_usdc: null, payments_in_x402_total_usdc: null, payments_in_currency: null,
1025
1569
  },
1026
1570
  totals_scope: {
1027
1571
  source: 'pulse-events', window_ms: PULSE_LIMITS.windowMs,
@@ -1034,8 +1578,15 @@ export function computeSnapshot(events, opts) {
1034
1578
  nodes: downField, agents: downField, active_agents: downField, seen_last_24h: downField,
1035
1579
  tasks: downField, tasks_completed: downField, tasks_verified: downField,
1036
1580
  tasks_settled: downField, signatures: downField,
1581
+ tasks_refunded: downField, tasks_disputed: downField,
1582
+ payments_in: downField, payments_in_total_atomic: downField,
1583
+ payments_in_x402: downField, payments_in_x402_total_atomic: downField,
1037
1584
  },
1038
1585
  },
1586
+ transfer_totals: null,
1587
+ x402_ledger: null,
1588
+ index_scope: null,
1589
+ payer_identity_scope: null,
1039
1590
  capabilities: [],
1040
1591
  recent_activity: [],
1041
1592
  confirmed_activity: emptyRows,
@@ -1125,8 +1676,35 @@ export function computeSnapshot(events, opts) {
1125
1676
  const totalsTasks = chainAuthoritative ? activity_totals.tasks : tasks.size;
1126
1677
  const totalsCompleted = chainAuthoritative ? activity_totals.tasks_completed : tasksCompleted.size;
1127
1678
  const totalsSettled = chainAuthoritative ? activity_totals.tasks_settled : tasksSettledPulse.size;
1679
+ // ★ 2026-09-29: 退款 / 争议**单列** —— 旧实现把它们并进 tasks_settled, 页面上就出现
1680
+ // 「已结算 5 > 已完成 3」这种假读数 (5 = Released 3 + Refunded 2, 而退款根本不是成交)。
1681
+ const totalsRefunded = chainAuthoritative ? activity_totals.tasks_refunded : null;
1682
+ const totalsDisputed = chainAuthoritative ? activity_totals.tasks_disputed : null;
1128
1683
  // 「验真」在链上索引里**没有**对应事件 → 链上口径下这个数没有源 (null + 未接入), 不拿结算数冒充已验证
1129
1684
  const totalsVerified = chainAuthoritative ? null : tasksVerified.size;
1685
+ // ★ 链上转入 (关注地址集内的 USDC 转账): 只有**关注地址集配好 且 索引可用**才算"有源";
1686
+ // 没配/没读到 → null + 未接入 (0 的意思是"一笔都没有", 那是另一句话)。
1687
+ const transferSummary = activity.transfers ?? null;
1688
+ // 「有源」的判据 (2026-09-29 收紧): 关注地址集配好 **且** 转账索引真同步过 —— 只配了地址集、
1689
+ // 索引却从没同步成功 (from_block 0 / last_synced_block -1) 时, 报 0 笔会把"没读到"说成"一笔都没有"。
1690
+ const transferSynced = !!transferSummary && transferSummary.configured === true &&
1691
+ Number(transferSummary.from_block) > 0 && Number(transferSummary.last_synced_block) >= Number(transferSummary.from_block);
1692
+ const paymentsKnown = chainAuthoritative && transferSynced;
1693
+ const totalsPaymentsIn = paymentsKnown ? activity_totals.payments_in : null;
1694
+ const totalsPaymentsInAtomic = paymentsKnown && activity_totals.payments_in !== null
1695
+ ? Number(activity_totals.payments_in_total_atomic)
1696
+ : null;
1697
+ // 其中经 x402 流程: 口径来自**卖方端点只读汇总**的交叉核 (链上只有普通 ERC-20 转账, 单看链分不出)。
1698
+ // 台账拿不到 → null + 未接入 (原因写清楚), **绝不当 0**。
1699
+ const seller = activity.seller_summary ?? null;
1700
+ const x402Known = paymentsKnown && !!seller && seller.available === true && activity_totals.payments_in_x402 !== null;
1701
+ const totalsPaymentsX402 = x402Known ? activity_totals.payments_in_x402 : null;
1702
+ const totalsPaymentsX402Atomic = x402Known && activity_totals.payments_in_x402_total_atomic !== null
1703
+ ? Number(activity_totals.payments_in_x402_total_atomic)
1704
+ : null;
1705
+ const x402UnknownWhy = !paymentsKnown
1706
+ ? { zh: '链上转入这一格没有源 (关注地址集未配置/索引不可用)', en: 'no source for chain receipts (watched address set not configured / index unavailable)' }
1707
+ : { zh: `卖方端点只读汇总取不到 → 「经 x402 流程」口径未知${seller?.reason ? ` (原因: ${seller.reason})` : ''}`, en: `seller read-only summary unavailable → the "via x402 flow" figure is unknown${seller?.reason ? ` (reason: ${seller.reason})` : ''}` };
1130
1708
  // 钱包签名: 真源 = 本机签名审计账 (窗口内条数, 只计数); 没有审计账 → 退回脉冲事件上报数;
1131
1709
  // 两个都没有 → null (未接入)。**绝不**在没有源的时候报 0 (那是「没发生过」, 是另一句话)。
1132
1710
  const sigTally = opts.signatureAudit ?? null;
@@ -1146,8 +1724,26 @@ export function computeSnapshot(events, opts) {
1146
1724
  ? chainField({ zh: '交付完成 (ProofSubmittedV2) 的不同任务', en: 'distinct tasks with ProofSubmittedV2' })
1147
1725
  : pulseField({ zh: '交付完成的任务', en: 'tasks observed as completed' }),
1148
1726
  tasks_settled: chainAuthoritative
1149
- ? chainField({ zh: '托管结算 (Released/Refunded/Expired/Disputed) 的不同任务', en: 'distinct tasks with an on-chain escrow settlement' })
1727
+ ? chainField({ zh: '托管**释放给卖方** (ReleasedV2) 的不同任务 —— 只有钱真从合约出给卖方才算', en: 'distinct tasks whose escrow was RELEASED to the seller (ReleasedV2)' })
1150
1728
  : pulseField({ zh: '链上口径结算的任务', en: 'tasks with on-chain settlement' }),
1729
+ tasks_refunded: chainAuthoritative
1730
+ ? chainField({ zh: '争议后退款 (RefundedV2) 的不同任务 —— **不是成交**', en: 'distinct tasks refunded after a dispute (RefundedV2) — NOT a sale' })
1731
+ : noneField({ zh: '脉冲口径没有退款事件', en: 'the pulse scope has no refund event' }),
1732
+ tasks_disputed: chainAuthoritative
1733
+ ? chainField({ zh: '争议中 (DisputedV2, 资金冻结) 的不同任务', en: 'distinct tasks under dispute (DisputedV2, funds frozen)' })
1734
+ : noneField({ zh: '脉冲口径没有争议事件', en: 'the pulse scope has no dispute event' }),
1735
+ payments_in: paymentsKnown
1736
+ ? chainField({ zh: '关注地址集内收到的 USDC 转账笔数 (含退款与自有转入, 逐行可核验)', en: 'incoming USDC transfers to the watched address set (refunds and self top-ups included; every row verifiable)' })
1737
+ : noneField({ zh: '关注地址集未配置 / 转账索引不可用', en: 'watched address set not configured / transfer index unavailable' }),
1738
+ payments_in_total_atomic: paymentsKnown
1739
+ ? chainField({ zh: '上面这些转入的金额合计 (原子单位, 6 位小数)', en: 'total of the incoming transfers above (atomic units, 6 decimals)' })
1740
+ : noneField({ zh: '关注地址集未配置 / 转账索引不可用', en: 'watched address set not configured / transfer index unavailable' }),
1741
+ payments_in_x402: x402Known
1742
+ ? x402LedgerField({ zh: 'txHash 出现在卖方端点台账里的转入笔数', en: 'incoming transfers whose txHash appears in the seller endpoint ledger' })
1743
+ : noneField(x402UnknownWhy),
1744
+ payments_in_x402_total_atomic: x402Known
1745
+ ? x402LedgerField({ zh: '其中经 x402 流程的金额合计 (原子单位)', en: 'total of the transfers attributed to the x402 flow (atomic units)' })
1746
+ : noneField(x402UnknownWhy),
1151
1747
  tasks_verified: chainAuthoritative
1152
1748
  ? noneField({ zh: '链上索引没有「验真」事件', en: 'the chain index has no verification event' })
1153
1749
  : pulseField({ zh: '真验真 (trade_verified) 的任务', en: 'tasks truly verified (trade_verified)' }),
@@ -1162,10 +1758,10 @@ export function computeSnapshot(events, opts) {
1162
1758
  window_ms: PULSE_LIMITS.windowMs,
1163
1759
  label: {
1164
1760
  zh: chainAuthoritative
1165
- ? `节点/智能体 = 本节点 ${WINDOW_HOURS}h 脉冲事件; 任务/已完成/已结算 = 链上索引全量 (与下表同源); 钱包签名 = 本机签名审计`
1761
+ ? `任务/已完成/已释放/已退款/争议中/链上转入 = 链上索引全量 (与下表同源); 「其中经 x402 流程」= 卖方端点台账交叉核; 节点/智能体 = 本节点 ${WINDOW_HOURS}h 脉冲事件 (非链上); 钱包签名 = 本机签名审计`
1166
1762
  : `只统计本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
1167
1763
  en: chainAuthoritative
1168
- ? `nodes/agents = this node's ${WINDOW_HOURS}h pulse events; tasks/completed/settled = chain index, whole (same source as the table below); wallet signatures = this node's signature audit log`
1764
+ ? `tasks/completed/released/refunded/disputed/chain-receipts = chain index, whole (same source as the table below); "via x402 flow" = cross-checked against the seller endpoint ledger; nodes/agents = this node's ${WINDOW_HOURS}h pulse events (not on-chain); wallet signatures = this node's signature audit log`
1169
1765
  : `Only pulse events received by this node within the ${WINDOW_HOURS}h observation window (reported by this node itself)`,
1170
1766
  },
1171
1767
  differs_from_activity: activity_totals.rows > 0 &&
@@ -1191,9 +1787,15 @@ export function computeSnapshot(events, opts) {
1191
1787
  `不是链上索引全量) —— 每个数的来源见 totals_scope.fields`);
1192
1788
  }
1193
1789
  if (chainAuthoritative) {
1194
- notes.push(`顶部「任务/已完成/已结算」= 链上索引的**同源计数** (= activity_totals, 与下表同源, 同源即恒等): ` +
1195
- `tasks=${totalsTasks} · tasks_completed=${totalsCompleted} · tasks_settled=${totalsSettled}; ` +
1196
- `「节点/智能体」= 本节点 ${WINDOW_HOURS}h 脉冲事件; 「已验证」在链上索引里没有对应事件 → ` +
1790
+ const x402Note = x402Known
1791
+ ? `「其中经 x402 流程」= ${totalsPaymentsX402} 笔 (卖方端点台账交叉核: ${seller?.endpoint || ''} · 取于 ${seller?.fetched_at ? new Date(Number(seller.fetched_at)).toISOString() : '未知时刻'})`
1792
+ : `「其中经 x402 流程」= **未知** (不是 0): ${x402UnknownWhy.zh}`;
1793
+ notes.push(`顶部链上计数 = 链上索引的**同源计数** (= activity_totals, 与下表同源, 同源即恒等): ` +
1794
+ `tasks=${totalsTasks} · tasks_completed=${totalsCompleted} · tasks_settled(释放给卖方)=${totalsSettled} · ` +
1795
+ `tasks_refunded=${totalsRefunded === null ? '未接入' : totalsRefunded} · tasks_disputed=${totalsDisputed === null ? '未接入' : totalsDisputed} · ` +
1796
+ `链上转入=${totalsPaymentsIn === null ? '没有源 → 不下发该字段 (不是 0)' : `${totalsPaymentsIn} 笔 (合计 ${activity_totals.payments_in_total_atomic} 原子)`}; ` +
1797
+ `${x402Note}; ` +
1798
+ `「节点/智能体」= 本节点 ${WINDOW_HOURS}h 脉冲事件 (**非链上**, 页面计数区不列); 「已验证」在链上索引里没有对应事件 → ` +
1197
1799
  `该口径无对应事件源, 不下发该字段 (不是 0); 「钱包签名」= ${auditWired
1198
1800
  ? `本机签名审计账 ${totalsSignatures} 条 (${WINDOW_HOURS}h 窗口, 只计数)`
1199
1801
  : (totalsSignatures === null
@@ -1221,13 +1823,77 @@ export function computeSnapshot(events, opts) {
1221
1823
  tasks_completed: totalsCompleted,
1222
1824
  tasks_verified: totalsVerified, // 链上口径下 = null (未接入), 不冒充 0
1223
1825
  signatures: totalsSignatures, // 真源 = 本机签名审计账; 无源 = null (未接入)
1224
- tasks_settled: totalsSettled, // 新增字段一律排最后 (老 8 个字段顺序逐字不变)
1826
+ tasks_settled: totalsSettled, // 只算 Released (钱真释放给卖方)
1827
+ // ★ 2026-09-29 追加 (新增字段一律排最后; 老字段的顺序逐字不变)
1828
+ tasks_refunded: totalsRefunded, // 争议后退款 (不是成交); 无源 = null
1829
+ tasks_disputed: totalsDisputed, // 争议中; 无源 = null
1830
+ payments_in: totalsPaymentsIn, // 关注地址集内转入笔数; 未配置/不可用 = null
1831
+ payments_in_total_atomic: totalsPaymentsInAtomic, // 转入合计 (原子单位); 同上
1832
+ payments_in_x402: totalsPaymentsX402, // 其中经 x402 流程 (卖方台账交叉核); 未知 = null
1833
+ payments_in_x402_total_atomic: totalsPaymentsX402Atomic,
1834
+ // 展示用折算 (非门禁字段: 门只看原子值, 避免浮点参与判定)
1835
+ payments_in_total_usdc: paymentsKnown ? formatUnits(activity_totals.payments_in_total_atomic, transferSummary?.token_decimals ?? 6) : null,
1836
+ payments_in_x402_total_usdc: x402Known ? formatUnits(activity_totals.payments_in_x402_total_atomic || '0', transferSummary?.token_decimals ?? 6) : null,
1837
+ payments_in_currency: paymentsKnown ? (transferSummary?.token_symbol || null) : null,
1225
1838
  },
1226
1839
  totals_scope,
1840
+ /** 链上转入的**分桶明细** (纯数据: 页面/机器清单都能用; 数字全部来自同一批行) */
1841
+ transfer_totals: transferSummary
1842
+ ? {
1843
+ configured: transferSynced,
1844
+ reason: transferSummary.reason,
1845
+ token_symbol: transferSummary.token_symbol,
1846
+ token_decimals: transferSummary.token_decimals,
1847
+ inbound: transferSummary.inbound,
1848
+ inbound_display: transferSummary.inbound_display,
1849
+ inbound_total_atomic: transferSummary.inbound_atomic,
1850
+ by_class: transferSummary.by_class,
1851
+ by_class_atomic: transferSummary.by_class_atomic,
1852
+ outbound: transferSummary.outbound,
1853
+ from_block: transferSummary.from_block,
1854
+ last_synced_block: transferSummary.last_synced_block,
1855
+ pending_observation: transferSummary.pending_observation,
1856
+ }
1857
+ : null,
1858
+ /** 卖方端点只读汇总的取数状态 (available=false ⇒ 「经 x402 流程」是**未知**, 不是 0) */
1859
+ x402_ledger: seller
1860
+ ? { available: seller.available === true, reason: seller.reason, endpoint: seller.endpoint, fetched_at: seller.fetched_at, count: seller.available === true ? seller.count : null }
1861
+ : null,
1862
+ /** 链上索引的覆盖口径 (覆盖什么 / 起止块 / 落后多少块) */
1863
+ index_scope: buildIndexScope({
1864
+ chainId: activity.index_info?.chainId ?? null,
1865
+ networkName: activity.index_info?.networkName ?? null,
1866
+ escrow: activity.index_info
1867
+ ? {
1868
+ deploymentBlock: activity.index_info.deploymentBlock,
1869
+ lastSyncedBlock: activity.index_info.lastSyncedBlock,
1870
+ headBlock: activity.index_info.headBlock,
1871
+ rows: activity.index_info.rows,
1872
+ pageSize: activity.index_info.pageSize,
1873
+ }
1874
+ : null,
1875
+ transfers: transferSummary,
1876
+ liveHeadBlock: opts.liveHeadBlock ?? null,
1877
+ }),
1227
1878
  capabilities,
1228
1879
  recent_activity: recent,
1229
1880
  confirmed_activity: activity.rows,
1230
1881
  confirmed_activity_source: activity.source,
1882
+ /** ★ 付款方智能体的**链下登记**取数口径 (2026-09-29): 只有已验签的绑定才让行里多出 payer_identity */
1883
+ payer_identity_scope: activity.payer_identity
1884
+ ? {
1885
+ loaded: activity.payer_identity.loaded,
1886
+ verified: activity.payer_identity.verified,
1887
+ rejected: activity.payer_identity.rejected.length,
1888
+ rejected_reasons: activity.payer_identity.rejected,
1889
+ rows_with_identity: activity.rows.filter((r) => !!r.payer_identity).length,
1890
+ reason: activity.payer_identity.reason,
1891
+ label: activity.payer_identity.label,
1892
+ note: activity.payer_identity.note,
1893
+ method: PAYER_IDENTITY_METHOD,
1894
+ source: PAYER_IDENTITY_SOURCE,
1895
+ }
1896
+ : null,
1231
1897
  activity_totals,
1232
1898
  chain_id_scope,
1233
1899
  open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
@@ -1251,7 +1917,11 @@ export async function getNetworkPulse(opts = {}) {
1251
1917
  && !!cached?.totals_scope?.fields // 老缓存没有逐字段口径 → 过期形状, 重算
1252
1918
  && !!cached?.activity_totals
1253
1919
  && !!cached?.chain_id_scope
1254
- && Array.isArray(cached?.open_tasks); // 老缓存没有待接单任务 → 过期形状, 重算
1920
+ && Array.isArray(cached?.open_tasks) // 老缓存没有待接单任务 → 过期形状, 重算
1921
+ && 'index_scope' in cached // 2026-09-29: 老缓存没有覆盖口径 → 重算
1922
+ && 'transfer_totals' in cached // 2026-09-29: 老缓存没有转入分桶 → 重算
1923
+ && 'payer_identity_scope' in cached // 2026-09-29: 老缓存没有付款方身份口径 → 重算
1924
+ && typeof cached?.totals?.tasks_refunded !== 'undefined';
1255
1925
  if (shapeOk && cached && Number.isFinite(cached.generated_at) && cached.generated_at + PULSE_LIMITS.snapshotTtlMs > now)
1256
1926
  return cached;
1257
1927
  }
@@ -1264,13 +1934,22 @@ export async function getNetworkPulse(opts = {}) {
1264
1934
  catch (e) {
1265
1935
  return computeSnapshot([], { now, unavailable: true });
1266
1936
  }
1267
- // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
1268
- const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
1937
+ // 活动行优先取 P5 链上索引 (真链上事实) + 关注地址集转账索引 (链上收款); 索引不可用 → 退回脉冲事件
1938
+ const confirmedActivity = await resolveConfirmedActivity({
1939
+ home: opts.home, events, now,
1940
+ readIndex: opts.readIndex,
1941
+ readTransfers: opts.readTransfers,
1942
+ readSellerSummary: opts.readSellerSummary,
1943
+ readPayerIdentities: opts.readPayerIdentities,
1944
+ });
1269
1945
  // 钱包签名的**真源**: 本机签名审计账 (窗口内条数)。没有账 → 注入 available:false → 快照标「未接入」
1270
1946
  // (不拿 0 冒充「没发生过」: 本机明明签过而脉冲事件丢了的那次真事故就是这么来的)
1271
1947
  const signatureAudit = tallySignatureAudit(opts.home, { now, windowMs: PULSE_LIMITS.windowMs });
1272
1948
  // 待接单任务: 只读本机公告板目录 (未认领且未过期), 投影成白名单 7 字段 —— 与脉冲事件层无关
1273
- const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now), signatureAudit });
1949
+ const snap = computeSnapshot(events, {
1950
+ now, confirmedActivity, openTasks: readOpenTasks(opts.home, now), signatureAudit,
1951
+ liveHeadBlock: opts.liveHeadBlock ?? null,
1952
+ });
1274
1953
  try {
1275
1954
  fs.mkdirSync(pulseDir(opts.home), { recursive: true });
1276
1955
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');