BlockVectra

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。 给内部团队回答"这个合约是不是代理?现在指向哪个实现?什么时候升级过、由谁升级的?"这类问题。

两条互补的检测路径

  1. proxy_upgrades(事件驱动):解码 {db}.logs 里符合 EIP-1967 标准事件形状的 Upgraded / AdminChanged / BeaconUpgraded 日志,按插入触发物化视图(CORRECTNESS RULE 方案 (a))落地,v_proxy_current_implementation 视图用 argMax 取每个代理最新的 Upgraded 事件。覆盖率高(历史完整、免费),但遗漏两类代理:纯 BeaconProxy 实例(升级事件发生在它指向的 beacon 合约上,自己从不 emit Upgraded)、以及在 {db}.logs 开始覆盖之前就已经存在且此后再也没有 emit 过事件的代理(若从未 emit 过任何事件,说明它自部署以来没升级过,proxy_slots 兜底)。
  2. 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
UpgradedUpgraded(address)0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b
AdminChangedAdminChanged(address,address)0x7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f
BeaconUpgradedBeaconUpgraded(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 覆盖,不需要回滚。

安装与补算

  1. 先决条件:{db}.logs 已经建好且有数据(chain-indexer init-schema + chain-indexer backfill)。
  2. 替换 {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 接口一次只接受一条语句)。
  3. 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 代理的场景需要重新跑脚本 才能看到新快照。
  4. 验证:见下方"验证"一节的具体数字和方法,可在任意数据库上重复。

验证(本地真实数据,逐项精确匹配)

拓扑:本地 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)一致
0x0bd7d308f8e1639fab988df18a8011f41eacad73WETHc6b81b429797e0f555440b70cd99e032d7ae947ec6b81b429797e0f555440b70cd99e032d7ae947e✅
0x1e324b9316138ca9a73f960213621ad1aaf01b89L2 Gateway Router030c64a359be400af05f9230a6f65f30537cdd12030c64a359be400af05f9230a6f65f30537cdd12✅
0xfd9b17206278c16ddaacf6ac8f05dbf97edcb31eL2 ERC20 Gatewaydf988cf6d83ebd578f6801820d01fee7280886d6df988cf6d83ebd578f6801820d01fee7280886d6✅
0x1d187c3e2da52d72bc9c41e3aba0fdfa6a7bf055L2 Weth Gateway0354a93fe0db94bb72ec053f43301746fc806edf0354a93fe0db94bb72ec053f43301746fc806edf✅
0x5fc5360d0400a0fd4f2af552add042d716f1d168USDG68184c449e1a8f34fa18d289737129fd27b66f8f68184c449e1a8f34fa18d289737129fd27b66f8f✅

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,不受这个限制影响)。

本页目录