@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.
- package/README.md +4 -4
- package/dist/agents/chain/transfer-classify.js +99 -0
- package/dist/agents/chain/transfer-index.js +354 -0
- package/dist/agents/identity/address-binding.js +613 -0
- package/dist/agents/network-pulse.js +706 -27
- package/dist/agents/x402/direct-payment.js +689 -0
- package/dist/agents/x402/order-identity.js +408 -0
- package/dist/agents/x402/paid-info-store.js +17 -0
- package/dist/agents/x402/seller-signing.js +614 -0
- package/dist/agents/x402/seller-summary.js +398 -0
- package/dist/cli/identity-command.js +327 -2
- package/dist/cli/protocol-envelope.js +4 -0
- package/dist/cli/update-commands.js +41 -25
- package/dist/cli/x402-buyer-command.js +463 -0
- package/dist/cli/x402-seller-command.js +289 -0
- package/dist/cli-entry.js +21 -5
- package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
- package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
- package/dist/electron-build/agents/chain/transfer-index.js +400 -0
- package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
- package/dist/electron-build/agents/identity/address-binding.js +672 -0
- package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
- package/dist/electron-build/agents/network-pulse.js +714 -28
- package/dist/electron-build/agents/network-pulse.js.map +1 -1
- package/dist/electron-build/agents/x402/direct-payment.js +744 -0
- package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
- package/dist/electron-build/agents/x402/order-identity.js +458 -0
- package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
- package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
- package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
- package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
- package/dist/electron-build/agents/x402/seller-signing.js +682 -0
- package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
- package/dist/electron-build/utils/auto-update.js +2 -2
- package/dist/electron-build/utils/auto-update.js.map +1 -1
- package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
- package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
- package/dist/electron-build/utils/update-manager.js +3 -3
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +1 -1
- package/dist/electron-build/utils/version-identity.js.map +1 -1
- package/dist/electron-build/web/routes-x402-info.js +28 -1
- package/dist/electron-build/web/routes-x402-info.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/utils/auto-update.js +2 -2
- package/dist/utils/runtime-bootstrap.js +1 -1
- package/dist/utils/update-health.js +2 -2
- package/dist/utils/update-manager.js +3 -3
- package/dist/utils/version-identity.js +1 -1
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/routes-x402-info.js +28 -1
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -96,11 +96,11 @@ npm start
|
|
|
96
96
|
不想在本机装 Node 与依赖, 可以直接跑容器 (多阶段镜像 · 非 root 用户 uid 1001 · 状态全部落在卷里):
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
docker build -t bolloon-agent:
|
|
99
|
+
docker build -t bolloon-agent:local .
|
|
100
100
|
docker run -d --name bolloon-agent \
|
|
101
101
|
-p 127.0.0.1:54188:54188 \
|
|
102
102
|
-v bolloon-data:/home/bolloon/.bolloon \
|
|
103
|
-
bolloon-agent:
|
|
103
|
+
bolloon-agent:local --web
|
|
104
104
|
# → http://127.0.0.1:54188
|
|
105
105
|
|
|
106
106
|
docker compose up -d # 同上的 compose 写法
|
|
@@ -167,9 +167,9 @@ Requires Node.js ≥ 18 and an LLM API key (`OPENAI_API_KEY` or `DEEPSEEK_API_KE
|
|
|
167
167
|
Run it in a container instead (multi-stage image, non-root user, state in a volume):
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
|
-
docker build -t bolloon-agent:
|
|
170
|
+
docker build -t bolloon-agent:local .
|
|
171
171
|
docker run -d --name bolloon-agent -p 127.0.0.1:54188:54188 \
|
|
172
|
-
-v bolloon-data:/home/bolloon/.bolloon bolloon-agent:
|
|
172
|
+
-v bolloon-data:/home/bolloon/.bolloon bolloon-agent:local --web
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
Copy `.env.example` to `.env.docker` (gitignored) for keys, or mount your `llm-config.json` read-only
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transfer-classify.ts — 转账索引的**纯**部分 (类型 + 分类 + 折算 + 汇总)
|
|
3
|
+
* =========================================================================
|
|
4
|
+
* 为什么单独一个文件: 这两半的**加载代价**完全不同 ——
|
|
5
|
+
* · 本文件: 零依赖 (不 import ethers / 不发 RPC / 不读盘) ⇒ 公开页/快照构建可以**静态**引入;
|
|
6
|
+
* · `transfer-index.ts`: 要 JSON-RPC provider (ethers) 与文件系统 ⇒ 只在**同步**路径上加载。
|
|
7
|
+
* 把纯函数放在这里, network-pulse 才不必为了 `formatUnits` 把 ethers 拖进网页进程。
|
|
8
|
+
*
|
|
9
|
+
* 分类口径 (leo 2026-09-29 实测拍板; **不许**只按"USDC 转到该地址"计数):
|
|
10
|
+
* · `escrow_settlement` from == escrow 合约 → 「escrow 退款/释放」(实测块 51685757 / 51686009
|
|
11
|
+
* 两笔 +0.001 与那两笔 Disputed→Refunded **逐块对应**) —— 钱退回, **不是收入**;
|
|
12
|
+
* · `self_transfer` from ∈ 自己地址集 (可配 ownAddresses) → 「转入(非销售)」;
|
|
13
|
+
* · `external_payment` 其余 → 「外部付款」(与卖方端点台账对上的加标「经 x402 流程」);
|
|
14
|
+
* · `outbound` 关注地址**付出** → 不进「链上转入」计数。
|
|
15
|
+
*/
|
|
16
|
+
export const TRANSFER_INDEX_SCHEMA_VERSION = 1;
|
|
17
|
+
/** Transfer(address indexed from, address indexed to, uint256 value) 的 topic0 */
|
|
18
|
+
export const ERC20_TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
|
|
19
|
+
/** Base 主网 USDC (缺省 token; 也可由 chain.json 的 tokenAddress / env 覆盖) */
|
|
20
|
+
export const USDC_BY_CHAIN = {
|
|
21
|
+
8453: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
22
|
+
84532: '0x036cbd53842c5426634e7929541ec2318f3dcf7e',
|
|
23
|
+
};
|
|
24
|
+
/** 0x + 40 位十六进制 (地址形状; 与 explorer.ts 的同一套尺子) */
|
|
25
|
+
export const TRANSFER_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
26
|
+
/** 原子单位 → 十进制字符串 (纯整数运算, 不经过浮点; 去掉末尾多余的 0) */
|
|
27
|
+
export function formatUnits(atomic, decimals) {
|
|
28
|
+
const v = BigInt(typeof atomic === 'bigint' ? atomic : (String(atomic || '0').trim() || '0'));
|
|
29
|
+
const neg = v < 0n;
|
|
30
|
+
const abs = neg ? -v : v;
|
|
31
|
+
const d = Math.max(0, Math.floor(decimals));
|
|
32
|
+
const base = 10n ** BigInt(d);
|
|
33
|
+
const whole = (abs / base).toString();
|
|
34
|
+
const frac = (abs % base).toString().padStart(d, '0').replace(/0+$/, '');
|
|
35
|
+
const out = frac ? `${whole}.${frac}` : whole;
|
|
36
|
+
return neg ? `-${out}` : out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 一条转账的**分类** (纯函数: 输入事实 + 配置, 输出分类)。
|
|
40
|
+
* 为什么在**读**的时候算而不是写索引时算: 分类规则 / 自己地址集是会变的
|
|
41
|
+
* (把 0xb4cb8009… 认成"自己钱包"之前, 它只是一个外部地址) ⇒ 历史行必须能被重新分类,
|
|
42
|
+
* 而不是把当时的判断固化进索引。索引里只存**链上事实** (from/to/value/块号)。
|
|
43
|
+
*/
|
|
44
|
+
export function classifyTransfer(e, cfg) {
|
|
45
|
+
const from = String(e.from || '').toLowerCase();
|
|
46
|
+
const to = String(e.to || '').toLowerCase();
|
|
47
|
+
const watch = new Set((cfg.watchAddresses || []).map((a) => String(a).toLowerCase()));
|
|
48
|
+
if (e.direction === 'out' || (watch.has(from) && !watch.has(to)))
|
|
49
|
+
return 'outbound';
|
|
50
|
+
const escrow = String(cfg.escrowAddress || '').toLowerCase();
|
|
51
|
+
if (escrow && from === escrow)
|
|
52
|
+
return 'escrow_settlement';
|
|
53
|
+
if ((cfg.ownAddresses || []).map((a) => String(a).toLowerCase()).includes(from))
|
|
54
|
+
return 'self_transfer';
|
|
55
|
+
return 'external_payment';
|
|
56
|
+
}
|
|
57
|
+
/** 已落盘转账索引 → 汇总 (纯函数; 不发网络、不读配置以外的东西) */
|
|
58
|
+
export function summarizeTransfers(state, cfg) {
|
|
59
|
+
const live = (state.entries || []).filter((e) => !e.suspect);
|
|
60
|
+
const by_class = { escrow_settlement: 0, self_transfer: 0, external_payment: 0 };
|
|
61
|
+
const by_class_atomic = { escrow_settlement: '0', self_transfer: '0', external_payment: '0' };
|
|
62
|
+
let inbound = 0, inboundAtomic = 0n, outbound = 0, outboundAtomic = 0n, pending = 0;
|
|
63
|
+
for (const e of live) {
|
|
64
|
+
if (e.finality === 'observed')
|
|
65
|
+
pending++;
|
|
66
|
+
const v = BigInt(e.value || '0');
|
|
67
|
+
if (e.direction === 'out') {
|
|
68
|
+
outbound++;
|
|
69
|
+
outboundAtomic += v;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
inbound++;
|
|
73
|
+
inboundAtomic += v;
|
|
74
|
+
const c = classifyTransfer(e, cfg);
|
|
75
|
+
if (c !== 'outbound') {
|
|
76
|
+
by_class[c]++;
|
|
77
|
+
by_class_atomic[c] = (BigInt(by_class_atomic[c]) + v).toString();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const decimals = Number.isInteger(state.tokenDecimals) ? state.tokenDecimals : (cfg.tokenDecimals ?? 6);
|
|
81
|
+
return {
|
|
82
|
+
configured: cfg.enabled !== false && !!((state.watchAddresses || cfg.watchAddresses || []).length),
|
|
83
|
+
reason: cfg.reason || '',
|
|
84
|
+
entries: live.length,
|
|
85
|
+
suspects: (state.entries || []).length - live.length,
|
|
86
|
+
inbound, inbound_atomic: inboundAtomic.toString(), inbound_display: formatUnits(inboundAtomic, decimals),
|
|
87
|
+
by_class, by_class_atomic,
|
|
88
|
+
outbound, outbound_atomic: outboundAtomic.toString(),
|
|
89
|
+
token_symbol: state.tokenSymbol || cfg.tokenSymbol || '',
|
|
90
|
+
token_decimals: decimals,
|
|
91
|
+
watch_addresses_count: (state.watchAddresses || cfg.watchAddresses || []).length,
|
|
92
|
+
own_addresses_count: (cfg.ownAddresses || []).length,
|
|
93
|
+
from_block: state.fromBlock,
|
|
94
|
+
last_synced_block: state.lastSyncedBlock,
|
|
95
|
+
last_synced_at: state.lastSyncedAt,
|
|
96
|
+
head_block: state.headBlock,
|
|
97
|
+
pending_observation: pending,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transfer-index.ts — **关注地址集**内的 ERC-20 (USDC) 转账索引 (2026-09-29)
|
|
3
|
+
* =========================================================================
|
|
4
|
+
* 为什么需要它 (leo:「我要记录的是链上数据…网关要显示的是所有交互」):
|
|
5
|
+
* 旧索引只扫 AgentEscrow 合约日志 ⇒ 走**直付 (买方自己发 USDC 到 payTo)** 的收款
|
|
6
|
+
* 在链上**根本没有 escrow 事件** (x402 在链上没有自己的事件; 付款就是一笔普通 ERC-20
|
|
7
|
+
* USDC 转账) ⇒ 「所有交互」在旧口径里是漏的。
|
|
8
|
+
*
|
|
9
|
+
* 干什么: 把 `tokenAddress` (缺省 = 本链 USDC) 上 **to ∈ 关注地址集 / from ∈ 关注地址集**
|
|
10
|
+
* 的 Transfer 日志扫进 `~/.bolloon/chain/transfers.json`, 让「链上转入」可核验
|
|
11
|
+
* (每笔带 txHash / 块号 / 原子金额; from/to 只留在本机索引里, 不进公开快照)。
|
|
12
|
+
*
|
|
13
|
+
* 硬约束 (每条都能被验收脚本证伪):
|
|
14
|
+
* ① 关注地址集来自**配置** (`~/.bolloon/chain.json` 的 `watchAddresses`), env
|
|
15
|
+
* (`BOLLOON_WATCH_ADDRESSES`) 可覆盖 —— **不硬编码任何单个地址**;
|
|
16
|
+
* 没配 → 索引自称 `enabled:false` 且**不猜** (页面上这一格显示「未配置」, 不显示 0)。
|
|
17
|
+
* ② 分页 eth_getLogs ≤ pageSize (默认 2000 —— 实测 mainnet.base.org 的 eth_getLogs
|
|
18
|
+
* **单次上限 2000 块**, 3000/5000/10000 块窗口全被拒: `is limited to a 2,000 range`);
|
|
19
|
+
* provider 报错就**对半拆**再试, 不静默漏页。
|
|
20
|
+
* ③ 去重键 = `txHash:logIndex` (小写), 幂等重跑 0 重复。
|
|
21
|
+
* ④ 起点 `fromBlock` 来自配置 (`transferScanFromBlock`), 缺省 = 链索引的 deploymentBlock
|
|
22
|
+
* (即 Bolloon 在 Base 主网上的起点) —— **不是** 0, 也不是"最新块"(那会漏掉历史收款)。
|
|
23
|
+
* ⑤ 重组: 每次同步**强制回扫**最后 `reorgDepth` (默认 32) 块, 用重扫结果**重写**该窗口内的
|
|
24
|
+
* 条目; 重扫后不再出现的键 → 标 `suspect` 并保留 (不静默丢弃)。
|
|
25
|
+
* ⑥ 本模块**只读链** (eth_getLogs / eth_getBlockByNumber), 不签名、不发交易、不碰私钥。
|
|
26
|
+
*
|
|
27
|
+
* 与 escrow 索引的关系: **互不影响**。escrow 索引 (`chain/index.json`) 的身份门 / 重组逻辑
|
|
28
|
+
* 一行未动 —— 本模块用**另一个文件** (`chain/transfers.json`) 记自己的游标, 只做增量 + 回扫。
|
|
29
|
+
*
|
|
30
|
+
* 纯函数 (分类 / 折算 / 汇总) 在 `transfer-classify.ts` —— 这里只做 RPC + 落盘, 并把纯函数**转出**
|
|
31
|
+
* (老调用方可以只 import 本文件)。
|
|
32
|
+
*/
|
|
33
|
+
import * as fs from 'fs';
|
|
34
|
+
import * as path from 'path';
|
|
35
|
+
import { createJsonRpcProvider } from './escrow-client.js';
|
|
36
|
+
import { bolloonHome, chainConfigPath, DEFAULT_CONFIRMATIONS } from './chain-config.js';
|
|
37
|
+
import { TRANSFER_INDEX_SCHEMA_VERSION, ERC20_TRANSFER_TOPIC, USDC_BY_CHAIN, TRANSFER_ADDRESS_RE, } from './transfer-classify.js';
|
|
38
|
+
export * from './transfer-classify.js';
|
|
39
|
+
export function transferIndexPath(home) {
|
|
40
|
+
return path.join(bolloonHome(home), 'chain', 'transfers.json');
|
|
41
|
+
}
|
|
42
|
+
/** 读本机 chain.json (读不出 → 空对象; 不抛) */
|
|
43
|
+
function readChainJson(home) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(chainConfigPath(home), 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function resolveAddrSet(raw, source) {
|
|
52
|
+
const addresses = [];
|
|
53
|
+
const rejected = [];
|
|
54
|
+
for (const a of raw) {
|
|
55
|
+
if (TRANSFER_ADDRESS_RE.test(String(a)))
|
|
56
|
+
addresses.push(String(a).toLowerCase());
|
|
57
|
+
else
|
|
58
|
+
rejected.push(String(a));
|
|
59
|
+
}
|
|
60
|
+
return { addresses: Array.from(new Set(addresses)).sort(), source, rejected };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 关注地址集解析 (**唯一实现**): env `BOLLOON_WATCH_ADDRESSES` (逗号/空白分隔) 优先,
|
|
64
|
+
* 其次 `~/.bolloon/chain.json` 的 `watchAddresses[]`。非法地址**全部丢弃并如实说明**
|
|
65
|
+
* (不"猜一个像的" —— 地址猜错会把别人的流水索引成本节点的)。
|
|
66
|
+
*/
|
|
67
|
+
export function resolveWatchAddresses(home, env = process.env) {
|
|
68
|
+
const envRaw = String(env.BOLLOON_WATCH_ADDRESSES || '').trim();
|
|
69
|
+
if (envRaw)
|
|
70
|
+
return resolveAddrSet(envRaw.split(/[\s,]+/).filter(Boolean), 'env BOLLOON_WATCH_ADDRESSES');
|
|
71
|
+
const file = readChainJson(home);
|
|
72
|
+
const arr = Array.isArray(file?.watchAddresses) ? file.watchAddresses : [];
|
|
73
|
+
return resolveAddrSet(arr.map((x) => String(x)), arr.length ? `${chainConfigPath(home)} .watchAddresses[]` : '未配置');
|
|
74
|
+
}
|
|
75
|
+
/** 自己地址集解析 (env `BOLLOON_OWN_ADDRESSES` 优先, 其次 chain.json `ownAddresses[]`; 非法全丢) */
|
|
76
|
+
export function resolveOwnAddresses(home, env = process.env) {
|
|
77
|
+
const envRaw = String(env.BOLLOON_OWN_ADDRESSES || '').trim();
|
|
78
|
+
if (envRaw)
|
|
79
|
+
return resolveAddrSet(envRaw.split(/[\s,]+/).filter(Boolean), 'env BOLLOON_OWN_ADDRESSES');
|
|
80
|
+
const file = readChainJson(home);
|
|
81
|
+
const arr = Array.isArray(file?.ownAddresses) ? file.ownAddresses : [];
|
|
82
|
+
return resolveAddrSet(arr.map((x) => String(x)), arr.length ? `${chainConfigPath(home)} .ownAddresses[]` : '未配置');
|
|
83
|
+
}
|
|
84
|
+
/** 转账索引的配置 (纯读配置, 不发 RPC) */
|
|
85
|
+
export function readTransferWatchConfig(home, env = process.env) {
|
|
86
|
+
const file = readChainJson(home);
|
|
87
|
+
const chainId = Number.isInteger(Number(file?.chainId)) ? Number(file.chainId) : 0;
|
|
88
|
+
const networkName = String(file?.networkName || 'unknown');
|
|
89
|
+
const rpcUrl = String(env.BOLLOON_CHAIN_RPC_URL || file?.rpcUrl || '');
|
|
90
|
+
const tokenAddress = String(env.BOLLOON_TRANSFER_TOKEN || file?.tokenAddress || USDC_BY_CHAIN[chainId] || '').toLowerCase();
|
|
91
|
+
const tokenDecimals = Number.isInteger(Number(file?.tokenDecimals)) ? Number(file.tokenDecimals) : 6;
|
|
92
|
+
const { addresses, source, rejected } = resolveWatchAddresses(home, env);
|
|
93
|
+
const own = resolveOwnAddresses(home, env);
|
|
94
|
+
const escrowAddress = String(env.BOLLOON_ESCROW_ADDRESS || file?.escrowAddress || '').toLowerCase();
|
|
95
|
+
const envPage = Number(env.BOLLOON_INDEX_PAGE_SIZE);
|
|
96
|
+
const pageSize = Math.max(1, Math.floor(Number.isInteger(envPage) && envPage > 0 ? envPage : 2000));
|
|
97
|
+
const envDepth = Number(env.BOLLOON_INDEX_REORG_DEPTH);
|
|
98
|
+
const reorgDepth = Math.max(1, Math.floor(Number.isInteger(envDepth) && envDepth > 0 ? envDepth : 32));
|
|
99
|
+
const cfgFrom = Number(env.BOLLOON_TRANSFER_SCAN_FROM || file?.transferScanFromBlock);
|
|
100
|
+
const fromConfigured = Number.isInteger(cfgFrom) && cfgFrom > 0;
|
|
101
|
+
const missing = [];
|
|
102
|
+
if (!TRANSFER_ADDRESS_RE.test(tokenAddress))
|
|
103
|
+
missing.push('tokenAddress');
|
|
104
|
+
if (!addresses.length)
|
|
105
|
+
missing.push(rejected.length ? `关注地址集全部非法: ${rejected.join(', ')}` : '关注地址集 (watchAddresses)');
|
|
106
|
+
if (!rpcUrl)
|
|
107
|
+
missing.push('rpcUrl');
|
|
108
|
+
return {
|
|
109
|
+
enabled: missing.length === 0,
|
|
110
|
+
reason: missing.length ? `未配置/未启用: 缺 ${missing.join(' · ')} (来源: ${source})` : `关注地址集 ${addresses.length} 个 (${source})`,
|
|
111
|
+
chainId, networkName, rpcUrl,
|
|
112
|
+
tokenAddress,
|
|
113
|
+
tokenSymbol: tokenAddress === USDC_BY_CHAIN[8453] || tokenAddress === USDC_BY_CHAIN[84532] ? 'USDC' : 'ERC20',
|
|
114
|
+
tokenDecimals,
|
|
115
|
+
watchAddresses: addresses,
|
|
116
|
+
ownAddresses: own.addresses,
|
|
117
|
+
escrowAddress,
|
|
118
|
+
// 起点: 显式配置优先; 否则留给调用方用 escrow deploymentBlock 填 (见 syncTransfers 的 opts.fromBlock)
|
|
119
|
+
fromBlock: fromConfigured ? cfgFrom : 0,
|
|
120
|
+
fromBlockSource: fromConfigured ? '配置 transferScanFromBlock' : '未配置 → 由调用方给 (缺省 = escrow 部署块)',
|
|
121
|
+
confirmations: DEFAULT_CONFIRMATIONS,
|
|
122
|
+
pageSize, reorgDepth,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function emptyIndex(cfg, fromBlock) {
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: TRANSFER_INDEX_SCHEMA_VERSION,
|
|
128
|
+
chainId: cfg.chainId, networkName: cfg.networkName,
|
|
129
|
+
tokenAddress: cfg.tokenAddress, tokenSymbol: cfg.tokenSymbol, tokenDecimals: cfg.tokenDecimals,
|
|
130
|
+
watchAddresses: cfg.watchAddresses,
|
|
131
|
+
fromBlock,
|
|
132
|
+
lastSyncedBlock: fromBlock - 1,
|
|
133
|
+
lastSyncedAt: null, headBlock: null, headBlockHash: null,
|
|
134
|
+
pageSize: cfg.pageSize, reorgDepth: cfg.reorgDepth,
|
|
135
|
+
entries: [], runs: [], updatedAt: Date.now(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function readTransferIndex(home) {
|
|
139
|
+
const cfg = readTransferWatchConfig(home);
|
|
140
|
+
const p = transferIndexPath(home);
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return emptyIndex(cfg, cfg.fromBlock || 0);
|
|
147
|
+
}
|
|
148
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.entries))
|
|
149
|
+
return emptyIndex(cfg, cfg.fromBlock || 0);
|
|
150
|
+
const dedup = new Map();
|
|
151
|
+
for (const e of raw.entries) {
|
|
152
|
+
const key = String(e?.key || '');
|
|
153
|
+
if (!key || dedup.has(key))
|
|
154
|
+
continue; // 坏文件里的重复键不许渗进来
|
|
155
|
+
dedup.set(key, e);
|
|
156
|
+
}
|
|
157
|
+
const entries = Array.from(dedup.values()).sort((a, b) => a.blockNumber - b.blockNumber || a.logIndex - b.logIndex);
|
|
158
|
+
return {
|
|
159
|
+
...emptyIndex(cfg, Number.isInteger(raw.fromBlock) ? Number(raw.fromBlock) : cfg.fromBlock),
|
|
160
|
+
chainId: Number(raw.chainId) || cfg.chainId,
|
|
161
|
+
networkName: String(raw.networkName || cfg.networkName),
|
|
162
|
+
tokenAddress: String(raw.tokenAddress || cfg.tokenAddress).toLowerCase(),
|
|
163
|
+
tokenSymbol: String(raw.tokenSymbol || cfg.tokenSymbol),
|
|
164
|
+
tokenDecimals: Number.isInteger(raw.tokenDecimals) ? Number(raw.tokenDecimals) : cfg.tokenDecimals,
|
|
165
|
+
watchAddresses: Array.isArray(raw.watchAddresses) ? raw.watchAddresses.map((a) => String(a).toLowerCase()) : cfg.watchAddresses,
|
|
166
|
+
fromBlock: Number.isInteger(raw.fromBlock) ? Number(raw.fromBlock) : cfg.fromBlock,
|
|
167
|
+
lastSyncedBlock: Number.isInteger(raw.lastSyncedBlock) ? Number(raw.lastSyncedBlock) : cfg.fromBlock - 1,
|
|
168
|
+
lastSyncedAt: raw.lastSyncedAt ?? null,
|
|
169
|
+
headBlock: Number.isInteger(raw.headBlock) ? Number(raw.headBlock) : null,
|
|
170
|
+
headBlockHash: raw.headBlockHash ?? null,
|
|
171
|
+
pageSize: Number(raw.pageSize) || cfg.pageSize,
|
|
172
|
+
reorgDepth: Number(raw.reorgDepth) || cfg.reorgDepth,
|
|
173
|
+
entries,
|
|
174
|
+
runs: Array.isArray(raw.runs) ? raw.runs.slice(-50) : [],
|
|
175
|
+
updatedAt: Number(raw.updatedAt) || Date.now(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** 原子写 (tmp + rename) */
|
|
179
|
+
export async function writeTransferIndex(home, state) {
|
|
180
|
+
const p = transferIndexPath(home);
|
|
181
|
+
await fs.promises.mkdir(path.dirname(p), { recursive: true });
|
|
182
|
+
const tmp = `${p}.tmp-${process.pid}`;
|
|
183
|
+
state.updatedAt = Date.now();
|
|
184
|
+
state.runs = state.runs.slice(-50);
|
|
185
|
+
await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
186
|
+
await fs.promises.rename(tmp, p);
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
const topicForAddress = (a) => '0x' + a.replace(/^0x/, '').padStart(64, '0');
|
|
190
|
+
/**
|
|
191
|
+
* 从原始日志取 logIndex: **原始 JSON-RPC 里叫 `logIndex`, ethers v6 的 Log 对象里叫 `index`**
|
|
192
|
+
* (两个都要认 —— 只认一个的话 ethers 路径会全部拿到 undefined, 索引里全是 -1, 行会被当成非法丢弃:
|
|
193
|
+
* 2026-09-29 实测踩过, 4 笔转入一笔都没出行)。同一套兼容在 chain-indexer.ts 的 logIndexOf 里。
|
|
194
|
+
*/
|
|
195
|
+
function logIndexOfRaw(raw) {
|
|
196
|
+
const n = Number(raw?.logIndex ?? raw?.index);
|
|
197
|
+
return Number.isInteger(n) && n >= 0 ? n : -1;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 增量同步 (**唯一写入口**):
|
|
201
|
+
* scanFrom = min(强制回扫窗口起点, lastSyncedBlock + 1); fromBlock 覆盖时取更小的那个 (补扫)
|
|
202
|
+
* scanTo = 当前链 head
|
|
203
|
+
* 每次同步都回扫最后 `reorgDepth` 块并把该窗口内的条目**重写**; 重扫里消失的键标 suspect 保留。
|
|
204
|
+
*/
|
|
205
|
+
export async function syncTransfers(opts = {}) {
|
|
206
|
+
const cfg = opts.config || readTransferWatchConfig(opts.home);
|
|
207
|
+
const log = opts.log || (() => { });
|
|
208
|
+
if (!cfg.enabled)
|
|
209
|
+
return { ok: false, skipped: cfg.reason, configuration: cfg };
|
|
210
|
+
const fromBlock = Math.max(0, Math.floor(opts.fromBlock ?? cfg.fromBlock));
|
|
211
|
+
if (!Number.isInteger(fromBlock) || fromBlock <= 0) {
|
|
212
|
+
return { ok: false, skipped: '缺扫描起点: 配置里没有 transferScanFromBlock, 调用方也没给 fromBlock (不猜 0 —— 从 0 开始扫会白扫几十万块)', configuration: cfg };
|
|
213
|
+
}
|
|
214
|
+
const t0 = Date.now();
|
|
215
|
+
const now = opts.now ?? Date.now();
|
|
216
|
+
const state = readTransferIndex(opts.home);
|
|
217
|
+
// 起点只在**首次**或**显式补扫**时生效 (不许被后续增量悄悄改大 —— 那会漏历史收款)
|
|
218
|
+
const effectiveFrom = state.entries.length === 0 && state.lastSyncedAt == null ? fromBlock : Math.min(fromBlock, state.fromBlock || fromBlock);
|
|
219
|
+
const provider = opts.provider || createJsonRpcProvider(cfg.rpcUrl);
|
|
220
|
+
const headNum = await provider.getBlockNumber();
|
|
221
|
+
const headBlock = await provider.getBlock(headNum);
|
|
222
|
+
if (!headBlock)
|
|
223
|
+
throw new Error(`读不到 head 区块 ${headNum} (RPC 异常)`);
|
|
224
|
+
const rewindFrom = Math.max(effectiveFrom, state.lastSyncedBlock - cfg.reorgDepth + 1);
|
|
225
|
+
const incremental = state.lastSyncedBlock + 1;
|
|
226
|
+
const scanFrom = Math.max(effectiveFrom, Math.min(incremental, rewindFrom));
|
|
227
|
+
if (scanFrom > headNum) {
|
|
228
|
+
return { ok: true, skipped: `已是最新 (lastSyncedBlock=${state.lastSyncedBlock} >= head=${headNum})`, configuration: cfg, lastSyncedBlock: state.lastSyncedBlock, headBlock: headNum };
|
|
229
|
+
}
|
|
230
|
+
// 逐页扫: 每个关注地址两条过滤 (from 侧 / to 侧)
|
|
231
|
+
const found = new Map();
|
|
232
|
+
let pages = 0;
|
|
233
|
+
const tryGet = async (filter, a, b) => {
|
|
234
|
+
try {
|
|
235
|
+
return await provider.getLogs({ ...filter, fromBlock: a, toBlock: b });
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
const msg = String(e?.shortMessage || e?.message || e);
|
|
239
|
+
if (a === b)
|
|
240
|
+
throw e;
|
|
241
|
+
log(` ⚠ eth_getLogs(${a}-${b}) 失败 (${msg.slice(0, 90)}) → 对半拆`);
|
|
242
|
+
const mid = Math.floor((a + b) / 2);
|
|
243
|
+
const left = await tryGet(filter, a, mid);
|
|
244
|
+
const right = await tryGet(filter, mid + 1, b);
|
|
245
|
+
return [...left, ...right];
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const filters = [];
|
|
249
|
+
for (const w of cfg.watchAddresses) {
|
|
250
|
+
filters.push({ address: cfg.tokenAddress, topics: [ERC20_TRANSFER_TOPIC, topicForAddress(w)] }); // from 侧
|
|
251
|
+
filters.push({ address: cfg.tokenAddress, topics: [ERC20_TRANSFER_TOPIC, null, topicForAddress(w)] }); // to 侧
|
|
252
|
+
}
|
|
253
|
+
const watch = new Set(cfg.watchAddresses);
|
|
254
|
+
for (let a = scanFrom; a <= headNum; a += cfg.pageSize) {
|
|
255
|
+
const b = Math.min(a + cfg.pageSize - 1, headNum);
|
|
256
|
+
for (const f of filters) {
|
|
257
|
+
const logs = await tryGet(f, a, b);
|
|
258
|
+
for (const l of logs || [])
|
|
259
|
+
found.set(`${String(l.transactionHash).toLowerCase()}:${logIndexOfRaw(l)}`, l);
|
|
260
|
+
}
|
|
261
|
+
pages++;
|
|
262
|
+
log(` · 扫 ${a}-${b} (${pages} 页, 命中 ${found.size} 条)`);
|
|
263
|
+
}
|
|
264
|
+
// 重扫窗口内的旧条目先摘出去 (下面按重扫结果重写)
|
|
265
|
+
const inWindow = (n) => n >= scanFrom && n <= headNum;
|
|
266
|
+
const kept = state.entries.filter((e) => !inWindow(e.blockNumber));
|
|
267
|
+
const removed = state.entries.filter((e) => inWindow(e.blockNumber));
|
|
268
|
+
const byKey = new Map(kept.map((e) => [e.key, e]));
|
|
269
|
+
let inserted = 0, deduped = 0, rewritten = 0;
|
|
270
|
+
const finalityOf = (conf) => (conf >= cfg.confirmations.finalized ? 'finalized' : (conf >= cfg.confirmations.confirmed ? 'confirmed' : 'observed'));
|
|
271
|
+
for (const [key, l] of found) {
|
|
272
|
+
const prev = removed.find((e) => e.key === key);
|
|
273
|
+
const topics = l.topics || [];
|
|
274
|
+
const from = '0x' + String(topics[1] || '').slice(-40);
|
|
275
|
+
const to = '0x' + String(topics[2] || '').slice(-40);
|
|
276
|
+
const block = Number(l.blockNumber);
|
|
277
|
+
const value = BigInt(l.data || '0x0').toString();
|
|
278
|
+
const direction = watch.has(to.toLowerCase()) ? 'in' : 'out';
|
|
279
|
+
const conf = Math.max(0, headNum - block + 1);
|
|
280
|
+
const entry = {
|
|
281
|
+
key,
|
|
282
|
+
blockNumber: block,
|
|
283
|
+
blockHash: String(l.blockHash || ''),
|
|
284
|
+
txHash: String(l.transactionHash).toLowerCase(),
|
|
285
|
+
txIndex: Number.isInteger(Number(l.transactionIndex)) ? Number(l.transactionIndex) : -1,
|
|
286
|
+
logIndex: logIndexOfRaw(l),
|
|
287
|
+
address: String(l.address || '').toLowerCase(),
|
|
288
|
+
from: from.toLowerCase(),
|
|
289
|
+
to: to.toLowerCase(),
|
|
290
|
+
value, direction,
|
|
291
|
+
confirmations: conf,
|
|
292
|
+
finality: finalityOf(conf),
|
|
293
|
+
firstSeenAt: prev?.firstSeenAt ?? now,
|
|
294
|
+
};
|
|
295
|
+
if (prev) {
|
|
296
|
+
rewritten++;
|
|
297
|
+
removed.splice(removed.indexOf(prev), 1);
|
|
298
|
+
}
|
|
299
|
+
else if (byKey.has(key))
|
|
300
|
+
deduped++;
|
|
301
|
+
else
|
|
302
|
+
inserted++;
|
|
303
|
+
byKey.set(key, entry);
|
|
304
|
+
}
|
|
305
|
+
// 重扫窗口内**不再出现**的旧条目: 标 suspect 保留 (不静默丢弃)
|
|
306
|
+
let markedSuspect = 0;
|
|
307
|
+
for (const e of removed) {
|
|
308
|
+
e.suspect = true;
|
|
309
|
+
e.suspectReason = `重扫 [${scanFrom}, ${headNum}] 后该日志不再出现 (链回滚 / 重组)`;
|
|
310
|
+
markedSuspect++;
|
|
311
|
+
byKey.set(e.key, e);
|
|
312
|
+
}
|
|
313
|
+
// 更新窗口外条目的确认数 (head 在长)
|
|
314
|
+
const entries = Array.from(byKey.values()).sort((x, y) => x.blockNumber - y.blockNumber || x.logIndex - y.logIndex);
|
|
315
|
+
for (const e of entries) {
|
|
316
|
+
if (inWindow(e.blockNumber))
|
|
317
|
+
continue;
|
|
318
|
+
e.confirmations = Math.max(0, headNum - e.blockNumber + 1);
|
|
319
|
+
e.finality = (e.suspect ? 'observed' : finalityOf(e.confirmations));
|
|
320
|
+
}
|
|
321
|
+
const nextState = {
|
|
322
|
+
...state,
|
|
323
|
+
chainId: cfg.chainId || state.chainId,
|
|
324
|
+
networkName: cfg.networkName || state.networkName,
|
|
325
|
+
tokenAddress: cfg.tokenAddress, tokenSymbol: cfg.tokenSymbol, tokenDecimals: cfg.tokenDecimals,
|
|
326
|
+
watchAddresses: cfg.watchAddresses,
|
|
327
|
+
fromBlock: effectiveFrom,
|
|
328
|
+
lastSyncedBlock: Math.max(state.lastSyncedBlock, headNum),
|
|
329
|
+
lastSyncedAt: now,
|
|
330
|
+
headBlock: headNum,
|
|
331
|
+
headBlockHash: String(headBlock.hash || ''),
|
|
332
|
+
pageSize: cfg.pageSize, reorgDepth: cfg.reorgDepth,
|
|
333
|
+
entries,
|
|
334
|
+
runs: [...state.runs, {
|
|
335
|
+
at: now,
|
|
336
|
+
mode: state.lastSyncedAt == null || effectiveFrom < state.fromBlock ? 'backfill' : 'sync',
|
|
337
|
+
scanFrom, scanTo: headNum, pages,
|
|
338
|
+
blocksScanned: headNum - scanFrom + 1,
|
|
339
|
+
logsFound: found.size, inserted, deduped, rewritten, markedSuspect,
|
|
340
|
+
headBlock: headNum, durationMs: Date.now() - t0,
|
|
341
|
+
}],
|
|
342
|
+
};
|
|
343
|
+
const indexPath = await writeTransferIndex(opts.home, nextState);
|
|
344
|
+
const live = entries.filter((e) => !e.suspect && e.direction === 'in');
|
|
345
|
+
const inWindowNew = live.filter((e) => inWindow(e.blockNumber));
|
|
346
|
+
return {
|
|
347
|
+
ok: true, indexPath, configuration: cfg,
|
|
348
|
+
scanFrom, scanTo: headNum, pages,
|
|
349
|
+
logsFound: found.size, inserted, deduped, rewritten, markedSuspect,
|
|
350
|
+
entries: entries.length, lastSyncedBlock: nextState.lastSyncedBlock, headBlock: headNum,
|
|
351
|
+
received: inWindowNew.length,
|
|
352
|
+
receivedAtomic: inWindowNew.reduce((n, e) => n + BigInt(e.value), 0n).toString(),
|
|
353
|
+
};
|
|
354
|
+
}
|