@bolloon/bolloon-agent 0.4.31 → 0.4.32

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 (40) 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/index.js +20 -0
  9. package/dist/agents/chain/onchain-trade.js +691 -0
  10. package/dist/agents/network-pulse.js +464 -2
  11. package/dist/agents/task/task-onchain-runner.js +284 -0
  12. package/dist/agents/x402/paid-info-store.js +75 -6
  13. package/dist/cli/commands/chain.js +926 -0
  14. package/dist/cli/commands/index.js +10 -2
  15. package/dist/cli/mcp/server.js +4 -1
  16. package/dist/cli/mcp/tools.js +348 -5
  17. package/dist/cli/protocol-envelope.js +12 -0
  18. package/dist/cli-entry.js +5 -0
  19. package/dist/electron-build/agents/chain/chain-config.js +556 -0
  20. package/dist/electron-build/agents/chain/chain-config.js.map +1 -0
  21. package/dist/electron-build/agents/chain/chain-index-query.js +145 -0
  22. package/dist/electron-build/agents/chain/chain-index-query.js.map +1 -0
  23. package/dist/electron-build/agents/chain/chain-indexer.js +1095 -0
  24. package/dist/electron-build/agents/chain/chain-indexer.js.map +1 -0
  25. package/dist/electron-build/agents/chain/chain-settlement.js +291 -0
  26. package/dist/electron-build/agents/chain/chain-settlement.js.map +1 -0
  27. package/dist/electron-build/agents/chain/escrow-client.js +443 -0
  28. package/dist/electron-build/agents/chain/escrow-client.js.map +1 -0
  29. package/dist/electron-build/agents/network-pulse.js +478 -3
  30. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  31. package/dist/electron-build/agents/x402/paid-info-store.js +76 -6
  32. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  33. package/dist/web/chain-explorer.js +617 -0
  34. package/dist/web/explorer.html +163 -0
  35. package/dist/web/index.html +5 -0
  36. package/dist/web/routes-chain-index.js +108 -0
  37. package/dist/web/routes-onchain-trade.js +99 -0
  38. package/dist/web/server.js +13 -0
  39. package/package.json +2 -1
  40. package/scripts/build-web.ts +20 -1
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * chain-indexer.ts — AgentEscrow 链上事件索引器 (P5: 链上索引器)
3
+ * =========================================================================
4
+ * 干什么: 把 AgentEscrow 的 **v2 事件** (5 个 + F2b 的 ExpiredV2) 从**部署区块**
5
+ * 开始逐块扫进本机一个可重启、可对账的索引文件, 并提供只读查询底座。
6
+ *
7
+ * 六条硬约束 (每一条都能被验收脚本证伪):
8
+ * ① 起点来自**部署 manifest 的 deployment block**, 不硬编码
9
+ * (env BOLLOON_ESCROW_DEPLOYMENT_BLOCK → contracts/deployments/*.json 按
10
+ * chainId + 地址匹配 → 报错; 找不到就抛, 不猜 0)。
11
+ * ② 分页 eth_getLogs: 每次 to-from+1 ≤ pageSize (env BOLLOON_INDEX_PAGE_SIZE);
12
+ * provider 报 "range 太大" 时**自动对半拆**再试, 不静默漏页。
13
+ * ③ 增量: 记 `lastSyncedBlock`, 重启从它 +1 继续, 默认**不重扫** [deploymentBlock, lastSyncedBlock]。
14
+ * ④ 去重: 键 = `txHash:logIndex` (小写). 同一键永远只有一条; 幂等重跑 0 重复。
15
+ * ⑤ 重组: 拿已记的 head blockHash / recentBlocks 父哈希链跟当前链比对 →
16
+ * 找到分叉点 → **回退** [fork+1, head] 区间并重扫; 被回退的记录标 `suspect=true`
17
+ * 并保留 (不静默丢弃); 重扫时若日志重新出现 → 复位 suspect 并记 history。
18
+ * ⑥ 全量重建: `rebuild()` 无视 lastSyncedBlock 从 deploymentBlock 重扫一遍,
19
+ * 与增量结果逐条比对 (compareIndexes) —— 不一致就说不一样, 不粉饰。
20
+ *
21
+ * 落盘: `~/.bolloon/chain/index.json` (原子写: tmp + rename; 每 N 页 checkpoint)。
22
+ * 确认数分层 (门槛来自 chain-config 的配置: confirmed=1 / finalized=12):
23
+ * finality = 'finalized' (≥finalized) | 'confirmed' (≥confirmed) | 'observed'
24
+ * 被回退 (suspect) 的记录一律降为 'observed' —— 它只是"曾在链上被观测到"。
25
+ *
26
+ * 本模块**只读链** (eth_getLogs / eth_getBlockByNumber), 不签名、不发交易、不碰私钥。
27
+ */
28
+ import * as fs from 'fs';
29
+ import * as fsp from 'fs/promises';
30
+ import * as path from 'path';
31
+ import { Interface } from 'ethers';
32
+ import { AGENT_ESCROW_V2_ABI, V2_EVENT_NAMES, createJsonRpcProvider, } from './escrow-client.js';
33
+ import { DEFAULT_CONFIRMATIONS, bolloonHome, deploymentsDir, } from './chain-config.js';
34
+ export const CHAIN_INDEX_SCHEMA_VERSION = 2;
35
+ export function identityOf(o) {
36
+ return {
37
+ chainId: Number.isFinite(Number(o.chainId)) ? Number(o.chainId) : 0,
38
+ escrowAddress: String(o.escrowAddress || '').toLowerCase(),
39
+ deploymentBlock: Number.isInteger(o.deploymentBlock) ? Number(o.deploymentBlock) : -1,
40
+ };
41
+ }
42
+ export function identitiesEqual(a, b) {
43
+ if (!a || !b)
44
+ return false;
45
+ return a.chainId === b.chainId && a.escrowAddress === b.escrowAddress && a.deploymentBlock === b.deploymentBlock;
46
+ }
47
+ /** 从落盘 JSON 里读身份 (坏字段 → null, 由调用方按顶层字段回退推导) */
48
+ export function readIndexIdentity(raw) {
49
+ const i = raw?.identity;
50
+ if (!i || typeof i !== 'object')
51
+ return null;
52
+ const chainId = Number(i.chainId);
53
+ const deploymentBlock = Number(i.deploymentBlock);
54
+ const escrowAddress = String(i.escrowAddress || '');
55
+ if (!Number.isFinite(chainId) || !Number.isInteger(deploymentBlock) || !escrowAddress)
56
+ return null;
57
+ return { chainId, escrowAddress: escrowAddress.toLowerCase(), deploymentBlock };
58
+ }
59
+ /** `sync` 遇身份变更报的码 (append-only, 不改 REORG_SUSPECTED 的含义 —— 那不是重组) */
60
+ export const INDEX_IDENTITY_CHANGED = 'INDEX_IDENTITY_CHANGED';
61
+ /** 身份变更后**唯一**该做的事 (只有它能既清掉旧身份记录、又不丢当前身份的事件) */
62
+ export const INDEX_IDENTITY_REBUILD_COMMAND = 'bolloon chain index rebuild';
63
+ /** 索引器认的 v2 事件 = P3 的 5 个 + F2b 新增的 ExpiredV2 (逐字对齐 contracts/deployments/abis/AgentEscrow.json) */
64
+ export const EXPIRED_V2_EVENT_SIG = 'event ExpiredV2(bytes32 indexed taskKey, address indexed caller, address indexed refundedTo, uint256 amount)';
65
+ export const INDEXED_EVENT_NAMES = Object.freeze([...V2_EVENT_NAMES, 'ExpiredV2']);
66
+ export const INDEX_IFACE = new Interface([
67
+ ...AGENT_ESCROW_V2_ABI,
68
+ EXPIRED_V2_EVENT_SIG,
69
+ ]);
70
+ // ── 路径 ────────────────────────────────────────────────────────────────────
71
+ export function chainIndexDir(home) {
72
+ return path.join(bolloonHome(home), 'chain');
73
+ }
74
+ export function chainIndexPath(home) {
75
+ return path.join(chainIndexDir(home), 'index.json');
76
+ }
77
+ export class ChainIndexError extends Error {
78
+ missing;
79
+ constructor(message, missing = []) {
80
+ super(message);
81
+ this.missing = missing;
82
+ this.name = 'ChainIndexError';
83
+ }
84
+ }
85
+ /**
86
+ * ★**索引身份变更** (不是重组)。`sync` 一旦发现索引文件属于另一次部署 / 另一条链实例,
87
+ * 就**什么都不动**地拒绝 (在任何 RPC、任何写盘之前) —— 把两边的事件混进一个文件,
88
+ * 比报一个可操作的错糟得多 (实测: 混完 451 条 / 328 suspects, 而文件里写的还是已死掉的旧地址)。
89
+ *
90
+ * 修法只有一个: `bolloon chain index rebuild` —— 干净重建 (丢弃旧身份记录, 采用当前身份)。
91
+ */
92
+ export class ChainIndexIdentityChangedError extends ChainIndexError {
93
+ oldIdentity;
94
+ newIdentity;
95
+ indexPath;
96
+ code = INDEX_IDENTITY_CHANGED;
97
+ nextAction = 'needs_human';
98
+ suggestedCommand = INDEX_IDENTITY_REBUILD_COMMAND;
99
+ constructor(oldIdentity, newIdentity, indexPath) {
100
+ super(`索引身份变了 (**这不是重组**): 索引文件${indexPath ? ` ${indexPath}` : ''} 属于 ` +
101
+ `chainId=${oldIdentity.chainId} / escrow=${oldIdentity.escrowAddress} / deploymentBlock=${oldIdentity.deploymentBlock}, ` +
102
+ `而当前链是 chainId=${newIdentity.chainId} / escrow=${newIdentity.escrowAddress} / deploymentBlock=${newIdentity.deploymentBlock}。` +
103
+ `旧索引里的事件不属于当前合约/链实例 —— 拒绝把它们混进同一个索引 (这次一条都没扫、没写盘)。` +
104
+ `修法: ${INDEX_IDENTITY_REBUILD_COMMAND} (干净重建: 丢弃旧身份记录, 采用当前身份)。`, ['indexIdentity']);
105
+ this.oldIdentity = oldIdentity;
106
+ this.newIdentity = newIdentity;
107
+ this.indexPath = indexPath;
108
+ this.name = 'ChainIndexIdentityChangedError';
109
+ }
110
+ }
111
+ /** 只读模式下的占位 provider: 只有真去调链时才报错, 查已落盘索引不受影响 */
112
+ const NO_RPC = {
113
+ async getBlockNumber() { throw new ChainIndexError('ChainIndexer 处于只读模式 (没给 provider/rpcUrl), 不能读链', ['rpcUrl']); },
114
+ async getBlock() { throw new ChainIndexError('ChainIndexer 处于只读模式 (没给 provider/rpcUrl), 不能读链', ['rpcUrl']); },
115
+ async getLogs() { throw new ChainIndexError('ChainIndexer 处于只读模式 (没给 provider/rpcUrl), 不能读链', ['rpcUrl']); },
116
+ };
117
+ export function defaultDeploymentsDir() {
118
+ // 与 chain-config 的 manifest 层**同一份**目录口径 (env BOLLOON_DEPLOYMENTS_DIR 优先,
119
+ // 其次从 cwd 往上找 contracts/deployments) —— 两处各写一遍就会给出两个答案。
120
+ return deploymentsDir(process.env);
121
+ }
122
+ /**
123
+ * 从部署 manifest 里读 **deployment block**。优先级:
124
+ * ① env BOLLOON_ESCROW_DEPLOYMENT_BLOCK / BOLLOON_INDEX_FROM_BLOCK
125
+ * ② contracts/deployments/*.json 里 chainId + AgentEscrow 地址都匹配的那份
126
+ * ③ 抛错 (不猜 0 —— 从 0 开始扫会白扫几十万块, 还会把假起点写进索引)
127
+ */
128
+ export function resolveDeploymentInfo(opts = {}) {
129
+ const env = opts.env || process.env;
130
+ const dir = opts.deploymentsDir || defaultDeploymentsDir();
131
+ const wantAddr = (opts.escrowAddress || env.BOLLOON_ESCROW_ADDRESS || '').toLowerCase();
132
+ const wantChain = opts.chainId ?? (env.BOLLOON_CHAIN_ID ? Number(env.BOLLOON_CHAIN_ID) : undefined);
133
+ const envBlock = env.BOLLOON_ESCROW_DEPLOYMENT_BLOCK || env.BOLLOON_INDEX_FROM_BLOCK;
134
+ const envBlockNum = envBlock && Number.isInteger(Number(envBlock)) && Number(envBlock) >= 0 ? Number(envBlock) : null;
135
+ // ① env 显式给起点
136
+ if (envBlockNum !== null) {
137
+ return {
138
+ chainId: wantChain ?? 0,
139
+ networkName: opts.networkName || env.BOLLOON_NETWORK_NAME || 'unknown',
140
+ escrowAddress: opts.escrowAddress || env.BOLLOON_ESCROW_ADDRESS || '',
141
+ deploymentBlock: envBlockNum,
142
+ source: env.BOLLOON_ESCROW_DEPLOYMENT_BLOCK ? 'env BOLLOON_ESCROW_DEPLOYMENT_BLOCK' : 'env BOLLOON_INDEX_FROM_BLOCK',
143
+ };
144
+ }
145
+ // ② manifest
146
+ let files = [];
147
+ try {
148
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
149
+ }
150
+ catch {
151
+ throw new ChainIndexError(`读不到部署 manifest 目录: ${dir} (设 BOLLOON_DEPLOYMENTS_DIR, 或给 deploymentBlock / env BOLLOON_INDEX_FROM_BLOCK)`, ['deploymentBlock']);
152
+ }
153
+ const near = [];
154
+ for (const f of files) {
155
+ const p = path.join(dir, f);
156
+ let m;
157
+ try {
158
+ m = JSON.parse(fs.readFileSync(p, 'utf8'));
159
+ }
160
+ catch {
161
+ continue;
162
+ }
163
+ const escrow = (m?.contracts || []).find((c) => c?.name === 'AgentEscrow');
164
+ if (!escrow)
165
+ continue;
166
+ const addrOk = !wantAddr || String(escrow.address).toLowerCase() === wantAddr;
167
+ const chainOk = wantChain === undefined || Number(m.chainId) === Number(wantChain);
168
+ if (addrOk && chainOk) {
169
+ const blk = Number(escrow.blockNumber);
170
+ if (!Number.isInteger(blk) || blk < 0)
171
+ continue;
172
+ // 多个匹配时取 deployment 最早的 (起点更保守, 不会漏)
173
+ return {
174
+ chainId: Number(m.chainId), networkName: String(m.networkName || opts.networkName || 'unknown'),
175
+ escrowAddress: String(escrow.address), deploymentBlock: blk,
176
+ source: `manifest ${path.relative(process.cwd(), p)} .contracts[AgentEscrow].blockNumber`,
177
+ manifestPath: p,
178
+ };
179
+ }
180
+ near.push(`${f}(chainId=${m?.chainId}, escrow=${escrow.address})`);
181
+ }
182
+ throw new ChainIndexError(`在 ${dir} 里找不到匹配的部署 manifest (want chainId=${wantChain ?? '?'} escrow=${wantAddr || '?'})。` +
183
+ `已看到: ${near.join(', ') || '(无)'}。` +
184
+ `索引起点必须来自 manifest 的 deployment block —— 拒绝猜。`, ['deploymentBlock']);
185
+ }
186
+ export class ChainIndexer {
187
+ provider;
188
+ escrowAddress;
189
+ chainId;
190
+ networkName;
191
+ deploymentBlock;
192
+ deploymentSource;
193
+ confirmations;
194
+ pageSize;
195
+ reorgDepth;
196
+ home;
197
+ /** 索引文件路径 (默认 ~/.bolloon/chain/index.json; 测试/多链并用可覆盖) */
198
+ indexPath;
199
+ log;
200
+ checkpointEvery;
201
+ destroyed = false;
202
+ constructor(opts = {}) {
203
+ const env = process.env;
204
+ const cfg = opts.config;
205
+ const rpcUrl = opts.rpcUrl || cfg?.rpcUrl || '';
206
+ const escrowAddress = opts.escrowAddress || cfg?.escrowAddress || env.BOLLOON_ESCROW_ADDRESS || '';
207
+ this.chainId = opts.chainId ?? cfg?.chainId ?? (env.BOLLOON_CHAIN_ID ? Number(env.BOLLOON_CHAIN_ID) : 0);
208
+ this.networkName = opts.networkName || cfg?.networkName || env.BOLLOON_NETWORK_NAME || 'unknown';
209
+ this.escrowAddress = escrowAddress;
210
+ this.confirmations = opts.confirmations || cfg?.confirmations || DEFAULT_CONFIRMATIONS;
211
+ const envPage = Number(env.BOLLOON_INDEX_PAGE_SIZE);
212
+ this.pageSize = Math.max(1, Math.floor(opts.pageSize ?? (Number.isInteger(envPage) && envPage > 0 ? envPage : 2000)));
213
+ const envDepth = Number(env.BOLLOON_INDEX_REORG_DEPTH);
214
+ this.reorgDepth = Math.max(1, Math.floor(opts.reorgDepth ?? (Number.isInteger(envDepth) && envDepth > 0 ? envDepth : 32)));
215
+ this.checkpointEvery = Math.max(1, opts.checkpointEvery ?? 20);
216
+ this.home = opts.home;
217
+ this.indexPath = opts.indexPath || chainIndexPath(opts.home);
218
+ this.log = opts.log || (() => { });
219
+ if (opts.deploymentBlock != null) {
220
+ if (!Number.isInteger(opts.deploymentBlock) || opts.deploymentBlock < 0) {
221
+ throw new ChainIndexError(`deploymentBlock 非法: ${opts.deploymentBlock}`, ['deploymentBlock']);
222
+ }
223
+ this.deploymentBlock = opts.deploymentBlock;
224
+ this.deploymentSource = opts.deploymentSource || '显式传入';
225
+ }
226
+ else {
227
+ const info = resolveDeploymentInfo({
228
+ chainId: this.chainId || undefined,
229
+ escrowAddress: this.escrowAddress || undefined,
230
+ networkName: this.networkName,
231
+ deploymentsDir: opts.deploymentsDir,
232
+ });
233
+ this.deploymentBlock = info.deploymentBlock;
234
+ this.deploymentSource = info.source;
235
+ if (!this.escrowAddress)
236
+ this.escrowAddress = info.escrowAddress;
237
+ if (!this.chainId)
238
+ this.chainId = info.chainId;
239
+ if (this.networkName === 'unknown' && info.networkName)
240
+ this.networkName = info.networkName;
241
+ }
242
+ if (opts.provider)
243
+ this.provider = opts.provider;
244
+ else if (rpcUrl)
245
+ this.provider = createJsonRpcProvider(rpcUrl);
246
+ else
247
+ this.provider = NO_RPC; // 只读模式: 查已落盘索引不需要 RPC; 真调链时才报错
248
+ if (!this.escrowAddress)
249
+ throw new ChainIndexError('ChainIndexer: 缺 escrowAddress', ['escrowAddress']);
250
+ }
251
+ close() {
252
+ if (this.destroyed)
253
+ return;
254
+ this.destroyed = true;
255
+ try {
256
+ this.provider.destroy?.();
257
+ }
258
+ catch { /* noop */ }
259
+ }
260
+ // ── 身份 ────────────────────────────────────────────────────────────────
261
+ /** 当前实例的身份 = 索引**应该**属于谁 (来自当前链配置/manifest, 不是从旧文件读的) */
262
+ identity() {
263
+ return identityOf(this);
264
+ }
265
+ /**
266
+ * 索引文件里记的身份。「未绑定」= 文件里既没有 entries 也从没同步过 (空壳索引) → null:
267
+ * 这时谈不上「属于谁」, 直接采用当前身份即可, 不该报身份变更。
268
+ * 旧文件没有 `identity` 字段 → 按顶层 (chainId / escrowAddress / deploymentBlock) 推导。
269
+ */
270
+ boundIdentity(state) {
271
+ const bound = state.entries.length > 0 || state.lastSyncedAt != null;
272
+ if (!bound)
273
+ return null;
274
+ return state.identity ?? identityOf(state);
275
+ }
276
+ /** 身份变更检测 (只读盘, 不发 RPC、不写盘) */
277
+ detectIdentityChange(state) {
278
+ const old = this.boundIdentity(state);
279
+ const neu = this.identity();
280
+ return { changed: old != null && !identitiesEqual(old, neu), old, new: neu };
281
+ }
282
+ // ── 落盘 ────────────────────────────────────────────────────────────────
283
+ emptyIndex() {
284
+ return {
285
+ schemaVersion: CHAIN_INDEX_SCHEMA_VERSION,
286
+ chainId: this.chainId,
287
+ networkName: this.networkName,
288
+ escrowAddress: this.escrowAddress,
289
+ deploymentBlock: this.deploymentBlock,
290
+ deploymentSource: this.deploymentSource,
291
+ identity: this.identity(),
292
+ lastSyncedBlock: this.deploymentBlock - 1,
293
+ lastSyncedAt: null,
294
+ headBlock: null,
295
+ headBlockHash: null,
296
+ confirmations: { ...this.confirmations },
297
+ pageSize: this.pageSize,
298
+ reorgDepth: this.reorgDepth,
299
+ entries: [],
300
+ recentBlocks: [],
301
+ runs: [],
302
+ updatedAt: Date.now(),
303
+ };
304
+ }
305
+ load() {
306
+ const base = this.emptyIndex();
307
+ const p = this.indexPath;
308
+ let raw;
309
+ try {
310
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
311
+ }
312
+ catch {
313
+ return base;
314
+ }
315
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.entries))
316
+ return base;
317
+ const entries = [];
318
+ const seen = new Set();
319
+ for (const e of raw.entries) {
320
+ const key = String(e?.key || '');
321
+ // 读盘路径也去重: 坏文件 / 手改文件里的重复键不许渗进来
322
+ if (!key || seen.has(key))
323
+ continue;
324
+ seen.add(key);
325
+ entries.push(this.normalizeEntry(e));
326
+ }
327
+ entries.sort(byBlockLogIndex);
328
+ // 身份口径 (顶层三字段 + identity 字段共用同一组回退值, 不各写一遍)
329
+ const chainId = Number(raw.chainId) || base.chainId;
330
+ const networkName = String(raw.networkName || base.networkName);
331
+ const escrowAddress = String(raw.escrowAddress || base.escrowAddress);
332
+ const deploymentBlock = Number.isInteger(raw.deploymentBlock) ? Number(raw.deploymentBlock) : base.deploymentBlock;
333
+ return {
334
+ ...base,
335
+ chainId,
336
+ networkName,
337
+ escrowAddress,
338
+ deploymentBlock,
339
+ deploymentSource: String(raw.deploymentSource || base.deploymentSource),
340
+ // 旧文件没有 identity 字段 → 按顶层字段推导 (不回退成"当前实例"的身份:
341
+ // 那样会把「旧文件属于谁」这件事实抹掉, 身份变更就检不出来了)
342
+ identity: readIndexIdentity(raw) ?? identityOf({ chainId, escrowAddress, deploymentBlock }),
343
+ lastSyncedBlock: Number.isInteger(raw.lastSyncedBlock) ? Number(raw.lastSyncedBlock) : base.lastSyncedBlock,
344
+ lastSyncedAt: raw.lastSyncedAt ?? null,
345
+ headBlock: Number.isInteger(raw.headBlock) ? Number(raw.headBlock) : null,
346
+ headBlockHash: raw.headBlockHash ?? null,
347
+ confirmations: raw.confirmations?.confirmed ? {
348
+ confirmed: Number(raw.confirmations.confirmed), finalized: Number(raw.confirmations.finalized),
349
+ } : base.confirmations,
350
+ pageSize: Number.isInteger(raw.pageSize) && raw.pageSize > 0 ? Number(raw.pageSize) : base.pageSize,
351
+ reorgDepth: Number.isInteger(raw.reorgDepth) && raw.reorgDepth > 0 ? Number(raw.reorgDepth) : base.reorgDepth,
352
+ entries,
353
+ recentBlocks: Array.isArray(raw.recentBlocks)
354
+ ? raw.recentBlocks.filter((b) => Number.isInteger(b?.number) && b?.hash).map((b) => ({ number: Number(b.number), hash: String(b.hash), parentHash: String(b.parentHash || '') }))
355
+ : [],
356
+ runs: Array.isArray(raw.runs) ? raw.runs.slice(-50) : [],
357
+ rebuiltAt: raw.rebuiltAt ?? undefined,
358
+ updatedAt: Number(raw.updatedAt) || Date.now(),
359
+ };
360
+ }
361
+ normalizeEntry(e) {
362
+ const txHash = String(e.txHash || '').toLowerCase();
363
+ const logIndex = Number(e.logIndex) || 0;
364
+ return {
365
+ key: String(e.key || `${txHash}:${logIndex}`),
366
+ blockNumber: Number(e.blockNumber) || 0,
367
+ blockHash: String(e.blockHash || ''),
368
+ txHash,
369
+ txIndex: e.txIndex == null ? null : Number(e.txIndex),
370
+ logIndex,
371
+ address: String(e.address || this.escrowAddress),
372
+ eventName: String(e.eventName || ''),
373
+ taskKey: String(e.taskKey || ''),
374
+ args: e.args && typeof e.args === 'object' ? { ...e.args } : {},
375
+ confirmations: Number(e.confirmations) || 0,
376
+ finality: (['observed', 'confirmed', 'finalized'].includes(e.finality) ? e.finality : 'observed'),
377
+ suspect: e.suspect === true,
378
+ suspectReason: e.suspectReason,
379
+ firstSeenAt: Number(e.firstSeenAt) || Date.now(),
380
+ updatedAt: Number(e.updatedAt) || Date.now(),
381
+ history: Array.isArray(e.history) ? e.history.slice(-20) : [],
382
+ };
383
+ }
384
+ async save(state) {
385
+ const p = this.indexPath;
386
+ await fsp.mkdir(path.dirname(p), { recursive: true });
387
+ const tmp = `${p}.tmp`;
388
+ state.entries.sort(byBlockLogIndex);
389
+ // ★ 落盘即写**真实身份**: 这份文件里的记录是**本实例**扫出来的 (扫描地址 = this.escrowAddress,
390
+ // 起点 = this.deploymentBlock)。历史缺陷: 落盘时沿用 load() 里的旧字段 →
391
+ // 扫描用新 escrow、文件里却写着旧地址, `chain index status` 报给用户的地址是假的。
392
+ // (身份未变时这三个值与文件里的完全相同 —— 不改任何数据面语义)
393
+ const id = this.identity();
394
+ state.chainId = id.chainId;
395
+ state.escrowAddress = this.escrowAddress; // 保留配置给的原始大小写; 比较一律走 identityOf (小写)
396
+ state.deploymentBlock = id.deploymentBlock;
397
+ state.deploymentSource = this.deploymentSource;
398
+ state.identity = id;
399
+ state.updatedAt = Date.now();
400
+ state.runs = state.runs.slice(-50);
401
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
402
+ await fsp.rename(tmp, p);
403
+ }
404
+ // ── 链读取 ──────────────────────────────────────────────────────────────
405
+ async head() {
406
+ const n = await this.provider.getBlockNumber();
407
+ const b = await this.provider.getBlock(n);
408
+ if (!b)
409
+ throw new ChainIndexError(`读不到 head 区块 ${n} (RPC 异常)`);
410
+ return { number: Number(b.number), hash: String(b.hash), parentHash: String(b.parentHash || '') };
411
+ }
412
+ async blockAt(n) {
413
+ const b = await this.provider.getBlock(n);
414
+ if (!b)
415
+ return null;
416
+ return { number: Number(b.number), hash: String(b.hash), parentHash: String(b.parentHash || '') };
417
+ }
418
+ /**
419
+ * 分页取日志。尊重 provider 的 block-range 限制:
420
+ * 每页 ≤ pageSize; provider 报区间太大 → 对半拆再试 (自适应), 不静默跳过。
421
+ */
422
+ async fetchLogsRange(from, to, acc, onPage) {
423
+ if (from > to)
424
+ return;
425
+ const topics = INDEXED_EVENT_NAMES.map((n) => {
426
+ const frag = INDEX_IFACE.getEvent(n);
427
+ if (!frag)
428
+ throw new ChainIndexError(`本地 ABI 里没有事件 ${n}`);
429
+ return String(frag.topicHash).toLowerCase();
430
+ });
431
+ const tryGet = async (a, b) => {
432
+ try {
433
+ return await this.provider.getLogs({
434
+ address: this.escrowAddress,
435
+ topics: [topics],
436
+ fromBlock: a,
437
+ toBlock: b,
438
+ });
439
+ }
440
+ catch (e) {
441
+ const msg = String(e?.shortMessage || e?.message || e);
442
+ if (a === b)
443
+ throw e;
444
+ acc.halves++;
445
+ this.log(` ⚠ eth_getLogs(${a}-${b}) 失败 (${msg.slice(0, 90)}) → 对半拆`);
446
+ const mid = Math.floor((a + b) / 2);
447
+ const left = await tryGet(a, mid);
448
+ const right = await tryGet(mid + 1, b);
449
+ return [...(left || []), ...(right || [])];
450
+ }
451
+ };
452
+ // 先按 pageSize 切; 单页失败再靠 tryGet 自适应对半拆
453
+ for (let a = from; a <= to; a += this.pageSize) {
454
+ const b = Math.min(a + this.pageSize - 1, to);
455
+ const logs = await tryGet(a, b);
456
+ acc.pages++;
457
+ acc.ranges.push({ from: a, to: b });
458
+ for (const l of logs || [])
459
+ acc.logs.push(l);
460
+ if (onPage)
461
+ await onPage(b, acc.pages);
462
+ }
463
+ }
464
+ // ── 解码 ────────────────────────────────────────────────────────────────
465
+ decodeLog(raw) {
466
+ let parsed;
467
+ try {
468
+ parsed = INDEX_IFACE.parseLog({ topics: [...(raw.topics || [])], data: raw.data });
469
+ }
470
+ catch {
471
+ return null;
472
+ }
473
+ if (!parsed)
474
+ return null;
475
+ if (!INDEXED_EVENT_NAMES.includes(parsed.name))
476
+ return null;
477
+ const args = {};
478
+ parsed.fragment.inputs.forEach((inp, i) => {
479
+ const v = parsed.args[i];
480
+ args[inp.name] = typeof v === 'bigint' ? v.toString() : String(v);
481
+ });
482
+ const taskKey = String(parsed.args[0] ?? '').toLowerCase();
483
+ return { eventName: parsed.name, taskKey, args };
484
+ }
485
+ finalityOf(confirmations, suspect) {
486
+ if (suspect)
487
+ return 'observed';
488
+ if (confirmations >= this.confirmations.finalized)
489
+ return 'finalized';
490
+ if (confirmations >= this.confirmations.confirmed)
491
+ return 'confirmed';
492
+ return 'observed';
493
+ }
494
+ refreshFinality(state, headBlock) {
495
+ for (const e of state.entries) {
496
+ const conf = headBlock - e.blockNumber + 1;
497
+ e.confirmations = conf < 0 ? 0 : conf;
498
+ e.finality = this.finalityOf(e.confirmations, e.suspect);
499
+ }
500
+ }
501
+ rememberBlock(state, b) {
502
+ state.recentBlocks = state.recentBlocks.filter((x) => x.number !== b.number);
503
+ state.recentBlocks.push(b);
504
+ const min = Math.max(this.deploymentBlock - 1, b.number - 2 * this.reorgDepth);
505
+ state.recentBlocks = state.recentBlocks.filter((x) => x.number >= min && x.number <= b.number).sort((x, y) => x.number - y.number);
506
+ }
507
+ /**
508
+ * 记下 head 下面 `reorgDepth` 个块的哈希 —— 这是**分叉点能定准**的关键:
509
+ * 只记 head 一个哈希的话, 重组时只能一路退到 deploymentBlock (浪费且不好解释)。
510
+ * 代价 = 每次同步最多 reorgDepth 次 eth_getBlockByNumber (已记过的不重复取)。
511
+ */
512
+ async rememberBlockWindow(state, head) {
513
+ this.rememberBlock(state, head);
514
+ const have = new Set(state.recentBlocks.map((b) => b.number));
515
+ const floor = Math.max(this.deploymentBlock - 1, head.number - this.reorgDepth);
516
+ for (let n = head.number - 1; n >= floor; n--) {
517
+ if (have.has(n))
518
+ continue;
519
+ const b = await this.blockAt(n);
520
+ if (!b)
521
+ continue;
522
+ state.recentBlocks.push(b);
523
+ }
524
+ const min = Math.max(this.deploymentBlock - 1, head.number - 2 * this.reorgDepth);
525
+ state.recentBlocks = state.recentBlocks.filter((x) => x.number >= min && x.number <= head.number).sort((x, y) => x.number - y.number);
526
+ }
527
+ // ── 重组检测 ────────────────────────────────────────────────────────────
528
+ /**
529
+ * 父哈希链比对: 从已记高度往回找**分叉点** (第一个哈希仍然一致的块)。
530
+ * 情况: ① 已记块号上的哈希变了 ② 已记块号在新链上没了 (rollback 后高度变低)
531
+ * ③ recentBlocks 里某块的 parentHash 对不上 → 都以"往回找第一个一致块"统一处理。
532
+ * 找不到一致块 (超过 reorgDepth) → 回退到 deploymentBlock-1 (全量重扫), 如实标注 deep。
533
+ */
534
+ async detectReorg(state, head) {
535
+ if (state.headBlock == null || state.headBlockHash == null)
536
+ return { reorged: false, forkPoint: null, checked: 0 };
537
+ const recorded = new Map();
538
+ for (const b of state.recentBlocks)
539
+ recorded.set(b.number, b.hash);
540
+ if (state.headBlockHash)
541
+ recorded.set(state.headBlock, state.headBlockHash);
542
+ // 已入库事件所在的块也是锚点 (多一层: 就算 recentBlocks 被清空也能定准)
543
+ for (const e of state.entries)
544
+ if (e.blockHash)
545
+ recorded.set(e.blockNumber, e.blockHash);
546
+ const topRecorded = Math.max(...recorded.keys());
547
+ const probeTop = Math.min(topRecorded, head.number);
548
+ const probe = await this.blockAt(probeTop);
549
+ if (probe && recorded.get(probeTop) && probe.hash === recorded.get(probeTop)) {
550
+ // 已记最高块仍在同哈希上 (链变短的情况由 syncFrom 的"高于 head 的记录"分支处理)
551
+ return { reorged: false, forkPoint: null, checked: 1 };
552
+ }
553
+ // 分歧: 往回走
554
+ // ★ 回退**不得越过扫描下界** (deploymentBlock - 1): 比它还矮的块根本不在本索引的扫描范围内,
555
+ // 回退到那里等于在说"我索引了不属于我的块"。下界取 instance 与文件里身份的下界中更保守的那个
556
+ // (身份已核对时两者相同; 不同时说明文件是旧的 —— 那种情况在 sync 的身份门就被拒了)。
557
+ const floor = Math.max(this.deploymentBlock - 1, state.deploymentBlock - 1, probeTop - this.reorgDepth);
558
+ let checked = 0;
559
+ for (let n = probeTop; n >= floor; n--) {
560
+ checked++;
561
+ const b = await this.blockAt(n);
562
+ const rec = recorded.get(n);
563
+ if (b && rec && b.hash === rec) {
564
+ return { reorged: true, forkPoint: n, reason: `高度 ${probeTop} 的块哈希已变 (父哈希链分叉), 回退到同哈希块 ${n}`, checked };
565
+ }
566
+ }
567
+ return {
568
+ reorged: true,
569
+ forkPoint: floor,
570
+ reason: `超过 reorgDepth=${this.reorgDepth} 仍找不到一致的块: 已索引区间 [${Math.max(this.deploymentBlock, state.deploymentBlock)}, ${probeTop}] 整体对不上 → 按扫描下界 ${floor} 重扫 (若你最近换过链/换过部署, 那不是重组 —— 先 rebuild 让索引换成当前身份)`,
571
+ checked,
572
+ };
573
+ }
574
+ // ── 主流程: 增量同步 ────────────────────────────────────────────────────
575
+ /**
576
+ * 从 `deploymentBlock` (或已记高度 +1) 扫到 head。
577
+ * @param fromBlock 显式起点 (手动补扫用); 不给 = 增量 (lastSyncedBlock+1)。低于 lastSyncedBlock 的补扫由去重保证不产生重复。
578
+ */
579
+ async syncFrom(fromBlock) {
580
+ const t0 = Date.now();
581
+ const state = this.load();
582
+ // ⓪ ★身份门 (在任何 RPC、任何写盘之前): 索引文件不是当前这条链 / 这个合约的 →
583
+ // 一律拒绝同步, 报 INDEX_IDENTITY_CHANGED (不是 REORG_SUSPECTED —— 那会把人引到错的方向)。
584
+ const idChg = this.detectIdentityChange(state);
585
+ if (idChg.changed) {
586
+ throw new ChainIndexIdentityChangedError(idChg.old, idChg.new, this.indexPath);
587
+ }
588
+ const head = await this.head();
589
+ // ① 重组检测 + 回退 (回退的记录**保留**, 标 suspect)
590
+ const re = await this.detectReorg(state, head);
591
+ let rewoundTo = null;
592
+ const orphans = [];
593
+ let markedSuspect = 0;
594
+ // ①b 链比索引矮 (anvil_rollback / 快照回滚): 高于 head 的已入库日志在链上已不存在
595
+ if (state.lastSyncedBlock > head.number || state.entries.some((e) => e.blockNumber > head.number)) {
596
+ const gone = state.entries.filter((e) => e.blockNumber > head.number);
597
+ for (const e of gone) {
598
+ if (!e.suspect) {
599
+ e.suspect = true;
600
+ e.suspectReason = `链已回滚到 ${head.number} 块: 该日志 (块 ${e.blockNumber}) 在链上已不存在`;
601
+ markedSuspect++;
602
+ }
603
+ e.history.push({ at: Date.now(), note: e.suspectReason });
604
+ e.updatedAt = Date.now();
605
+ orphans.push(e.key);
606
+ }
607
+ state.lastSyncedBlock = Math.min(state.lastSyncedBlock, head.number);
608
+ state.recentBlocks = state.recentBlocks.filter((b) => b.number <= head.number);
609
+ if (gone.length)
610
+ this.log(` ⚠ 链高 ${head.number} < 索引高度: ${gone.length} 条记录所在块已消失 → 标 suspect (不删除)`);
611
+ }
612
+ if (re.reorged && re.forkPoint != null) {
613
+ const fork = re.forkPoint;
614
+ rewoundTo = fork;
615
+ const affected = state.entries.filter((e) => e.blockNumber > fork);
616
+ for (const e of affected) {
617
+ if (!e.suspect) {
618
+ e.suspect = true;
619
+ e.suspectReason = `重组回退: ${re.reason}`;
620
+ markedSuspect++;
621
+ }
622
+ e.history.push({ at: Date.now(), note: `回退 (suspect): ${re.reason}` });
623
+ e.updatedAt = Date.now();
624
+ orphans.push(e.key);
625
+ }
626
+ state.recentBlocks = state.recentBlocks.filter((b) => b.number <= fork);
627
+ state.lastSyncedBlock = Math.min(state.lastSyncedBlock, fork);
628
+ this.log(` ⚠ 重组: ${re.reason}; 回退 ${affected.length} 条记录 (标 suspect, 不删除)`);
629
+ }
630
+ // ② 决定起点 (一律用**当前实例**的 deploymentBlock: 身份已核对一致, 且拒绝沿用旧文件里可能陈旧的起点)
631
+ let scanFrom;
632
+ if (rewoundTo != null)
633
+ scanFrom = Math.max(rewoundTo + 1, this.deploymentBlock);
634
+ else if (fromBlock != null)
635
+ scanFrom = Math.max(fromBlock, this.deploymentBlock);
636
+ else
637
+ scanFrom = Math.max(state.lastSyncedBlock + 1, this.deploymentBlock);
638
+ const scanTo = head.number;
639
+ const acc = { pages: 0, ranges: [], logs: [], halves: 0 };
640
+ // ③ 分页扫; checkpoint (每 checkpointEvery 页) 让重启能从已扫高度继续
641
+ if (scanFrom <= scanTo) {
642
+ await this.fetchLogsRange(scanFrom, scanTo, acc, async (scannedTo, pages) => {
643
+ if (pages % this.checkpointEvery !== 0)
644
+ return;
645
+ state.lastSyncedBlock = Math.max(state.lastSyncedBlock, scannedTo);
646
+ state.headBlock = head.number;
647
+ state.headBlockHash = head.hash;
648
+ this.refreshFinality(state, head.number);
649
+ await this.save(state);
650
+ this.log(` ✓ checkpoint: lastSyncedBlock=${state.lastSyncedBlock} (${acc.logs.length} 条日志)`);
651
+ });
652
+ }
653
+ // ④ 入库: 去重 (txHash:logIndex) + 重新出现 → 复位 suspect
654
+ const byKey = new Map(state.entries.map((e) => [e.key, e]));
655
+ let inserted = 0, deduped = 0, restored = 0;
656
+ const touched = new Set();
657
+ for (const raw of acc.logs) {
658
+ const txHash = String(raw?.transactionHash || '').toLowerCase();
659
+ const logIndex = logIndexOf(raw);
660
+ const blockNumber = Number(raw?.blockNumber);
661
+ if (!/^0x[0-9a-f]{64}$/.test(txHash) || !Number.isInteger(logIndex) || !Number.isInteger(blockNumber))
662
+ continue;
663
+ const key = `${txHash}:${logIndex}`;
664
+ if (touched.has(key)) {
665
+ deduped++;
666
+ continue;
667
+ } // 同一次返回里重复 (不该有, 但也不许入库两次)
668
+ touched.add(key);
669
+ const dec = this.decodeLog(raw);
670
+ if (!dec)
671
+ continue;
672
+ const blockHash = String(raw?.blockHash || '');
673
+ const prev = byKey.get(key);
674
+ if (prev && prev.blockHash === blockHash && raw?.removed !== true) {
675
+ // 同块同哈希再次扫到: 正常情况是重复 → 去重; 但如果它被标过 suspect
676
+ // (回退时整块消失、之后链又回到了同一块 —— 真链上 anvil_rollback + 重放会复现完全相同的块哈希),
677
+ // 那就必须**复位 suspect**, 不能因为"哈希碰巧一样"就留着一个过期的可疑标记。
678
+ if (prev.suspect) {
679
+ prev.suspect = false;
680
+ prev.suspectReason = undefined;
681
+ prev.history.push({ at: Date.now(), note: `重新出现: 同块同哈希 (${blockNumber}/${blockHash.slice(0, 12)}…) 再次扫到 → 复位 suspect`, blockNumber, blockHash });
682
+ prev.updatedAt = Date.now();
683
+ restored++;
684
+ }
685
+ else
686
+ deduped++;
687
+ continue;
688
+ }
689
+ if (prev && raw?.removed !== true) {
690
+ // 同一 txHash:logIndex 出现在**新的块**上 (重组后重新打包) → 更新事实并复位 suspect
691
+ prev.blockNumber = blockNumber;
692
+ prev.blockHash = blockHash;
693
+ prev.txIndex = raw?.transactionIndex == null ? prev.txIndex : Number(raw.transactionIndex);
694
+ prev.args = dec.args;
695
+ prev.eventName = dec.eventName;
696
+ prev.taskKey = dec.taskKey;
697
+ if (prev.suspect) {
698
+ prev.suspect = false;
699
+ prev.suspectReason = undefined;
700
+ prev.history.push({ at: Date.now(), note: `重新上链: 出现在新块 ${blockNumber} (哈希 ${blockHash.slice(0, 12)}…)`, blockNumber, blockHash });
701
+ restored++;
702
+ }
703
+ else {
704
+ prev.history.push({ at: Date.now(), note: `同键换块: ${prev.blockNumber} → ${blockNumber}`, blockNumber, blockHash });
705
+ }
706
+ prev.updatedAt = Date.now();
707
+ continue;
708
+ }
709
+ if (prev && raw?.removed === true) {
710
+ prev.suspect = true;
711
+ prev.suspectReason = 'eth_getLogs 返回 removed=true (链已回滚该日志)';
712
+ prev.history.push({ at: Date.now(), note: prev.suspectReason });
713
+ prev.updatedAt = Date.now();
714
+ continue;
715
+ }
716
+ const entry = {
717
+ key, blockNumber, blockHash, txHash,
718
+ txIndex: raw?.transactionIndex == null ? null : Number(raw.transactionIndex),
719
+ logIndex,
720
+ address: String(raw?.address || this.escrowAddress).toLowerCase(),
721
+ eventName: dec.eventName,
722
+ taskKey: dec.taskKey,
723
+ args: dec.args,
724
+ confirmations: 0,
725
+ finality: 'observed',
726
+ suspect: false,
727
+ firstSeenAt: Date.now(),
728
+ updatedAt: Date.now(),
729
+ history: [{ at: Date.now(), note: `首次入库 (区块 ${blockNumber})`, blockNumber, blockHash }],
730
+ };
731
+ state.entries.push(entry);
732
+ byKey.set(key, entry);
733
+ inserted++;
734
+ }
735
+ // ⑤ 回退后没重新扫到的 (真消失了) → 仍 suspect, 明确记一笔
736
+ const unresolved = [];
737
+ if (rewoundTo != null) {
738
+ for (const e of state.entries) {
739
+ if (e.blockNumber > rewoundTo && e.suspect) {
740
+ unresolved.push(e.key);
741
+ if (!e.history.some((h) => h.note.includes('未再出现'))) {
742
+ e.history.push({ at: Date.now(), note: `回退后重扫未再出现 (链上已无此日志) → 保持 suspect` });
743
+ }
744
+ }
745
+ }
746
+ }
747
+ // ⑥ 收尾: 记高度 / head 哈希 / 确认数分层
748
+ if (scanFrom <= scanTo) {
749
+ state.lastSyncedBlock = Math.max(state.lastSyncedBlock, scanTo);
750
+ state.lastSyncedAt = Date.now();
751
+ }
752
+ state.headBlock = head.number;
753
+ state.headBlockHash = head.hash;
754
+ await this.rememberBlockWindow(state, head);
755
+ this.refreshFinality(state, head.number);
756
+ // 落盘时记下这次用的配置 (读文件的人要知道 finality 是按哪个门槛算的)
757
+ state.confirmations = { ...this.confirmations };
758
+ state.pageSize = this.pageSize;
759
+ state.reorgDepth = this.reorgDepth;
760
+ const run = {
761
+ at: Date.now(), mode: 'sync',
762
+ requestedFrom: fromBlock ?? null,
763
+ scanFrom, scanTo,
764
+ pages: acc.pages, ranges: acc.ranges,
765
+ blocksScanned: scanFrom <= scanTo ? scanTo - scanFrom + 1 : 0,
766
+ logsFound: acc.logs.length, inserted, deduped, restored, markedSuspect,
767
+ rewoundTo, headBlock: head.number, headBlockHash: head.hash,
768
+ durationMs: Date.now() - t0,
769
+ note: acc.halves ? `自适应对半拆 ${acc.halves} 次` : undefined,
770
+ };
771
+ state.runs.push(run);
772
+ await this.save(state);
773
+ return {
774
+ mode: 'sync', requestedFrom: fromBlock ?? null, scanFrom, scanTo,
775
+ pages: acc.pages, ranges: acc.ranges, blocksScanned: run.blocksScanned,
776
+ logsFound: acc.logs.length, inserted, deduped, restored, markedSuspect, rewoundTo,
777
+ orphans: Array.from(new Set([...orphans, ...unresolved])),
778
+ headBlock: head.number, headBlockHash: head.hash,
779
+ lastSyncedBlock: state.lastSyncedBlock,
780
+ entries: state.entries.length,
781
+ suspects: state.entries.filter((e) => e.suspect).length,
782
+ durationMs: run.durationMs,
783
+ identity: this.identity(),
784
+ resynced: rewoundTo != null || (fromBlock != null && fromBlock < state.lastSyncedBlock),
785
+ };
786
+ }
787
+ // ── 全量重建 ────────────────────────────────────────────────────────────
788
+ /**
789
+ * 无视 lastSyncedBlock, 从 deploymentBlock 全量重扫 (与增量走**同一条**扫码路径)。
790
+ * 结果与现有增量索引逐条比对; `persist=true` 时用重建结果替换 entries
791
+ * (已有的 suspect 记录若重建后仍不在链上 → 原样保留标 suspect, 不静默丢弃)。
792
+ *
793
+ * ★**身份变更** (索引文件属于另一次部署 / 另一条链实例) → 走**干净重建**:
794
+ * 旧身份的 entries **一律丢弃** (不是标 suspect —— 它们不属于当前身份, 标 suspect 会让人
795
+ * 以为「链上曾经有过」), 身份改用当前链配置写回, 并在结果/run 里如实报
796
+ * `identityChanged: true` + `oldIdentity`/`newIdentity` + 丢了多少条。
797
+ * 身份未变时行为与历史**逐条等价** (旧的 suspect 记录照旧保留)。
798
+ */
799
+ async rebuild(opts = {}) {
800
+ const t0 = Date.now();
801
+ const state = this.load();
802
+ // ★ 身份判定必须在扫链**之前** (与 sync 同一口径): 它决定重建是"合并保留"还是"干净重建"。
803
+ const idChg = this.detectIdentityChange(state);
804
+ const newIdentity = this.identity();
805
+ const head = await this.head();
806
+ const acc = { pages: 0, ranges: [], logs: [], halves: 0 };
807
+ await this.fetchLogsRange(this.deploymentBlock, head.number, acc);
808
+ const fresh = [];
809
+ const seen = new Set();
810
+ for (const raw of acc.logs) {
811
+ const txHash = String(raw?.transactionHash || '').toLowerCase();
812
+ const logIndex = logIndexOf(raw);
813
+ const blockNumber = Number(raw?.blockNumber);
814
+ if (!/^0x[0-9a-f]{64}$/.test(txHash) || !Number.isInteger(logIndex) || !Number.isInteger(blockNumber))
815
+ continue;
816
+ const key = `${txHash}:${logIndex}`;
817
+ if (seen.has(key))
818
+ continue;
819
+ const dec = this.decodeLog(raw);
820
+ if (!dec)
821
+ continue;
822
+ seen.add(key);
823
+ const blockHash = String(raw?.blockHash || '');
824
+ const conf = head.number - blockNumber + 1;
825
+ fresh.push({
826
+ key, blockNumber, blockHash, txHash,
827
+ txIndex: raw?.transactionIndex == null ? null : Number(raw.transactionIndex),
828
+ logIndex,
829
+ address: String(raw?.address || this.escrowAddress).toLowerCase(),
830
+ eventName: dec.eventName, taskKey: dec.taskKey, args: dec.args,
831
+ confirmations: conf < 0 ? 0 : conf,
832
+ finality: this.finalityOf(conf, false),
833
+ suspect: false,
834
+ firstSeenAt: Date.now(), updatedAt: Date.now(),
835
+ history: [{ at: Date.now(), note: `全量重建 (区块 ${blockNumber})`, blockNumber, blockHash }],
836
+ });
837
+ }
838
+ const comparison = compareIndexes(state.entries, fresh);
839
+ let discardedEntries = 0;
840
+ if (opts.persist) {
841
+ if (idChg.changed) {
842
+ // ── 干净重建 ────────────────────────────────────────────────────────
843
+ // 旧身份的记录**一条都不留** (也不标 suspect): 它们要么是另一条链上同一块号的别的日志,
844
+ // 要么属于已死掉的合约 —— 留在文件里就会污染 status/stats/timeline 的每一处读数。
845
+ discardedEntries = state.entries.length;
846
+ const after = this.emptyIndex(); // ← 身份/起点/来源全取**当前**实例 (换部署后必须采用新值)
847
+ after.entries = fresh;
848
+ after.lastSyncedBlock = head.number;
849
+ after.lastSyncedAt = Date.now();
850
+ after.headBlock = head.number;
851
+ after.headBlockHash = head.hash;
852
+ after.rebuiltAt = Date.now();
853
+ after.runs = state.runs.slice(-49); // 审计: 旧 run 记录是真的发生过, 保留 (它们自会写明自己的身份)
854
+ after.recentBlocks = [];
855
+ await this.rememberBlockWindow(after, head);
856
+ this.refreshFinality(after, head.number);
857
+ after.runs.push({
858
+ at: Date.now(), mode: 'rebuild', requestedFrom: this.deploymentBlock,
859
+ scanFrom: this.deploymentBlock, scanTo: head.number, pages: acc.pages, ranges: acc.ranges,
860
+ blocksScanned: head.number - this.deploymentBlock + 1, logsFound: acc.logs.length,
861
+ inserted: fresh.length, deduped: acc.logs.length - fresh.length, restored: 0,
862
+ markedSuspect: 0, rewoundTo: null, headBlock: head.number, headBlockHash: head.hash,
863
+ durationMs: Date.now() - t0,
864
+ identityChanged: true, oldIdentity: idChg.old, newIdentity, discardedEntries,
865
+ note: `身份变更 → 干净重建: 丢弃 ${discardedEntries} 条属于旧身份 (chainId=${idChg.old.chainId} escrow=${idChg.old.escrowAddress} deploymentBlock=${idChg.old.deploymentBlock}) 的记录 (不标 suspect); ` +
866
+ `索引身份改为 (chainId=${newIdentity.chainId} escrow=${newIdentity.escrowAddress} deploymentBlock=${newIdentity.deploymentBlock}); 与重建前逐条比对: same=${comparison.same}`,
867
+ });
868
+ await this.save(after);
869
+ }
870
+ else {
871
+ const rebuiltKeys = new Set(fresh.map((e) => e.key));
872
+ // 审计留存: 旧的 suspect 记录若重建后不在链上 → 保留 (标 suspect, 说明原因)
873
+ const carried = state.entries
874
+ .filter((e) => e.suspect && !rebuiltKeys.has(e.key))
875
+ .map((e) => ({
876
+ ...e,
877
+ suspect: true,
878
+ suspectReason: e.suspectReason || '重建后链上已无此日志',
879
+ history: [...e.history, { at: Date.now(), note: '全量重建: 链上已无此日志, 保留为 suspect 审计记录' }].slice(-20),
880
+ updatedAt: Date.now(),
881
+ }));
882
+ const after = {
883
+ ...state,
884
+ entries: [...fresh, ...carried],
885
+ lastSyncedBlock: head.number,
886
+ lastSyncedAt: Date.now(),
887
+ headBlock: head.number,
888
+ headBlockHash: head.hash,
889
+ rebuiltAt: Date.now(),
890
+ // ★ 身份未变, 但仍显式写回当前实例的口径 (save() 也会兜底 stamp)
891
+ chainId: this.chainId,
892
+ escrowAddress: this.escrowAddress,
893
+ deploymentBlock: this.deploymentBlock,
894
+ deploymentSource: this.deploymentSource,
895
+ identity: newIdentity,
896
+ confirmations: { ...this.confirmations },
897
+ pageSize: this.pageSize,
898
+ reorgDepth: this.reorgDepth,
899
+ };
900
+ after.recentBlocks = [];
901
+ await this.rememberBlockWindow(after, head);
902
+ this.refreshFinality(after, head.number);
903
+ after.runs.push({
904
+ at: Date.now(), mode: 'rebuild', requestedFrom: this.deploymentBlock,
905
+ scanFrom: this.deploymentBlock, scanTo: head.number, pages: acc.pages, ranges: acc.ranges,
906
+ blocksScanned: head.number - this.deploymentBlock + 1, logsFound: acc.logs.length,
907
+ inserted: fresh.length, deduped: acc.logs.length - fresh.length, restored: 0,
908
+ markedSuspect: carried.length, rewoundTo: null, headBlock: head.number, headBlockHash: head.hash,
909
+ durationMs: Date.now() - t0,
910
+ identityChanged: false, oldIdentity: idChg.old, newIdentity, discardedEntries: 0,
911
+ note: `全量重建; 与增量比对: same=${comparison.same} (差 ${comparison.missingInB.length + comparison.extraInB.length + comparison.mismatched.length} 处)`,
912
+ });
913
+ await this.save(after);
914
+ }
915
+ }
916
+ return {
917
+ mode: 'rebuild', pages: acc.pages, ranges: acc.ranges,
918
+ blocksScanned: head.number - this.deploymentBlock + 1,
919
+ logsFound: acc.logs.length, entries: fresh.length,
920
+ headBlock: head.number, comparison, durationMs: Date.now() - t0, persisted: !!opts.persist,
921
+ identity: newIdentity,
922
+ identityChanged: idChg.changed,
923
+ oldIdentity: idChg.old,
924
+ newIdentity,
925
+ discardedEntries,
926
+ };
927
+ }
928
+ // ── 只读接口 (索引文件没有 → 返回空索引, 不假装有) ──────────────────────
929
+ status() {
930
+ const s = this.load();
931
+ return {
932
+ chainId: s.chainId, networkName: s.networkName, escrowAddress: s.escrowAddress,
933
+ deploymentBlock: s.deploymentBlock, deploymentSource: s.deploymentSource,
934
+ lastSyncedBlock: s.lastSyncedBlock, lastSyncedAt: s.lastSyncedAt,
935
+ headBlock: s.headBlock, headBlockHash: s.headBlockHash,
936
+ entries: s.entries.length, suspects: s.entries.filter((e) => e.suspect).length,
937
+ confirmations: s.confirmations, pageSize: s.pageSize, reorgDepth: s.reorgDepth,
938
+ rebuiltAt: s.rebuiltAt ?? null, indexPath: this.indexPath,
939
+ };
940
+ }
941
+ /** 按 taskKey 查 escrow 时间线 (按 blockNumber, logIndex 升序) */
942
+ timeline(taskKey) {
943
+ const key = String(taskKey || '').toLowerCase();
944
+ const events = this.load().entries.filter((e) => e.taskKey.toLowerCase() === key).sort(byBlockLogIndex);
945
+ return { taskKey: key, events, state: deriveEscrowState(events), count: events.length, hasSuspect: events.some((e) => e.suspect) };
946
+ }
947
+ /** cursor = (blockNumber, logIndex) 之后的事件, 升序返回 (增量拉取) */
948
+ fetchAfter(cursor, limit = 200) {
949
+ const cb = cursor ? Number(cursor.blockNumber) : -1;
950
+ const cl = cursor ? Number(cursor.logIndex) : -1;
951
+ const all = this.load().entries
952
+ .filter((e) => e.blockNumber > cb || (e.blockNumber === cb && e.logIndex > cl))
953
+ .sort(byBlockLogIndex);
954
+ const lim = Math.max(1, Math.min(2000, Math.floor(limit)));
955
+ const page = all.slice(0, lim);
956
+ const last = page[page.length - 1];
957
+ return {
958
+ events: page,
959
+ nextCursor: last ? { blockNumber: last.blockNumber, logIndex: last.logIndex } : (cursor ?? null),
960
+ hasMore: all.length > page.length,
961
+ total: all.length,
962
+ };
963
+ }
964
+ /** 全量统计 (tasks/released/refunded/disputed/expired ...) */
965
+ stats() {
966
+ const s = this.load();
967
+ const live = s.entries.filter((e) => !e.suspect);
968
+ const count = (n) => live.filter((e) => e.eventName === n).length;
969
+ const tasks = new Set(live.filter((e) => e.eventName === 'EscrowCreatedV2').map((e) => e.taskKey.toLowerCase())).size;
970
+ const byFinality = { observed: 0, confirmed: 0, finalized: 0 };
971
+ for (const e of s.entries)
972
+ byFinality[e.finality]++;
973
+ return {
974
+ entries: s.entries.length, suspects: s.entries.filter((e) => e.suspect).length, tasks,
975
+ created: count('EscrowCreatedV2'),
976
+ proofSubmitted: count('ProofSubmittedV2'),
977
+ released: count('ReleasedV2'),
978
+ refunded: count('RefundedV2'),
979
+ disputed: count('DisputedV2'),
980
+ expired: count('ExpiredV2'),
981
+ byFinality,
982
+ lastSyncedBlock: s.lastSyncedBlock, lastSyncedAt: s.lastSyncedAt, headBlock: s.headBlock,
983
+ deploymentBlock: s.deploymentBlock, chainId: s.chainId, escrowAddress: s.escrowAddress,
984
+ };
985
+ }
986
+ }
987
+ /** 从原始日志取 logIndex: 原始 JSON-RPC 里叫 logIndex, ethers v6 的 Log 对象里叫 index (两个都要认) */
988
+ function logIndexOf(raw) {
989
+ return Number(raw?.logIndex ?? raw?.index);
990
+ }
991
+ // ── 纯函数 ─────────────────────────────────────────────────────────────────
992
+ export function byBlockLogIndex(a, b) {
993
+ return a.blockNumber - b.blockNumber || a.logIndex - b.logIndex;
994
+ }
995
+ /** 从事件序列推 escrow 状态 (只按事件推, 不读链) */
996
+ export function deriveEscrowState(events) {
997
+ if (!events.length)
998
+ return null;
999
+ const has = (n) => events.some((e) => e.eventName === n);
1000
+ if (has('ReleasedV2'))
1001
+ return 'RELEASED';
1002
+ if (has('RefundedV2'))
1003
+ return 'REFUNDED';
1004
+ if (has('ExpiredV2'))
1005
+ return 'EXPIRED';
1006
+ if (has('DisputedV2'))
1007
+ return 'DISPUTED';
1008
+ if (has('ProofSubmittedV2'))
1009
+ return 'ACTIVE (proof submitted)';
1010
+ if (has('EscrowCreatedV2'))
1011
+ return 'ACTIVE';
1012
+ return null;
1013
+ }
1014
+ /**
1015
+ * 逐条比对两份索引 (只看**链上事实**: 键/块号/块哈希/事件名/taskKey/logIndex/args)。
1016
+ * 确认数与 finality 随 head 变, 不参与比对 (这是设计, 不是漏项)。
1017
+ */
1018
+ export function compareIndexes(a, b) {
1019
+ const ma = new Map(a.map((e) => [e.key, e]));
1020
+ const mb = new Map(b.map((e) => [e.key, e]));
1021
+ const missingInB = [];
1022
+ const extraInB = [];
1023
+ const mismatched = [];
1024
+ for (const [k, ea] of ma) {
1025
+ const eb = mb.get(k);
1026
+ if (!eb) {
1027
+ missingInB.push(k);
1028
+ continue;
1029
+ }
1030
+ for (const f of ['blockNumber', 'blockHash', 'eventName', 'taskKey', 'logIndex']) {
1031
+ if (ea[f] !== eb[f])
1032
+ mismatched.push({ key: k, field: f, a: ea[f], b: eb[f] });
1033
+ }
1034
+ const sa = JSON.stringify(ea.args), sb = JSON.stringify(eb.args);
1035
+ if (sa !== sb)
1036
+ mismatched.push({ key: k, field: 'args', a: sa, b: sb });
1037
+ }
1038
+ for (const k of mb.keys())
1039
+ if (!ma.has(k))
1040
+ extraInB.push(k);
1041
+ return {
1042
+ same: missingInB.length === 0 && extraInB.length === 0 && mismatched.length === 0,
1043
+ countA: ma.size, countB: mb.size, missingInB, extraInB, mismatched,
1044
+ };
1045
+ }