BlockVectra

Traces 数据集(traces / contract_creations / native_transfers / _trace_gaps)

对应 DDL:sql/traces.sql(表与两个派生 MV)+ sql/traces_block_hash.sql(block_hash 迁移, 2026-09-25 加入)。写入方是 chain-indexer follow --traces(src/trace.rs 解码、 src/sink_cli…

对应 DDL:sql/traces.sql(表与两个派生 MV)+ sql/traces_block_hash.sql(block_hash 迁移, 2026-09-25 加入)。写入方是 chain-indexer follow --traces(src/trace.rs 解码、 src/sink_clickhouse.rs 的 ClickHouseTraceIngest 落库)。

一句话:traces 是按 (block_number, tx_index, trace_address) 展开的 EVM 调用轨迹 (callTracer 口径,含顶层的顶层调用与所有子调用)。2026-09-25 起每一行都带 block_hash, 用来证明这行轨迹来自哪一个分叉——这是本文档的重点,见 §2。


1. 表结构要点

{db}.traces(完整列见 sql/traces.sql,引擎 ReplacingMergeTree(version, is_deleted), ORDER BY (block_number, tx_index, trace_address),读取一律 FINAL + is_deleted = 0):

列说明
block_number / block_timestamp区块号 / 区块时间(时间来自同一批 blocks 行)
block_hash该行轨迹所属分叉的区块哈希(FixedString(32),hex() 看即可)。全 0 表示"fork 未标注"(迁移前/迁移过渡期写入的行,见 §5)
tx_index / tx_hash交易在区块内的下标 / 哈希
trace_address调用树路径,[] 是顶层调用,[2, 1] 是"第 3 个子调用的第 2 个子调用"
depth / call_type / from / to深度(= trace_address 长度)/ CALL·STATICCALL·DELEGATECALL·CREATE… / 调用方 / 被调方(CREATE 系是部署出的地址,可能为 NULL)
value / gas / gas_usedUInt256 / UInt64 / UInt64,链上原始整数
input / output原始字节(hex()/unhex()),output 空串表示 tracer 没给
error / revert_reason空串 = 未回滚;revert_reason 是 require/revert 消息,可 NULL
extra未提升的调用帧字段(如 Arbitrum 的 beforeEVMTransfers/afterEVMTransfers),原样 JSON
version / is_deletedReplacingMergeTree 版本号 / 重组墓碑标记

两张派生表(同一文件里的插入触发 MV,1:1 逐行透传 version/is_deleted, 即 CORRECTNESS RULE 的 (a) 方案——不做任何 SUM/COUNT,所以 backfill 重复插入和重组墓碑 在 FINAL 上的折叠行为与 traces 完全一致,不会双计):

  • {db}.contract_creations:call_type IN ('CREATE','CREATE2'),creator → created_address, input 是 init code、output 是部署出的代码。
  • {db}.native_transfers:value > 0 的原生币内部转账(from/to/value/call_type)。
  • 这两张表没有 block_hash 列(它们的键与 traces 完全一致)。需要分叉身份时按 (block_number, tx_index, trace_address) join 回 {db}.traces FINAL。

{db}._trace_gaps(ReplacingMergeTree(recorded_at),ORDER BY (start, end)):区间账本。 某段区块在 follow 去抓 trace 时已经滑出节点的 pruned trace window(historical state is not available,本节点实测 ~1024 块,[follow].trace_window_blocks 配 950 留余量), 这个区间就被记为一条 gap——不是 bug,是永久的既成事实:该区间的 blocks/ transactions/logs 正常写入,只有 trace 覆盖率缺失。查"哪些区间没有 trace"读这张表。


2. 重组串库问题与 block_hash 修复

2.1 原来的 bug

traces 的键里没有区块哈希,而 trace 是按区块号抓的 (debug_traceBlockByNumber)。follow 的提交顺序是:抓区块 → 抓 trace → 写入。 如果在这两步之间发生重组(链从分叉 A 切到分叉 B),按号抓到的 trace 是分叉 B 的, 而随后写入并提交的 blocks/transactions 行是分叉 A 的:两边的 block_number 一模一样,数据里没有任何字段能发现这件事,链上也不会再发生一次重组来触发回滚。 结果就是一批"用 A 的区块行配 B 的调用轨迹"的脏数据,永远留在库里。

2.2 现在的写入路径

写入方是 ClickHouseTraceIngest,两段式(为了同时保住 #63 的并发性质与分叉正确性):

  1. begin_trace_batch(start, end, head)——先出发、按号抓、不落库: 批次区间一确定就调用 trace::fetch_raw_traces_by_number,对 [start, end) 发一次 debug_traceBlockByNumber JSON-RPC batch(一个批次仍然只有一个请求),与 BlockSource::fetch_range 并发——发请求时本批区块(以及哈希)还不存在。 拿回来的响应是 RawTraceOutcome::Fetched(原始 JSON),未经任何分叉归属,到此为止。

  2. finish_trace_batch(blocks)——区块到齐后逐块校验,必要时按哈希重抓:

    • 对每个区块调 trace::verify_and_decode_block_traces(block, raw) = trace::ensure_trace_matches_block(响应里 item 数 = 本块交易数、顺序一致、 每笔 txHash 逐个等于本块对应交易的哈希;debug_trace* 响应不带区块身份, txHash 是唯一的锚)+ 解码,行上落 TraceRow::block_hash = 本块哈希;
    • 校验不过 → 说明这条按号响应来自别的分叉,对该块调 trace::fetch_and_decode_traces(debug_traceBlockByHash(<本块哈希>))重抓并再次校验, 通过才写;
    • 两次都不过 → fail closed:finish_trace_batch 返回错误, sink.write_batch 不会执行,last_head() 不前进,重启/下一轮重来。 窗口 gap(historical state is not available)与校验无关,按块记进 _trace_gaps。
  3. 行上落 block_hash:TraceRow::block_hash 一路写到 ClickHouse 的 traces.block_hash(CkTrace 的字段与表的列按 ADD COLUMN 的追加位置都排在最后)。 从此"轨迹属于哪个分叉"是可查询的事实,而不是一个假设:§3 的 QA 查询直接拿它和 blocks.hash 比。重组的清理逻辑不变(follow 检测到重组后 delete_traces_from(ancestor+1) 给 block_number >= ancestor+1 的 trace 打墓碑——墓碑复制现在也带上 block_hash)。

诚实说明每一步能证明什么:#63 的并发化决定了 trace 请求必须按号先发, 所以第一层校验能证明的只是"响应与本块交易列表一致"——两个分叉的区块如果交易列表完全 一样,单靠它区分不了(这是并发换来的代价,也是重抓路径存在的原因);而 debug_traceBlockByHash 问的是"这个哈希的块",节点只能按该块作答,没有这个盲区—— 按号校验一旦亮红灯,能修就修(按哈希重抓)、修不了就整体失败,绝不写入。

副作用(有意为之):debug_traceBlockByHash 与 debug_traceBlockByNumber 在真实节点上 错误行为完全一样——对超出窗口的区块返回同一条 historical state is not available, 所以 _trace_gaps 的识别逻辑一个字没改。

2.3 与 PR #63(fix/follow-retry-and-trace-concurrency)的关系

#63 已合入 pre-dev(d530741),本 PR 就是基于它改的。两个改动的关系:

  • #63 让 trace 抓取与区块抓取并发(解决 2026-09-25 08:12 UTC 的 trace 窗口缺口事故), 代价是 trace 请求发出时本批区块(以及它们的哈希)还没拿到,只能按号抓;
  • 本 PR 提供"按哈希抓"(trace::fetch_and_decode_traces,作为重抓路径)与行上 block_hash,并把两者分别接在 begin_trace_batch(按号先发,保住 #63 的并发)与 finish_trace_batch(区块到齐后逐块校验;不过就按哈希重抓,再不过就 fail closed) 上(见 §2.2)。

所以合并后的顺序没有回到"抓区块 → 抓 trace → 写入":#63 的并发化原样保留, finish_trace_batch 只是把"响应能否归到本分叉"这一步补上,任何一条路径都不会把 未经校验的轨迹写进 traces。


3. QA 查询

主查询:scripts/qa/traces_block_hash.sql (替换 {db} 后执行,已带 SETTINGS max_threads = 2):

sed 's/{db}/robinhood/g' scripts/qa/traces_block_hash.sql \
  | curl -sS "$CH_URL/?user=$CH_USER&password=$CH_PASSWORD" --data-binary @-

它做的是:traces FINAL 与 blocks FINAL 按 block_number join,两边都取 is_deleted = 0,列出 t.block_hash != b.hash 的行并同时打印两个哈希。 迁移前写入的全 0 哈希行(§5.4)是"fork 未标注"而不是 mismatch,主查询用 block_hash != unhex(repeat('00', 32)) 把它们排除在外,需要计数时用下面的配套查询 (a); 否则它们(数量可达迁移前的全部历史)会占满 LIMIT 100 的输出窗口,把只可能出现在 区块头附近的真实 mismatch 挡掉。 空结果 = 干净;有行 = 存在"轨迹与区块来自不同分叉"的脏数据,需要用 "补算"(§5)处理该区间。

配套的三个查询(按需单独执行):

-- (a) 全 0 哈希的行:迁移前/过渡期写入,fork 未标注(不是 mismatch,分开统计)
SELECT count() AS unknown_fork_rows
FROM {db}.traces FINAL
WHERE is_deleted = 0 AND block_hash = unhex(repeat('00', 32));

-- (b) 孤儿轨迹:有 trace 行但对应区块行不存在(正常情况下应为 0)
SELECT count() AS orphan_trace_rows
FROM {db}.traces AS t FINAL
LEFT JOIN {db}.blocks AS b FINAL ON b.number = t.block_number
WHERE t.is_deleted = 0 AND b.number = 0;

-- (c) trace 覆盖明细:哪些区块完全没有 trace 行(收盘区间内应只剩 _trace_gaps 的区间)
SELECT count() AS blocks_without_traces
FROM {db}.blocks AS b FINAL
LEFT JOIN (SELECT DISTINCT block_number FROM {db}.traces FINAL WHERE is_deleted = 0) AS t
  ON t.block_number = b.number
WHERE b.is_deleted = 0 AND t.block_number = 0;

字段级抽查沿用 scripts/qa/check_clickhouse.py 的思路 (不复用索引器代码,直接用 Python 标准库打节点 RPC):对随机抽到的区块,用 eth_getBlockByHash 拿交易列表、用 debug_traceBlockByHash 独立抓一遍, 逐笔核对 item 数与 txHash 顺序,再比对该块的 count(DISTINCT tx_index) 与 count()(同 §4 的做法)。唯一不能靠单点 RPC 复算的是"某个分叉是否仍是规范链", 那正是 QA 主查询在做的事(对比 blocks.hash)。


4. 验证

4.1 单元测试(cargo test,含 mock RPC)

src/trace.rs::tests::fetch_by_hash(内嵌 mock JSON-RPC 服务器, crate::test_support::start_custom,不走真实节点):

  • traces_are_fetched_by_block_hash_and_rows_carry_it:断言实际发出的请求是 debug_traceBlockByHash 且 params[0] 等于该块的哈希(不是区块号)、 params[1] 是 {"tracer":"callTracer"},并断言返回的每一行 block_hash 都等于它;
  • a_response_from_another_fork_is_rejected:mismatch 路径——mock 返回"同号不同分叉" 的同类响应(交易数相同、txHash 不同),断言整批 fetch 报错、不产出任何行;
  • a_response_with_a_different_transaction_count_is_rejected:item 数与交易数不等 → 报错;
  • out_of_order_transactions_are_rejected:本块交易列表乱序(tx_index 与位置不符)→ 报错, 避免"按位置当 tx_index"写错行;
  • rows_carry_the_hash_of_the_block_they_were_fetched_for:哈希落到每一行(含子调用帧);
  • a_by_number_answer_is_returned_raw_and_checked_against_its_block:按号抓的结果原样返回、 不落库,且 verify_and_decode_block_traces 拒绝"另一分叉"的那一条;
  • asking_by_hash_recovers_a_block_whose_by_number_answer_was_the_other_fork:按号答案校验不过 时,按哈希重抓拿到的才是要写入的行(且带本块哈希)。

ClickHouse 侧(src/sink_clickhouse.rs::trace_ingest_tests,--ignored,需要本地 ClickHouse、不需要真实节点):

  • finish_trace_batch_retraces_by_hash_when_the_by_number_answer_is_another_fork:mock 节点 按号返回另一分叉、按哈希返回本块,走完整 begin_trace_batch → finish_trace_batch, 断言落库的是按哈希重抓的那份、每行 block_hash = 本块哈希、且不记 gap;
  • delete_traces_from_tombstones_old_rows_and_rewrite_wins_on_final:墓碑复制必须带上 block_hash(这条钉住 §2.2 的那条位置式 INSERT INTO traces SELECT;见下方"墓碑复制" 说明)。

回归:cargo test --all-targets = 157 passed / 0 failed / 9 ignored;cargo fmt --all --check、 cargo clippy --all-targets -- -D warnings 干净。

4.2 本地端到端:follow --traces 2 分钟(真实节点 + 本地 ClickHouse)

用本 worktree 的二进制对真实节点跑 follow --traces(配置里 chain.name 指向临时 scratch 库,节点 URL 不落库、不入库),2 分钟后停掉,再跑 §3 的查询(数字见下方"实测记录"):

export CH_URL=http://127.0.0.1:8123 CH_USER=indexer CH_PASSWORD=indexer
./target/debug/chain-indexer init-schema --config scratch/<scratch>.toml
./target/debug/chain-indexer follow --traces --config scratch/<scratch>.toml --from <head-40>
# 2 分钟后 Ctrl-C / kill,然后:
sed 's/{db}/<scratch>/g' scripts/qa/traces_block_hash.sql | curl -sS "$CH_URL/?user=$CH_USER&password=$CH_PASSWORD" --data-binary @-

判读:主查询必须 0 行;另外抽查若干块用独立 RPC 复算 count(DISTINCT tx_index) 与调用帧数。

4.3 实测记录(2026-09-25)

  • 按哈希抓时代(rebase 到 #63 之前)的实测:下面几条是针对"全部按哈希抓"那版代码跑的, rebase 之后按哈希只剩重抓路径(§2.3),数字本身仍然成立(解码、行内容、QA 查询都不受 影响),但写入顺序已改为 §2.2 的两段式。
  • 节点 API 行为:debug_traceBlockByHash 对最近 7 个区块(head-1/2/3/10/37/150/400) 返回的 item 顺序与 eth_getBlockByNumber 的交易数组逐一致(7/7,含 Arbitrum 每块首笔 ArbOS 系统交易);对 head-1200 的区块返回 historical state is not available,与按区块号 调用返回同一条错误 → _trace_gaps 的 gap 判定不受影响。
  • 本地 scratch 库 end-to-end(单次 follow --traces 150 秒):写入区块 72,316,019–72,316,542 的 trace,共 524 个区块 / 103,664 行。随后跑 §3 的全部查询: 主查询(block_hash vs blocks.hash)0 行、全 0 哈希行 0、孤儿行 0、 uniqExact(tx_index) 与 blocks.tx_count 不一致的区块 0、FINAL 后重复键 0。 该次运行后来落后 head 约 950 块,超出 trace_window_blocks,后续批次按设计写 _trace_gaps(不是伪造 trace 行)——顺便验证了 gap 路径没有被这次改动破坏。
  • 独立逐块核对(运行中实时抽样,避免区块滑出窗口):对区块 72,320,079(commit 后 数秒内),节点独立 debug_traceBlockByHash 数出的调用帧数 103 == 库内该块行数 103;uniqExact(tx_index) 7 == 节点该块交易数 7;库里该块的 hex(block_hash) == 节点 eth_getBlockByNumber 给的规范哈希;响应的 item 顺序与节点交易数组一致。
  • QA 查询的反向验证:往 scratch 库注入一行 block_hash = 0xabab…(version 取 u64::MAX) 后,主查询精确报出该行并同时打印两个哈希(随后 DROP 掉 scratch 库)——证明查询不是"永远为空"。
  • rebase 到 #63 之后(TraceIngest 两段式,本 PR 最终形态)的复核:
    • cargo test --all-targets 157 passed / 0 failed / 9 ignored;cargo fmt --all --check、 cargo clippy --all-targets -- -D warnings 干净;
    • ClickHouse 侧 --ignored 用例 8/8 通过(follow_sink_tests 2 个 + trace_ingest_tests 6 个),其中新增的 finish_trace_batch_retraces_by_hash_… 就是"按号答案来自另一分叉 → 拒绝 → 按哈希重抓 → 才落库"的端到端用例(mock 节点,不依赖真实 RPC);
    • QA 主查询在 scratch 库上做过"200 行全 0 + 100 行正确 + 1 行头部真 mismatch"的对照: 加排除谓词前返回的 100 行全是全 0 行、真 mismatch 被挤掉;加谓词后恰好 1 行 且就是那条真 mismatch,配套查询 (a) 单独数出 200 行全 0(数量与 ground truth 一致);
    • 未复跑真实节点 smoke:本机到节点的隧道(127.0.0.1:8547)无监听,公网 rpc.mainnet.chain.robinhood.com 对 debug_traceBlockByHash/ debug_traceBlockByNumber 都返回 -32601(不支持),因此这次只能跑到 mock 节点为止; 上面的 7/7 顺序一致、historical state is not available 同错等结论来自 rebase 前那次 真实节点实测。
  • 环境说明:本地 ClickHouse 是和十几个 worker 共享的小 VM,期间两次重跑被 clickhouse 的 per-query 内存上限(Code 241,MEMORY_LIMIT_EXCEEDED,约 780 MiB)中断,与本次改动无关; 上面引用的是完整跑完的那次运行。

墓碑复制(delete_traces_from_async)

2026-09-25 复核时实测到:这条位置式 INSERT INTO traces SELECT ... , {version} AS version, 1 AS is_deleted, block_hash FROM traces FINAL WHERE ... AND is_deleted = 0 里的两个 alias 会遮蔽同名的源表列(ClickHouse 默认 prefer_column_name_to_alias = 0),于是 WHERE 里的 is_deleted 变成字面量 1、条件恒假——整条语句一行都不复制(read_rows = 0), 墓碑根本没写进去,block_hash 自然也无从谈起。现在这三个尾列不再带别名(位置式插入本来 也只看位置),复制恢复正常,上面的 delete_traces_from_tombstones_… 用例就是钉这个的。 同样形状的 alias 遮蔽当时还存在于 delete_from_async 的 blocks/transactions/logs 三条 语句里(空操作,重组回滚不会给旧分叉打墓碑);已另行修复:三条改为显式列清单、尾列不带别名, 由 follow_sink_tests::delete_from_tombstones_rows_the_new_fork_does_not_overwrite 钉住。


5. 安装与补算

5.1 新库(init-schema)

chain-indexer init-schema 会自动按顺序执行 sql/schema.sql → sql/traces.sql → sql/traces_block_hash.sql,一条命令建出带 block_hash 的完整结构(迁移语句在新建库上 本身就是幂等的 no-op,见下)。

5.2 已有库(只跑迁移)

生产库不用重跑整个 schema,只执行 sql/traces_block_hash.sql 里那一条:

sed 's/{db}/robinhood/g' sql/traces_block_hash.sql \
  | curl -sS "$CH_URL/?user=$CH_USER&password=$CH_PASSWORD" --data-binary @-
  • 该语句是 ALTER TABLE {db}.traces ADD COLUMN IF NOT EXISTS block_hash FixedString(32) DEFAULT ...: 只改元数据、不重写任何 part、不触发 mutation,对正在写入的 follow 无影响, 可重复执行。
  • 追加的列一定在表的最末(ADD COLUMN 的语义),CkTrace 的字段顺序和 delete_traces_from_async 里那条位置式 INSERT INTO traces SELECT ... 都按这个 顺序对齐——迁移前后都要保持这一点。
  • 迁移后立刻跑一次 §3 的主查询确认(应为 0 行;迁移前写入的行是全 0,不会被算成 mismatch)。

5.3 升级顺序

  1. 先跑 §5.2 的迁移;
  2. 再换成带 block_hash 的新二进制并重启 follow --traces。

顺序反过来(先换新二进制、后迁移)会失败但不会写脏数据:新二进制往缺列的 traces 里插入时,ClickHouse 客户端在写前做 schema 校验,直接报 "database schema has no column named block_hash"(fail closed)。 先迁移后换二进制时,旧二进制仍能继续写(新列有 DEFAULT,客户端按列名写入、缺的列取默认值), 但那个窗口里写进去的行 block_hash 全是 0 = "fork 未标注",所以迁移与重启之间不要拖太久。

5.4 补算

  • 全 0 哈希的行(迁移前/过渡期写入):这些行是老的"按区块号抓"路径写进来的,数据里 没有记录它们来自哪个分叉,因此无法事后验证。两种处理方式,按用途选择:
    1. 需要严格保证"每行都可校验"的分析,查询里显式排除它们,或按区间用新二进制重抓覆盖 (重抓前先给该区间打墓碑:INSERT INTO {db}.traces SELECT ... , <now> AS version, 1 AS is_deleted, block_hash FROM {db}.traces FINAL WHERE ...,与 delete_traces_from_async 的做法一致);

    2. 只想让 QA 覆盖它们:可以用 blocks.hash 回填(见下),但这等价于声明"这些行来自 现在仍然是规范链的那个分叉"——只有在没有发生过影响该区间的重组时才成立。这是 一条有代价的 mutation,生产上务必按区间分批、避开高峰:

      -- 可选、非幂等轻量化:按区间分批回填,{from}/{to} 自行替换
      ALTER TABLE {db}.traces
        UPDATE block_hash = (SELECT b.hash FROM {db}.blocks AS b FINAL WHERE b.number = block_number AND b.is_deleted = 0)
        WHERE block_number >= {from} AND block_number < {to} AND block_hash = unhex(repeat('00', 32))
        SETTINGS mutations_sync = 0;
  • 真实 mismatch(§3 主查询报出来的行):说明写入时发生了重组串库,必须重抓这些区块的 trace。定位区间 → 打墓碑 → 用新二进制重抓(重抓路径一定是按哈希抓 + 交叉校验的), 重抓后再跑一次主查询确认归零。
  • gap(_trace_gaps 区间):无需补算,也不应该补——节点已经没有那段历史状态, 再抓只会拿到同一条 historical state is not available。

5.5 本次实测

见 §4.3:单次 150 秒 follow --traces 覆盖 524 个区块 / 103,664 行,主查询 0 mismatch、 全 0 哈希 0 行、孤儿 0 行,运行中实时抽样的 1 个区块与节点独立复算完全一致; 另行注入坏行验证了 QA 查询确实能报出 mismatch。

本页目录