BlockVectra

合约注册表(contracts)

面向直接写 SQL 的内部团队。库名以 robinhood 为例,其他链换库名同理(见 0001-architecture.md §4.1)。SQL 定义见 schema/clickhouse/derived/010_contracts.sql。

面向直接写 SQL 的内部团队。库名以 robinhood 为例,其他链换库名同理(见 0001-architecture.md §4.1)。SQL 定义见 schema/clickhouse/derived/010_contracts.sql。

1. 数据表

1.1 {db}.contracts —— 合约注册表

每个曾经被创建过的合约地址一行:

列类型说明
addressFixedString(20)合约地址(原始字节,hex() 读)
creatorFixedString(20)部署者(创建交易的 from)
creation_tx_hashFixedString(32)创建交易哈希
block_number / block_timestamp创建所在区块
viaLowCardinality(String)'tx' = 顶层创建交易(本文件唯一来源);'trace' 预留给后续从 traces 表(CREATE/CREATE2)回填的工厂子合约创建,当前尚未写入——见 §3
version / is_deletedReplacingMergeTree 去重列,透传自 transactions

ORDER BY (address):一个地址只会被顶层创建一次(CREATE 地址由 (sender, nonce) 唯一确定,两笔不同的创建交易不可能产生同一地址),所以 address 既是天然去重键, 也是这张表最主要的查询键("查地址 X 是谁部署的")。

-- 谁部署了这个合约?
SELECT hex(creator), hex(creation_tx_hash), block_number, block_timestamp, via
FROM robinhood.contracts FINAL
WHERE is_deleted = 0 AND address = unhex('...');

1.2 {db}.contract_daily_activity —— 按天的合约活跃度(仅覆盖已收口的天)

列类型说明
dayDateUTC 天
contractFixedString(20)合约地址(必须已在 contracts 表中)
tx_countUInt64当天调用该合约的交易数
unique_callersAggregateFunction(uniqExact, FixedString(20))当天去重 caller 数的状态,跨天求和请用 uniqExactMerge/uniqExactState 组合
refreshed_at最近一次刷新时间,ReplacingMergeTree 去重列

只覆盖已收口的天(REFRESH EVERY 1 DAY 每天跑一次,回看最近 3 天), 今天和最近窗口内的数据要等下一次调度刷新才会出现——需要实时数据请直接查 {db}.transactions FINAL。

-- 某合约某天的调用数 / 去重 caller 数
SELECT day, tx_count, uniqExactMerge(unique_callers) AS callers
FROM robinhood.contract_daily_activity FINAL
WHERE contract = unhex('...')
GROUP BY day, tx_count
ORDER BY day;

1.3 {db}.contract_activity_summary —— 单合约生命周期汇总(普通 VIEW)

在 contracts / contract_daily_activity 之上做的一层轻量 VIEW(不是物化表, 每次查询都会重新算,但只扫小表,不碰 transactions):

列说明
address / creator / creation_tx_hash / created_at_block / first_seen / via来自 contracts
first_active_day / last_active_day来自 contract_daily_activity(同样只到最近一次刷新覆盖的天)。合约从未被调用过(或还没等到下一次刷新覆盖它的创建日)时是 NULL,不是 1970-01-01
tx_count_total已收口天的调用数之和;从未被调用过是 0
unique_callers_totaluniqExactMerge 合并后的去重 caller 总数;从未被调用过是 0
SELECT * FROM robinhood.contract_activity_summary WHERE address = unhex('...');

2. 正确性设计:两张表用了两种不同的方案,原因不同

背景:{db}.transactions 是 ReplacingMergeTree(version, is_deleted)—— 断点重抽会重复插入同一笔交易(同版本,无害),重组会给被回滚的交易写一条 "同一行、is_deleted=1、更高 version" 的墓碑行。插入触发型物化视图对原始 插入做 SUM/COUNT 会重复计数,见下方各表的具体说明。

  • contracts:方案 (a)——插入触发型 MV,但只做行内变换(一笔顶层创建交易 ⟶ 一行 contracts),把源行的 version/is_deleted 原样透传,不做聚合。 和 001_erc20_transfers.sql 同一思路:如果源交易被正确墓碑化(transactions 里出现同一笔交易、更高 version、is_deleted=1 的行),contracts 里对应 地址的行会因为透传的 is_deleted=1 在 FINAL 上被丢弃;同一地址后续在新 区块被重新创建(同 (sender, nonce) 重放)时,新记录带更高 version, FINAL 自动收敛到重组后的正确结果,不需要额外处理。这一段"透传自愈"逻辑 本身已经过验证(本地 scratch 库手工构造一条正确写法的墓碑行,mv_contracts 确实把对应地址从 contracts FINAL 里正确剔除,见 §5)。

    已修复(PR #109):生产环境负责在重组时写入这条墓碑的代码路径是 src/sink_clickhouse.rs 的 delete_from_async(不在本文件改动范围内)。它 以前的墓碑 INSERT 用 SELECT-list 别名写法({version} AS version, 1 AS is_deleted),而 ClickHouse 26.9(本项目 pin 的版本)起 analyzer 强制启用, 会把 WHERE 里不带表别名的 is_deleted 解析成 SELECT 列表里那个同名别名, 而不是源表的列,整个条件静默退化成 1 = 0,墓碑 INSERT 执行成功但实际写入 0 行。PR #109 已经修复:三条墓碑语句(blocks/transactions/logs)改成显式 列清单、不带 AS version/AS is_deleted 别名(见 tombstone_rows_sql 等, src/sink_clickhouse.rs),并且 blocks 先于 transactions/logs 打墓碑。 重组回滚一笔创建交易现在会正确在三张表里产生墓碑,{db}.contracts 上面这段 "透传自愈"逻辑因此在生产上真正生效,不会再长期保留分叉链上已作废的 creator/交易哈希。

  • contract_daily_activity:方案 (b)——"某合约当天被调用几次/几个不同 caller" 是对 transactions 很多行的真聚合,方案 (a) 不适用(插入触发型 MV 对未去重的原始插入做 COUNT/uniqExact 会在断点重抽重叠区间和重组墓碑两种 场景下重复计数)。做法和 004_daily_chain_stats.sql 完全一致:REFRESH EVERY 1 DAY 的可刷新物化视图,每次调度都对最近 3 天做一次基于 {db}.transactions FINAL 的从头重算,自愈两种场景,不需要单独处理重叠或 重组。目标表用 ReplacingMergeTree(refreshed_at) + APPEND(而非 AggregatingMergeTree,原因同 004 文件头注释:AggregatingMergeTree 要求非 key 列全部是聚合状态,tx_count 这类普通列会导致建表失败)。

money-safety:本文件没有金额字段;地址/哈希全部是原始字节,读出来用 hex()(见 查询速查手册 §1.2)。

3. 范围说明:只有 via = 'tx'

{db}.transactions.contract_address 只在顶层创建交易(to IS NULL, 交易本身部署合约)时由回执置位。工厂合约在调用内部通过 CREATE/CREATE2 部署的子合约,只会出现在 {db}.traces(sql/traces.sql)里——该表正在 另外接入,当前没有数据,本文件不依赖它,也没有 JOIN 它。

via 列已经为后续工作预留:等 traces 表有数据后,只需要一个新的 MV/回填脚本,从 {db}.traces WHERE call_type IN ('CREATE', 'CREATE2') 读取并 INSERT INTO {db}.contracts(via = 'trace'),不需要改这张表的 schema。

4. 安装与补算

# 1. 建表 + 两个物化视图 + 一个普通 VIEW,一次性执行({db} 换成目标库)
sed 's/{db}/robinhood/g' schema/clickhouse/derived/010_contracts.sql \
  | clickhouse-client --multiquery

# 通过 HTTP 接口执行则要拆成多条独立请求(HTTP 接口不接受一次请求多条语句),
# 见 003_backfill.sql 同样的提示。010_contracts.sql 里已经把 CREATE TABLE /
# CREATE MATERIALIZED VIEW / INSERT / CREATE VIEW 各自独立成完整语句,按顺序
#逐条发送即可。

物化视图只处理"建视图之后"新写入 {db}.transactions 的行;文件里紧跟在两个 CREATE 之后的两条 INSERT ... SELECT(contracts 一条、 contract_daily_activity 一条)就是一次性补算脚本,用来覆盖"建视图之前就已经 落库"的历史数据。ReplacingMergeTree 去重,重复运行安全。

contract_daily_activity 的可刷新 MV 只回看最近 3 天,比 3 天更早的历史全部 由补算脚本覆盖(不看 today() - 3 下界,只有 < today() 上界);如果观察到 补算/重组修正的时间跨度超过 3 天,需要扩大 mv_contract_daily_activity 里的 回看窗口或重跑对应区间的补算。

5. 验证

对任意区块区间,contracts 表(FINAL、is_deleted=0)应该和原始 transactions 里 contract_address IS NOT NULL 的行逐条精确对应:

SELECT count() FROM {db}.transactions FINAL
WHERE is_deleted = 0 AND contract_address IS NOT NULL
  AND block_number >= {lo} AND block_number < {hi};

SELECT count() FROM {db}.contracts FINAL
WHERE is_deleted = 0 AND block_number >= {lo} AND block_number < {hi};

本次开发在本地 scratch 库(真实节点数据,区块 [1000000, 1004000),通过 backfill --sink clickhouse 拉取,4000 个区块 / 9429 笔交易 / 14083 条日志) 上验证过:

  • 逐行精确匹配:contract_address IS NOT NULL 的交易与 contracts FINAL 按 (address, creator, creation_tx_hash, block_number) 四元组 FULL OUTER JOIN,19 笔创建交易 19 行全部精确匹配,0 条只在一侧出现。
  • contract_activity_summary 的 NULL 修复:19 个合约中 2 个有真实调用 (分别 14 笔 / 2 笔交易,1 个去重 caller),17 个在验证窗口内从未被调用过; 两类合约的 first_active_day/last_active_day/tx_count_total/ unique_callers_total 都和对 {db}.transactions FINAL 的独立聚合查询逐值 精确对应——有活动的合约给出真实日期和计数,无活动的 17 个合约全部是 NULL/NULL/0/0,视图里没有任何一行 first_active_day/ last_active_day 是 1970-01-01(SETTINGS join_use_nulls = 1 生效,且 不会被调用方自己的 session 设置覆盖)。
  • 重组自愈(mv_contracts 自身逻辑):因为 delete_from_async 当前在这 个 ClickHouse 版本上不会真的写墓碑(见 §2 的"已知上游缺口"),这里手工向 {db}.transactions 插入一条写法正确、版本更高的墓碑行(模拟 delete_from_async 应该产出的效果),确认 mv_contracts 正确把对应地址 从 {db}.contracts FINAL 里剔除——这验证的是本文件 MV 透传逻辑本身没问题, 不代表生产环境重组时真的会走到这一步(那依赖 §2 提到的上游修复)。

没有再单独对 delete_from_async 本身跑重组场景,因为已经从代码 + 本地 26.9.1.1629 直接复现确认它当前对 blocks/transactions/logs 三张表都写入 0 行墓碑(WHERE 子句被同名 SELECT 别名遮蔽)——这是一个独立于本 PR 文件范围 的上游 bug,等它修好之后,上面验证过的 mv_contracts 自愈逻辑不需要改动就能 在生产环境生效。

更新(2026-09-26):该上游 bug 已修复(delete_from_async 三条墓碑语句改为显式列清单、 尾列不带别名,见 follow_sink_tests::delete_from_tombstones_rows_the_new_fork_does_not_overwrite), 新版本 follow 上线后,重组墓碑会经由上述 MV 透传到 {db}.contracts。

6. 研究结论(生产环境只读查询)

  • 全链顶层合约创建交易数:99,931(robinhood.transactions FINAL, contract_address IS NOT NULL,区块范围 [1, 72048822],即当时的链上全部 已提交交易 129,958,981 笔中的一部分)。

  • Top 工厂地址(按部署合约数排序,前 5):

    creator部署数首次部署区块最近部署区块
    0xf311aedf74fe0dc22d6d236c22c84acd2a5a8f024124970,0529,270,002
    0x480d87c6d675450076a7d8f1f2e9d4763dc339e8350410,817,26316,039,900
    0xb7f6a5bfdd0b8110e72851a52169b2ca3d88ee1130054,864,59910,458,098
    0xb34854f98051b51ce47cfafa52d0d30d6e34283429345,177,10010,509,275
    0xa830f20181079db732d44b582a892bedff649cc822651,042,1495,660,049

    最大的工厂地址 0xf311...8f02 的第一笔部署:区块 970052, 交易哈希 0xcb424d74382b9f6f32f7adcdd87da65f40645b5f83c8397321a46f0b78a84f91, 部署地址 0x583ef68d455ab7a5194c49d2015bd93ccac89c22。

本页目录