合约注册表(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 —— 合约注册表
每个曾经被创建过的合约地址一行:
| 列 | 类型 | 说明 |
|---|---|---|
address | FixedString(20) | 合约地址(原始字节,hex() 读) |
creator | FixedString(20) | 部署者(创建交易的 from) |
creation_tx_hash | FixedString(32) | 创建交易哈希 |
block_number / block_timestamp | 创建所在区块 | |
via | LowCardinality(String) | 'tx' = 顶层创建交易(本文件唯一来源);'trace' 预留给后续从 traces 表(CREATE/CREATE2)回填的工厂子合约创建,当前尚未写入——见 §3 |
version / is_deleted | ReplacingMergeTree 去重列,透传自 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 —— 按天的合约活跃度(仅覆盖已收口的天)
| 列 | 类型 | 说明 |
|---|---|---|
day | Date | UTC 天 |
contract | FixedString(20) | 合约地址(必须已在 contracts 表中) |
tx_count | UInt64 | 当天调用该合约的交易数 |
unique_callers | AggregateFunction(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_total | uniqExactMerge 合并后的去重 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 部署数 首次部署区块 最近部署区块 0xf311aedf74fe0dc22d6d236c22c84acd2a5a8f024124 970,052 9,270,002 0x480d87c6d675450076a7d8f1f2e9d4763dc339e83504 10,817,263 16,039,900 0xb7f6a5bfdd0b8110e72851a52169b2ca3d88ee113005 4,864,599 10,458,098 0xb34854f98051b51ce47cfafa52d0d30d6e3428342934 5,177,100 10,509,275 0xa830f20181079db732d44b582a892bedff649cc82265 1,042,149 5,660,049 最大的工厂地址
0xf311...8f02的第一笔部署:区块970052, 交易哈希0xcb424d74382b9f6f32f7adcdd87da65f40645b5f83c8397321a46f0b78a84f91, 部署地址0x583ef68d455ab7a5194c49d2015bd93ccac89c22。
Bridge flows(跨链桥流水)
面向直接写 SQL 的内部团队。覆盖 Robinhood Chain(chain id 4663,Arbitrum Orbit) 上 L1<->L2 跨链桥的三类原始信号,派生出 4 张表,全部在 schema/clickhouse/derived/012_bridge_flows.sql。库名以 robi…
Proxies 数据集(Upgradeable-proxy detection)
来源:schema/clickhouse/derived/026_proxy_detection.sql + scripts/derived/check_proxy_slots.py。 给内部团队回答"这个合约是不是代理?现在指向哪个实现?什么时候升级过、由谁升级的?"这类问题。