@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
@@ -0,0 +1,284 @@
1
+ /**
2
+ * chain-settlement.ts — 「链上到底结算了没有」的唯一判定 (P3, 修 F5)
3
+ *
4
+ * 真问题 F5 (原样引):
5
+ * `chainSettled = !!txHash` —— 拿到 txHash 就判链上结算, 从不读 receipt/事件/确认数。
6
+ *
7
+ * 本文件的规矩 (硬规则, 不许放宽):
8
+ * ① 只有 receipt.status == 1 **且** 确认数 >= 配置门槛 **且** 事件对得上 → 才 `chainSettled: true`
9
+ * ② 拿不到 RPC / receipt 读不到 / 确认数不够 → 一律如实回「未结算 / 不确定」, `chainSettled: false`
10
+ * ③ 已经被认过的交易「不见了」或「换了块」→ 判重组 (`reorged`), **不是**已结算
11
+ * ④ `local-dev` 永远进不来这里 (这里只认链上事实)
12
+ *
13
+ * 判定只看链上原始事实 (receipt / logs / 合约状态), 不看谁说的。
14
+ */
15
+ import { Interface } from 'ethers';
16
+ import { EscrowClient, } from './escrow-client.js';
17
+ import { loadChainConfig, DEFAULT_CONFIRMATIONS, } from './chain-config.js';
18
+ /** ERC20 Transfer 事件 topic0 */
19
+ export const ERC20_TRANSFER_TOPIC0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
20
+ const ERC20_IFACE = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']);
21
+ const ZERO32 = '0x0000000000000000000000000000000000000000000000000000000000000000';
22
+ function base(partial) {
23
+ return {
24
+ chainSettled: false,
25
+ confirmationsRequired: 0,
26
+ requiredGate: 'confirmed',
27
+ rpcAvailable: true,
28
+ checkedAt: Date.now(),
29
+ evidence: {},
30
+ ...partial,
31
+ };
32
+ }
33
+ /**
34
+ * ★ 唯一判定入口。
35
+ * 只用链上原始事实; 任何一步读不到 → `chainSettled: false` + 说明为什么。
36
+ */
37
+ export async function verifyChainSettlement(client, opts) {
38
+ const cfgConf = opts.confirmations || client.confirmations || DEFAULT_CONFIRMATIONS;
39
+ const gate = opts.gate || 'confirmed';
40
+ const required = gate === 'finalized' ? cfgConf.finalized : cfgConf.confirmed;
41
+ const expect = opts.expect || { kind: 'none' };
42
+ const txHash = opts.txHash ? String(opts.txHash) : '';
43
+ // ① 没 txHash: 从来没发过链上交易
44
+ if (!txHash) {
45
+ return base({
46
+ status: 'not_attempted',
47
+ reason: '没有 txHash: 这不是一笔链上交易, 不能判链上结算',
48
+ confirmationsRequired: required, requiredGate: gate,
49
+ });
50
+ }
51
+ // ② receipt (读不到 = rpcAvailable false, 不是"没结算")
52
+ let rc;
53
+ try {
54
+ rc = await client.getReceipt(txHash);
55
+ }
56
+ catch (e) {
57
+ return base({
58
+ status: 'unknown',
59
+ reason: `读 receipt 失败 (RPC 不可用?): ${String(e?.message || e).slice(0, 160)} —— 付款状态未知, 不等于失败, 也不等于已结算`,
60
+ txHash, confirmationsRequired: required, requiredGate: gate,
61
+ rpcAvailable: false, evidence: { receiptError: String(e?.message || e).slice(0, 200) },
62
+ });
63
+ }
64
+ if (!rc) {
65
+ // 链上没有这笔了 —— 如果之前认过它, 那就是重组
66
+ if (opts.recorded && opts.recorded.blockNumber != null) {
67
+ return base({
68
+ status: 'reorged',
69
+ reason: `曾记在块 ${opts.recorded.blockNumber} 的交易现在链上查不到 (重组?), 不可信, 不能当已结算`,
70
+ txHash, confirmationsRequired: required, requiredGate: gate,
71
+ eventMatched: null, evidence: { recorded: opts.recorded },
72
+ });
73
+ }
74
+ return base({
75
+ status: 'unknown',
76
+ reason: 'receipt 为空: 交易还没被打包 (或在内存池里) —— 未结算, 也不等于失败',
77
+ txHash, confirmationsRequired: required, requiredGate: gate,
78
+ evidence: { receipt: null },
79
+ });
80
+ }
81
+ const blockNumber = Number(rc.blockNumber);
82
+ // ③ latestBlock (数确认数要用; 读不到 = 不知道)
83
+ let latestBlock;
84
+ try {
85
+ latestBlock = await client.getLatestBlockNumber();
86
+ }
87
+ catch (e) {
88
+ return base({
89
+ status: 'unknown',
90
+ reason: `读不到最新块高, 无法数确认数: ${String(e?.message || e).slice(0, 160)}`,
91
+ txHash, blockNumber, confirmationsRequired: required, requiredGate: gate,
92
+ rpcAvailable: false, evidence: { receiptStatus: Number(rc.status) },
93
+ });
94
+ }
95
+ const confirmations = latestBlock - blockNumber + 1;
96
+ // ④ receipt status: 0 = 明确失败, 钱一定没动
97
+ if (Number(rc.status) !== 1) {
98
+ return base({
99
+ status: 'reverted',
100
+ reason: `receipt.status = ${Number(rc.status)}: 交易回滚了, 链上没结算 (钱没动)`,
101
+ txHash, blockNumber, confirmations, latestBlock,
102
+ confirmationsRequired: required, requiredGate: gate,
103
+ evidence: { receiptStatus: Number(rc.status) },
104
+ });
105
+ }
106
+ // ⑤ 重组检测: 同一 txHash 出现在**另一个**块里
107
+ if (opts.recorded && opts.recorded.blockNumber != null && Number(opts.recorded.blockNumber) !== blockNumber) {
108
+ return base({
109
+ status: 'reorged',
110
+ reason: `重组: 同一 txHash 上次记在块 ${opts.recorded.blockNumber}, 现在是块 ${blockNumber} —— 链被重排过, 旧确认不可信`,
111
+ txHash, blockNumber, confirmations, latestBlock,
112
+ confirmationsRequired: required, requiredGate: gate,
113
+ evidence: { recordedBlock: opts.recorded.blockNumber, currentBlock: blockNumber },
114
+ });
115
+ }
116
+ // ⑥ 事件核对 (按预期类型)
117
+ const logs = (rc.logs || []).map((l) => ({ address: l.address, topics: l.topics, data: l.data, blockNumber: l.blockNumber, index: l.index }));
118
+ let eventMatched = null;
119
+ let matchedEvent;
120
+ let escrowState = null;
121
+ const evEvidence = { logCount: logs.length };
122
+ if (expect.kind === 'escrow') {
123
+ if (!expect.taskKey) {
124
+ return base({
125
+ status: 'event_mismatch',
126
+ reason: '要求核对 escrow 事件但没给 taskKey → 核不了, 不敢判已结算',
127
+ txHash, blockNumber, confirmations, latestBlock,
128
+ confirmationsRequired: required, requiredGate: gate, eventMatched: false,
129
+ });
130
+ }
131
+ const decoded = client.decodeV2Events({ txHash, blockNumber, gasUsed: null, status: 1, reverted: false, logs, broadcast: true });
132
+ const wanted = expect.eventName ? decoded.filter((d) => d.name === expect.eventName) : decoded;
133
+ const hit = wanted.find((d) => {
134
+ const tk = String(d.args.taskKey || '').toLowerCase();
135
+ if (tk !== String(expect.taskKey).toLowerCase())
136
+ return false;
137
+ if (expect.resultHash) {
138
+ const rh = String(d.args.resultHash || '').toLowerCase();
139
+ if (rh !== String(expect.resultHash).toLowerCase())
140
+ return false;
141
+ }
142
+ return true;
143
+ });
144
+ eventMatched = !!hit;
145
+ matchedEvent = hit?.name;
146
+ evEvidence.decodedEvents = decoded.map((d) => d.name);
147
+ evEvidence.expectedTaskKey = expect.taskKey;
148
+ // 读合约里这条 escrow 的真实状态
149
+ try {
150
+ const e = await client.getEscrow(expect.taskKey);
151
+ escrowState = e ? e.stateName : null;
152
+ evEvidence.escrowPresent = !!e;
153
+ if (e)
154
+ evEvidence.escrowState = e.stateName;
155
+ }
156
+ catch (e) {
157
+ evEvidence.escrowReadError = String(e?.message || e).slice(0, 160);
158
+ }
159
+ if (!hit) {
160
+ return base({
161
+ status: 'event_mismatch',
162
+ reason: `receipt 里有 ${decoded.length} 条本合约事件, 但没有一条 taskKey=${expect.taskKey}` +
163
+ (expect.eventName ? ` 且事件=${expect.eventName}` : '') +
164
+ (expect.resultHash ? ` 且 resultHash=${expect.resultHash}` : '') + ' —— 这不是我们要的那笔, 不能当已结算',
165
+ txHash, blockNumber, confirmations, latestBlock,
166
+ confirmationsRequired: required, requiredGate: gate,
167
+ taskKey: expect.taskKey, resultHash: expect.resultHash,
168
+ eventMatched, matchedEvent, escrowState, evidence: evEvidence,
169
+ });
170
+ }
171
+ if (expect.expectEscrowState && escrowState !== expect.expectEscrowState) {
172
+ return base({
173
+ status: 'event_mismatch',
174
+ reason: `事件对得上但合约状态是 ${escrowState ?? '(读不到)'}, 期望 ${expect.expectEscrowState}`,
175
+ txHash, blockNumber, confirmations, latestBlock,
176
+ confirmationsRequired: required, requiredGate: gate,
177
+ taskKey: expect.taskKey, eventMatched, matchedEvent, escrowState, evidence: evEvidence,
178
+ });
179
+ }
180
+ }
181
+ else if (expect.kind === 'erc20_transfer') {
182
+ if (!expect.tokenAddress) {
183
+ return base({
184
+ status: 'unknown',
185
+ reason: '要核对 ERC20 Transfer 但没给 tokenAddress → 核不了, 不能判已结算',
186
+ txHash, blockNumber, confirmations, latestBlock,
187
+ confirmationsRequired: required, requiredGate: gate, eventMatched: null,
188
+ });
189
+ }
190
+ const transfers = logs.filter((l) => String(l.address).toLowerCase() === String(expect.tokenAddress).toLowerCase() &&
191
+ String(l.topics?.[0] || '').toLowerCase() === ERC20_TRANSFER_TOPIC0);
192
+ const match = transfers.find((l) => {
193
+ try {
194
+ const p = ERC20_IFACE.parseLog({ topics: [...l.topics], data: l.data });
195
+ if (!p)
196
+ return false;
197
+ const from = String(p.args.from).toLowerCase();
198
+ const to = String(p.args.to).toLowerCase();
199
+ const value = BigInt(p.args.value);
200
+ if (expect.to && to !== String(expect.to).toLowerCase())
201
+ return false;
202
+ if (expect.from && from !== String(expect.from).toLowerCase())
203
+ return false;
204
+ if (expect.minAmount != null && value < expect.minAmount)
205
+ return false;
206
+ return true;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ });
212
+ eventMatched = !!match;
213
+ matchedEvent = match ? 'Transfer' : undefined;
214
+ evEvidence.transferLogs = transfers.length;
215
+ if (!match) {
216
+ return base({
217
+ status: 'event_mismatch',
218
+ reason: `receipt 里没有匹配的 ERC20 Transfer (token=${expect.tokenAddress}, 收了 ${transfers.length} 条 Transfer) —— 不是这笔付款, 不能当已结算`,
219
+ txHash, blockNumber, confirmations, latestBlock,
220
+ confirmationsRequired: required, requiredGate: gate, eventMatched, evidence: evEvidence,
221
+ });
222
+ }
223
+ }
224
+ else {
225
+ eventMatched = null; // 没要求核对事件
226
+ }
227
+ // ⑦ 确认数门槛: 不够 → pending (明确"还没到", 不是"已结算")
228
+ if (confirmations < required) {
229
+ return base({
230
+ status: 'pending',
231
+ reason: `确认数 ${confirmations} < 门槛 ${required} (${gate}) → 还没到可以判结算的程度`,
232
+ txHash, blockNumber, confirmations, latestBlock,
233
+ confirmationsRequired: required, requiredGate: gate,
234
+ taskKey: expect.taskKey, resultHash: expect.resultHash,
235
+ eventMatched, matchedEvent, escrowState, evidence: evEvidence,
236
+ });
237
+ }
238
+ // ⑧ 全过 → 结算成立
239
+ const status = confirmations >= cfgConf.finalized ? 'finalized' : 'confirmed';
240
+ return base({
241
+ chainSettled: true,
242
+ status,
243
+ reason: `receipt.status=1, 确认数 ${confirmations} >= ${required} (${gate})` +
244
+ (matchedEvent ? `, 事件 ${matchedEvent} 对得上` : '') + ` → 链上结算成立`,
245
+ txHash, blockNumber, confirmations, latestBlock,
246
+ confirmationsRequired: required, requiredGate: gate,
247
+ taskKey: expect.taskKey, resultHash: expect.resultHash,
248
+ eventMatched, matchedEvent, escrowState, evidence: evEvidence,
249
+ });
250
+ }
251
+ /** 用现成 client 造验证器 */
252
+ export function createChainSettlementVerifier(client, defaults = {}) {
253
+ return (req) => verifyChainSettlement(client, { ...req, gate: req.gate || defaults.gate || 'confirmed' });
254
+ }
255
+ /**
256
+ * 缺省验证器: 用本机配置 (env → ~/.bolloon/chain.json → 报错) 造 client。
257
+ * 配置取不到 → **不抛** (付款路径不能被配置问题炸掉), 而是返回一个永远
258
+ * `chainSettled: false` + `config_unavailable` 的诚实判定:
259
+ * 「我没法验证 → 不能宣称链上已验证」。
260
+ */
261
+ export function createDefaultChainSettlementVerifier(opts = {}) {
262
+ let client = null;
263
+ let loadError = null;
264
+ try {
265
+ const cfg = loadChainConfig({ home: opts.home, env: opts.env });
266
+ client = opts.clientFactory ? opts.clientFactory(cfg) : new EscrowClient({ config: cfg });
267
+ }
268
+ catch (e) {
269
+ loadError = String(e?.message || e).slice(0, 300);
270
+ }
271
+ if (!client) {
272
+ const err = loadError;
273
+ return async (req) => base({
274
+ status: 'config_unavailable',
275
+ reason: `链配置取不到, 无法做真链验证 → 不能判链上结算: ${err}`,
276
+ txHash: req.txHash,
277
+ confirmationsRequired: 0, requiredGate: req.gate || 'confirmed',
278
+ rpcAvailable: false,
279
+ taskKey: req.expect?.taskKey,
280
+ evidence: { configError: err },
281
+ });
282
+ }
283
+ return createChainSettlementVerifier(client, { gate: opts.gate });
284
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * chain-state-store.ts — 链上状态的持久化 / 重启恢复 / 重组对账 (P3, ④)
3
+ *
4
+ * 为什么必须有:
5
+ * 进程会被杀, 会重启, 会被 rollback。重启后必须能回答:
6
+ * 「哪些交易已上链 / 待确认 / 已确认 / 被重组」—— 而不是靠内存里的对象猜。
7
+ *
8
+ * 落盘位置: `~/.bolloon/chain/chain-state.json` (原子写: tmp + rename)。
9
+ * 每条记录必须带的**关键状态**:
10
+ * 链上 escrow 地址 · txHash · taskKey · 确认数 · 最后检查块 · 判定状态 · 是否不可信
11
+ *
12
+ * 重组的两条检出路径 (都**不**静默当已结算):
13
+ * ① 同一 txHash 出现在另一个块号 (或链上查不到了) → `reorged` + suspect=true
14
+ * ② 之前核上的事件在链上"消失" (logs 里没了) → `event_mismatch` + suspect=true
15
+ *
16
+ * 本模块只负责"记"和"对账", 判定本身仍只由 chain-settlement.ts 给。
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as fsp from 'fs/promises';
20
+ import * as path from 'path';
21
+ import { bolloonHome } from './chain-config.js';
22
+ import { verifyChainSettlement } from './chain-settlement.js';
23
+ export const CHAIN_STATE_SCHEMA_VERSION = 1;
24
+ /**
25
+ * ★ 对账之后「仍然已结算」的 requestId 集合 = 本轮重算为 confirmed/finalized 的
26
+ * **加上** 已 finalized 被按设计跳过的。
27
+ *
28
+ * 存在的理由: `skipFinalized` 是省 RPC 的优化, 但它的副作用是让 `confirmed` 少了人 ——
29
+ * 用它来判断「这条还没对账 / 没结算」就会得出**错的**结论。这个函数把两种事实合起来,
30
+ * 让「跳过」不可能被误读成「没结算」。
31
+ */
32
+ export function reconciledSettledIds(report) {
33
+ return Array.from(new Set([
34
+ ...report.confirmed.map((r) => r.requestId),
35
+ ...report.skippedFinalized.map((r) => r.requestId),
36
+ ]));
37
+ }
38
+ export function chainStateDir(home) {
39
+ return path.join(bolloonHome(home), 'chain');
40
+ }
41
+ export function chainStatePath(home) {
42
+ return path.join(chainStateDir(home), 'chain-state.json');
43
+ }
44
+ export function emptyChainState() {
45
+ return { schemaVersion: CHAIN_STATE_SCHEMA_VERSION, updatedAt: Date.now(), escrowAddresses: [], records: {} };
46
+ }
47
+ export function loadChainState(home) {
48
+ const p = chainStatePath(home);
49
+ try {
50
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
51
+ if (j && typeof j === 'object' && j.records && typeof j.records === 'object') {
52
+ return {
53
+ schemaVersion: Number(j.schemaVersion) || CHAIN_STATE_SCHEMA_VERSION,
54
+ updatedAt: Number(j.updatedAt) || 0,
55
+ escrowAddresses: Array.isArray(j.escrowAddresses) ? j.escrowAddresses.map(String) : [],
56
+ records: j.records,
57
+ };
58
+ }
59
+ }
60
+ catch { /* 没有 / 坏了 → 空状态 (不猜) */ }
61
+ return emptyChainState();
62
+ }
63
+ export async function saveChainState(state, home) {
64
+ const dir = chainStateDir(home);
65
+ await fsp.mkdir(dir, { recursive: true });
66
+ const p = chainStatePath(home);
67
+ const tmp = `${p}.tmp`;
68
+ const out = { ...state, updatedAt: Date.now() };
69
+ // escrowAddresses 从记录里重建 (别让两份真相漂开)
70
+ out.escrowAddresses = Array.from(new Set(Object.values(out.records).map((r) => r.escrowAddress))).filter(Boolean);
71
+ await fsp.writeFile(tmp, JSON.stringify(out, null, 2), 'utf8');
72
+ await fsp.rename(tmp, p);
73
+ }
74
+ /** 落盘一条记录 (幂等 upsert; 追加 history) */
75
+ export async function upsertChainTx(rec, home) {
76
+ const state = loadChainState(home);
77
+ const prev = state.records[rec.requestId];
78
+ const now = Date.now();
79
+ const merged = {
80
+ ...(prev || {}),
81
+ ...rec,
82
+ suspect: rec.suspect ?? prev?.suspect ?? false,
83
+ firstSeenAt: prev?.firstSeenAt ?? rec.firstSeenAt ?? now,
84
+ updatedAt: now,
85
+ history: [
86
+ ...(prev?.history || rec.history || []),
87
+ { at: now, status: rec.status, txHash: rec.txHash, blockNumber: rec.blockNumber, confirmations: rec.confirmations, reason: rec.reason },
88
+ ],
89
+ };
90
+ state.records[rec.requestId] = merged;
91
+ await saveChainState(state, home);
92
+ return merged;
93
+ }
94
+ /** 把一次判定落盘 (verdict → record) */
95
+ export async function recordVerdict(base, verdict, home) {
96
+ const suspectStatuses = ['reorged'];
97
+ const prev = loadChainState(home).records[base.requestId];
98
+ const suspect = suspectStatuses.includes(verdict.status)
99
+ ? true
100
+ : (prev?.suspect || false);
101
+ return upsertChainTx({
102
+ ...base,
103
+ resultHash: base.resultHash ?? prev?.resultHash,
104
+ confirmations: verdict.confirmations ?? 0,
105
+ lastCheckedBlock: verdict.latestBlock ?? prev?.lastCheckedBlock ?? null,
106
+ // ★ 新判定没带块号时, **保留旧事实里的块号** —— "这笔曾记在哪个块"是重组复核的唯一证据;
107
+ // 判定成 reorged 时判定器不带块号, 若这里覆盖成 null, 下一次对账就只能报 unknown
108
+ // (说不出"它是被回滚的"), 重组证据被自己抹掉。
109
+ blockNumber: verdict.blockNumber ?? prev?.blockNumber ?? null,
110
+ status: verdict.status,
111
+ suspect,
112
+ suspectReason: verdict.status === 'reorged' ? verdict.reason : prev?.suspectReason,
113
+ reason: verdict.reason,
114
+ eventMatched: verdict.eventMatched ?? null,
115
+ matchedEvent: verdict.matchedEvent,
116
+ escrowState: verdict.escrowState ?? null,
117
+ }, home);
118
+ }
119
+ /**
120
+ * 从**持久记录**重建状态 (进程重启后第一步就该调它)。
121
+ * 不联网: 只用落盘的事实。`suspect` 记录一律不算 settled。
122
+ */
123
+ export function recoverChainState(home) {
124
+ const state = loadChainState(home);
125
+ const all = Object.values(state.records);
126
+ const settled = all.filter((r) => (r.status === 'confirmed' || r.status === 'finalized') && !r.suspect);
127
+ const byStatus = (s) => all.filter((r) => r.status === s);
128
+ return {
129
+ total: all.length,
130
+ settled,
131
+ pending: byStatus('pending'),
132
+ reorged: byStatus('reorged'),
133
+ unknown: byStatus('unknown').concat(byStatus('config_unavailable')),
134
+ reverted: byStatus('reverted'),
135
+ eventMismatch: byStatus('event_mismatch'),
136
+ suspect: all.filter((r) => r.suspect),
137
+ escrowAddresses: state.escrowAddresses,
138
+ lastCheckedBlock: all.reduce((acc, r) => (r.lastCheckedBlock != null && (acc == null || r.lastCheckedBlock > acc) ? r.lastCheckedBlock : acc), null),
139
+ };
140
+ }
141
+ /**
142
+ * ★ 对账: 把每条记录的**旧事实** (blockNumber/confirmations/eventMatched)
143
+ * 交给唯一判定器重算一遍, 并按结果落盘。
144
+ *
145
+ * 报告**不许静默丢记录**: 范围内的每条记录要么进 `scanned` 的判定结果, 要么进
146
+ * `skippedFinalized` (不变式: `considered === scanned + skippedFinalized.length`)。
147
+ *
148
+ * 重组两条路径都在这里显式检出:
149
+ * · 块号变了 / 链上查不到了 → verdict.status = 'reorged' → suspect = true
150
+ * · 事件"消失"了 (之前 eventMatched=true, 现在 false) → suspect = true
151
+ */
152
+ export async function reconcileChainState(client, opts = {}) {
153
+ const state = loadChainState(opts.home);
154
+ const report = {
155
+ checkedAt: Date.now(), considered: 0, scanned: 0,
156
+ confirmed: [], skippedFinalized: [], pending: [], reorged: [], unknown: [], reverted: [], newlySuspect: [], rpcErrors: 0,
157
+ };
158
+ const gate = opts.gate || 'confirmed';
159
+ const shouldSkipFinalized = opts.skipFinalized !== false;
160
+ for (const rec of Object.values(state.records)) {
161
+ if (opts.only && !opts.only.includes(rec.requestId))
162
+ continue;
163
+ report.considered++;
164
+ const isFinalized = rec.status === 'finalized' && !rec.suspect;
165
+ if (shouldSkipFinalized && isFinalized) {
166
+ // ★ 显式记账: 「跳过」必须被说出来。否则报告会把「已最终确定」表达成「查不到这条」,
167
+ // 调用方 (账单/验收) 就会把已结算当成没对账 —— 那是会撒谎的门。
168
+ report.skippedFinalized.push({
169
+ requestId: rec.requestId, taskKey: rec.taskKey, txHash: rec.txHash,
170
+ status: rec.status, confirmations: rec.confirmations,
171
+ reason: `已是 finalized 且未被标可疑 → 按设计跳过复核 (skipFinalized=true)。它仍是已结算, 只是本轮没重算`,
172
+ });
173
+ continue;
174
+ }
175
+ report.scanned++;
176
+ const verdict = await verifyChainSettlement(client, {
177
+ txHash: rec.txHash,
178
+ gate,
179
+ expect: rec.taskKey ? { kind: 'escrow', taskKey: rec.taskKey, resultHash: rec.resultHash } : undefined,
180
+ recorded: { blockNumber: rec.blockNumber, confirmations: rec.confirmations, status: rec.status },
181
+ });
182
+ // 事件"消失" = 重组 (旧判定说对得上, 现在对不上)
183
+ const eventVanished = rec.eventMatched === true && verdict.eventMatched === false && verdict.status === 'event_mismatch';
184
+ const prevSuspect = rec.suspect;
185
+ const effective = eventVanished
186
+ ? { ...verdict, status: 'reorged', chainSettled: false, reason: `重组: 之前核上的事件 ${rec.matchedEvent || ''} 在链上消失了 (${verdict.reason})` }
187
+ : verdict;
188
+ const after = await recordVerdict({ requestId: rec.requestId, method: rec.method, taskKey: rec.taskKey, escrowAddress: rec.escrowAddress, chainId: rec.chainId, txHash: rec.txHash, resultHash: rec.resultHash }, effective, opts.home);
189
+ // 事件消失也要显式标不可信 (recordVerdict 只对 'reorged' 自动标)
190
+ if (eventVanished && !after.suspect) {
191
+ const st = loadChainState(opts.home);
192
+ if (st.records[rec.requestId]) {
193
+ st.records[rec.requestId].suspect = true;
194
+ st.records[rec.requestId].suspectReason = '事件在链上消失 (重组)';
195
+ await saveChainState(st, opts.home);
196
+ }
197
+ }
198
+ const now = loadChainState(opts.home).records[rec.requestId] || rec;
199
+ if (now.suspect && !prevSuspect)
200
+ report.newlySuspect.push(rec.requestId);
201
+ if (!effective.rpcAvailable)
202
+ report.rpcErrors++;
203
+ const item = { requestId: rec.requestId, taskKey: rec.taskKey, txHash: rec.txHash };
204
+ switch (effective.status) {
205
+ case 'reorged':
206
+ report.reorged.push({ ...item, reason: effective.reason });
207
+ break;
208
+ case 'reverted':
209
+ report.reverted.push({ ...item, reason: effective.reason });
210
+ break;
211
+ case 'pending':
212
+ report.pending.push({ ...item, confirmations: effective.confirmations ?? 0, required: effective.confirmationsRequired });
213
+ break;
214
+ case 'confirmed':
215
+ case 'finalized':
216
+ report.confirmed.push({ ...item, status: effective.status, confirmations: effective.confirmations ?? 0 });
217
+ break;
218
+ default:
219
+ // unknown / config_unavailable / event_mismatch / not_attempted → 一律进 unknown (诚实: 不知道)
220
+ report.unknown.push({ ...item, reason: effective.reason });
221
+ break;
222
+ }
223
+ }
224
+ return report;
225
+ }
226
+ /** 人工/程序显式标不可信 (绝不静默) */
227
+ export async function markSuspect(requestId, reason, home) {
228
+ const state = loadChainState(home);
229
+ const rec = state.records[requestId];
230
+ if (!rec)
231
+ return null;
232
+ rec.suspect = true;
233
+ rec.suspectReason = reason;
234
+ rec.updatedAt = Date.now();
235
+ rec.history.push({ at: rec.updatedAt, status: rec.status, txHash: rec.txHash, blockNumber: rec.blockNumber, confirmations: rec.confirmations, reason: `标记不可信: ${reason}` });
236
+ await saveChainState(state, home);
237
+ return rec;
238
+ }
239
+ export function getChainTxRecord(requestId, home) {
240
+ return loadChainState(home).records[requestId] || null;
241
+ }
242
+ export function findByTxHash(txHash, home) {
243
+ const h = String(txHash || '').toLowerCase();
244
+ return Object.values(loadChainState(home).records).find((r) => String(r.txHash).toLowerCase() === h) || null;
245
+ }