Proxies 数据集(Upgradeable-proxy detection)
来源:schema/clickhouse/derived/026_proxy_detection.sql + scripts/derived/check_proxy_slots.py。 给内部团队回答"这个合约是不是代理?现在指向哪个实现?什么时候升级过、由谁升级的?"这类问题。
来源:schema/clickhouse/derived/026_proxy_detection.sql + scripts/derived/check_proxy_slots.py。
给内部团队回答"这个合约是不是代理?现在指向哪个实现?什么时候升级过、由谁升级的?"这类问题。
两条互补的检测路径
proxy_upgrades(事件驱动):解码{db}.logs里符合 EIP-1967 标准事件形状的Upgraded/AdminChanged/BeaconUpgraded日志,按插入触发物化视图(CORRECTNESS RULE 方案 (a))落地,v_proxy_current_implementation视图用argMax取每个代理最新的Upgraded事件。覆盖率高(历史完整、免费),但遗漏两类代理:纯BeaconProxy实例(升级事件发生在它指向的 beacon 合约上,自己从不 emitUpgraded)、以及在{db}.logs开始覆盖之前就已经存在且此后再也没有 emit 过事件的代理(若从未 emit 过任何事件,说明它自部署以来没升级过,proxy_slots兜底)。proxy_slots(存储槽快照):scripts/derived/check_proxy_slots.py对"当前调用量 最高的 N 个合约"直接eth_getStorageAt读 EIP-1967 实现槽的当前值,不依赖事件 历史,是①的兜底和交叉验证;缺点是只覆盖脚本跑过的地址、只有跑的那一刻的快照(不是 历史)。
两条路径的验证结果见下方"验证"一节:对样本内同时被两条路径覆盖的 5 个代理,实现地址 逐字节精确一致。
事件签名 / topic0(独立计算,非抄文档)
topic0 = keccak256(ascii(canonical_signature)),用 pycryptodome
(Crypto.Hash.keccak, digest_bits=256) 本地重新计算,不是照抄 EIP 文档或某个区块链
浏览器页面:
| 事件 | 签名 | topic0 |
|---|---|---|
Upgraded | Upgraded(address) | 0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b |
AdminChanged | AdminChanged(address,address) | 0x7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f |
BeaconUpgraded | BeaconUpgraded(address) | 0x1cf3b03a6cf19fa2baba4df148e9dcabedea7f8a5c07840e207e5c089be95d3e |
三者的日志形状(用于 MV 的 WHERE,见 026 文件顶部注释详细展开):
Upgraded:implementation是唯一参数且indexed→topic1= 实现地址(32 字节左 补零),topic2/topic3为空,data长度 0 字节。AdminChanged:EIP-1967 canonical ABI 里两个参数都不是indexed→topic1..3全部为空,previousAdmin/newAdmin都在data里,各占 32 字节(左补零),共 64 字节,previousAdmin在前。BeaconUpgraded:beacon是唯一参数且indexed→ 形状和Upgraded一样,topic1= beacon 地址,data长度 0 字节。
EIP-1967 实现槽常量同样独立重算验证:keccak256("eip1967.proxy.implementation") - 1
= 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc,与脚本里
EIP1967_IMPLEMENTATION_SLOT 常量、任务里给出的值三方一致。
表结构
proxy_upgrades(逐事件,插入触发 MV,CORRECTNESS RULE 方案 (a))
ORDER BY (proxy_address, block_number, log_index),version/is_deleted 从
{db}.logs 原样透传(和 001_erc20_transfers.sql 的 mv_erc20_transfers 同一模式)。
三个物化视图(mv_proxy_upgrades_upgraded/_admin_changed/_beacon_upgraded)各自
按上面的形状条件过滤、都写向同一张 proxy_upgrades 表——ClickHouse 支持多个 MV 写同一个
目标表,这里用 event_type 列区分"这一行是哪种事件",非当前事件类型对应的值列全部为
NULL(例如 event_type='AdminChanged' 的行,implementation/beacon 都是 NULL,只有
previous_admin/new_admin 有值)。
v_proxy_current_implementation:普通 VIEW(非物化),对 proxy_upgrades FINAL WHERE is_deleted=0 AND event_type='Upgraded' 按 proxy_address 分组、argMax(implementation, (block_number, log_index)) 取最新实现地址,附带 last_upgrade_block/upgrade_count。
因为只在 proxy_upgrades(一个代理的事件通常几条到几十条)上聚合而不是扫全表,查询成本
很低,不需要做成物化视图。
proxy_slots(外部快照,非从 {db}.logs 派生)
ReplacingMergeTree(checked_block),ORDER BY address——重新跑脚本、拿到更新的
checked_block 会自然替换旧快照(和 011_address_labels.sql 的重新 seed 是同一个幂等
思路)。这里没有 is_deleted/重组的概念:eth_getStorageAt(..., "latest") 每次都是从
节点当下状态现读,不是从可能被重组的链历史派生,旧快照单纯被下一次更新的
checked_block 覆盖,不需要回滚。
安装与补算
- 先决条件:
{db}.logs已经建好且有数据(chain-indexer init-schema+chain-indexer backfill)。 - 替换
{db}后按顺序整体执行026_proxy_detection.sql(sed 's/{db}/robinhood/g' 026_proxy_detection.sql | clickhouse-client --multiquery)。文件里已经把"建proxy_upgrades表 + 3 个 MV + 1 个 view + 建proxy_slots表 + 一次性回填历史行"全部按顺序写好,不需要额外的 backfill 文件。用 HTTP 接口时按语句拆开逐条 POST(HTTP 接口一次只接受一条语句)。 proxy_slots需要额外跑一次脚本才有数据(不会因为建表而自动填充):export CH_URL=... CH_USER=... CH_PASSWORD=... python3 scripts/derived/check_proxy_slots.py --db robinhood \ --rpc-url "$RPC_URL" --top-n 100 --sleep 0.3--top-n上限 200;--sleep控制两次eth_getStorageAt之间的间隔,共享节点上 不要调小于默认值。脚本还支持--addresses 0x...针对指定合约列表查询,以及--include-zero将非代理(槽全零)也一并记录入库。建议按需(例如每天一次)重新跑,而不是只跑一次——proxy_upgrades会自动捕捉后续的Upgraded事件,但只靠存储槽兜底纯 beacon 代理的场景需要重新跑脚本 才能看到新快照。- 验证:见下方"验证"一节的具体数字和方法,可在任意数据库上重复。
验证(本地真实数据,逐项精确匹配)
拓扑:本地 Docker ClickHouse(http://127.0.0.1:8123),独立 scratch 库
proxy_det_wt52;chain-indexer backfill 对 Robinhood Chain 生产节点
(chain_id=4663)真实回填区块 [0, 5000):5000 个区块、12559 笔交易、2970 条
日志(真实 RPC 抓取,非合成数据)。
1. 事件计数:派生表 vs 对 {db}.logs FINAL 的独立形状匹配计数
SELECT
countIf(topic0 = unhex('bc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b')
AND topic1 IS NOT NULL AND topic2 IS NULL AND topic3 IS NULL AND length(data)=0) AS upgraded_raw,
countIf(topic0 = unhex('7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f')
AND topic1 IS NULL AND topic2 IS NULL AND topic3 IS NULL AND length(data)=64) AS admin_changed_raw,
countIf(topic0 = unhex('1cf3b03a6cf19fa2baba4df148e9dcabedea7f8a5c07840e207e5c089be95d3e')
AND topic1 IS NOT NULL AND topic2 IS NULL AND topic3 IS NULL AND length(data)=0) AS beacon_upgraded_raw
FROM {db}.logs FINAL WHERE is_deleted=0;结果:upgraded_raw=32, admin_changed_raw=14, beacon_upgraded_raw=0。
SELECT event_type, count() FROM {db}.proxy_upgrades FINAL WHERE is_deleted=0 GROUP BY event_type;结果:Upgraded=32, AdminChanged=14(BeaconUpgraded 无行,与上面 beacon_upgraded_raw=0
一致——不是漏做了,是样本区间内确实没有这类事件)。三个数字逐一精确相等。
2. 解码正确性:逐字节比对真实 wire 日志
代理 0x2a153c6a1b66dbc930a8d7017230ab0253005c09,区块 2,交易
0x05317cd173ba8e973c7e88aeb7f7e56eb22cf05c12552d4283c993dbb1f56b12:
- 真实
eth_getLogs返回:logIndex=0x3的Upgraded日志topics[1] = 0x000000000000000000000000e83b11faa5693e68388785feca3595c6805dfb9a;logIndex=0x4的AdminChanged日志data = 0x000...000 000000000000000000000000a3acd31afb851b4eb9dad00f5204c01d924267df(前 32 字节全零 =previousAdmin,后 32 字节 =newAdmin)。 {db}.proxy_upgrades里对应行:implementation = E83B11FAA5693E68388785FECA3595C6805DFB9A(log_index=3);previous_admin = 0000000000000000000000000000000000000000, new_admin = A3ACD31AFB851B4EB9DAD00F5204C01D924267DF(log_index=4)。逐字节精确一致。
3. v_proxy_current_implementation vs 链当前状态(跨 7200 万区块的交叉验证)
对 5 个已知的 EIP-1967 代理(docs/datasets/labels.md 里的 bridge_gateway/token
分类地址):用只回填到区块 5000 的事件历史算出的"当前实现",与**链当前高度
(约 72,086,603,即 5000 区块之后又过了约 7200 万个区块)**实时 eth_getStorageAt
读到的实现槽逐字节比较:
| 代理 | 用途 | 事件推算实现(≤区块5000) | 链上实时实现(区块~72.09M) | 一致 |
|---|---|---|---|---|
0x0bd7d308f8e1639fab988df18a8011f41eacad73 | WETH | c6b81b429797e0f555440b70cd99e032d7ae947e | c6b81b429797e0f555440b70cd99e032d7ae947e | ✅ |
0x1e324b9316138ca9a73f960213621ad1aaf01b89 | L2 Gateway Router | 030c64a359be400af05f9230a6f65f30537cdd12 | 030c64a359be400af05f9230a6f65f30537cdd12 | ✅ |
0xfd9b17206278c16ddaacf6ac8f05dbf97edcb31e | L2 ERC20 Gateway | df988cf6d83ebd578f6801820d01fee7280886d6 | df988cf6d83ebd578f6801820d01fee7280886d6 | ✅ |
0x1d187c3e2da52d72bc9c41e3aba0fdfa6a7bf055 | L2 Weth Gateway | 0354a93fe0db94bb72ec053f43301746fc806edf | 0354a93fe0db94bb72ec053f43301746fc806edf | ✅ |
0x5fc5360d0400a0fd4f2af552add042d716f1d168 | USDG | 68184c449e1a8f34fa18d289737129fd27b66f8f | 68184c449e1a8f34fa18d289737129fd27b66f8f | ✅ |
5/5 精确一致——说明这 5 个核心协议合约自区块 ~317(USDG 是区块 57)以来一直没有再升级过,
argMax 推算的"当前实现"确实等于链当下的真实状态。
4. check_proxy_slots.py 实测
对 proxy_det_wt52.transactions 里调用量最高的 15 个地址跑脚本
(--top-n 15 --sleep 0.3):15 个候选里 5 个是真代理(eth_getStorageAt 非零槽)、
10 个不是(槽全零,已知非代理,正确跳过)、0 次 RPC 失败。写入 proxy_slots 后与
v_proxy_current_implementation 内连接比较:
SELECT s.address, s.implementation = v.current_implementation AS match
FROM (SELECT address, implementation FROM {db}.proxy_slots FINAL) AS s
LEFT JOIN {db}.v_proxy_current_implementation AS v ON s.address = v.proxy_address;5/5 match=1(其中 0x2a153c6a1b66dbc930a8d7017230ab0253005c09 的槽值
3c3e52bc8c181d06a76e2518bbc655c5bb3ce7cd 与该代理第二条 Upgraded 事件
[log_index=5] 解码出的实现地址精确一致)。
5. BeaconUpgraded 解码 + 重组回滚(合成数据,因为样本区间真实事件数为 0)
样本 [0, 5000) 区间没有真实 BeaconUpgraded 事件(见第 1 节),为验证该 MV 的解码
逻辑本身没有问题,手工构造了一条形状正确的合成日志(address=0xaa..aa,
topic0=BeaconUpgraded, topic1=0x00..00bb..bb)插入 scratch 库的 {db}.logs:
- 插入后:
{db}.proxy_upgrades FINAL WHERE event_type='BeaconUpgraded'立刻出现 1 行,beacon精确解码为BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB。 - 再插入一条内容相同、
is_deleted=1、version更高的墓碑行(模拟重组回滚):count() FROM proxy_upgrades FINAL WHERE event_type='BeaconUpgraded' AND is_deleted=0从 1 变回 0,验证了 CORRECTNESS RULE 方案 (a) 对这张表同样成立——不需要额外代码,version/is_deleted透传就足够正确处理重组。
已知局限
- 纯 beacon 代理不会出现在
v_proxy_current_implementation里:BeaconProxy实例 本身只在首次部署时可能 emit 一次BeaconUpgraded(多数实现甚至连这个都不 emit, 只是把 beacon 地址写进构造函数),后续升级发生在它指向的UpgradeableBeacon合约上 (该合约 emit 自己的Upgraded),代理实例自己再也不会有新事件。要拿到这类代理的 "当前实现",需要:① 从proxy_upgrades里找到该 beacon 合约收到的最新Upgraded, 或 ② 直接用proxy_slots/check_proxy_slots.py读代理自己的实现槽(多数 beacon 代理实现会在读函数里转发查询给 beacon,槽本身不一定会更新——具体取决于代理实现, 这是本次任务没有进一步验证的边界情况,需要针对具体合约字节码单独确认)。 proxy_slots只覆盖脚本跑过的地址、只是跑那一刻的快照:不在--top-n范围内的 长尾合约不会出现;两次运行之间发生的升级不会被捕捉到,除非重新跑脚本。- 共享节点稳定性:验证过程中节点对
eth_getLogs大区块跨度(≥ 1,000,000 区块)请求 出现过超时/502(可能是并发 worker 负载导致,也可能是节点自身对大范围eth_getLogs较慢),因此本次验证把真实数据回填范围控制在 5000 区块(脚本设计上也一致:check_proxy_slots.py按地址单次调用eth_getStorageAt,不受这个限制影响)。
合约注册表(contracts)
面向直接写 SQL 的内部团队。库名以 robinhood 为例,其他链换库名同理(见 0001-architecture.md §4.1)。SQL 定义见 schema/clickhouse/derived/010_contracts.sql。
地址标签(address_labels)
{db}.address_labels 给 Robinhood Chain(chain id 4663)上一批"已知地址"打标签,配合 一个把标签 JOIN 到 transactions.from/transactions.to 的视图,方便写 SQL 时不用记 一长串 hex 地址。