BlockVectra

DEX 交易数据集(dex_pools / dex_swaps)

对应 SQL:schema/clickhouse/derived/008_dex.sql。 安装方式与 001_erc20_transfers.sql 等一致:sed 's/{db}/robinhood/g' 008_dex.sql | clickhouse-client --multiquery(HTTP…

对应 SQL:schema/clickhouse/derived/008_dex.sql。 安装方式与 001_erc20_transfers.sql 等一致:sed 's/{db}/robinhood/g' 008_dex.sql | clickhouse-client --multiquery(HTTP 接口一次只能发一条语句,需按 ; 拆开逐条发送)。

1. 链上实际存在哪些 DEX(研究结论 + 证据)

方法:对生产 ClickHouse(只读,max_threads=2, max_execution_time=90,未加 FINAL, 因此计数是「已落盘版本数」的上界,会包含极少量因 ReplacingMergeTree 尚未合并产生的重复 版本;backfill 同时在跑,同一查询在不同时间点结果会有几百到几千的正常波动——这里的数字 用于判断「协议是否存在/量级如何」,不是精确对账口径)按 topic0 分组计数:

协议事件topic0计数(约)备注
Uniswap V3Swap0xc42079f9...115fbcca6726,911,768129,572 个不同 pool 地址发出过该事件
Uniswap V4Swap0x40e9cecb...84ad7112f7,335,173单例 PoolManager:99.9994%(7,421,818/7,421,836)来自同一地址 0x8366a39cc670b4001a1121b8f6a443a643e40951
Uniswap V2(及同 ABI 的 fork)Sync0x1c411e9a...9fffbbad15,363,397见下方「V2 的异常」
Uniswap V3PoolCreated0x783cca1c...b4e6b7118141,40198.5%(142,282/144,443)来自单一工厂 0x1f7d7550b1b028f7571e69a784071f0205fd2efa
Uniswap V4Initialize0xdd466e67...0838d643893,907发出者恒为上面同一个 PoolManager
Uniswap V2(及 fork)PairCreated0x0d3648bd...1afa28d0e924,74965 个不同工厂地址,见下
Balancer V2Swap0x2170c741...b9e0b207b19,741100% 来自单一 Vault 合约 0xd315a9c38ec871068fec378e4ce78af528c76293(Balancer 架构本身就是单 Vault 代所有池子转账,这是预期形状,不是异常)
CurveTokenExchange0x8b3e96f2...15a3dd9714057仅 3 个池子,量级太小

topic0 全部用本地 cast keccak "<事件签名>"(Foundry,非记忆/网络搜索)现算,逐一核对过 长度(32 字节)后再用于生产查询,避免手抄错一位导致「协议不存在」的假阴性。

V2 的异常:有池子、有 Sync,但零笔标准 Swap

PairCreated 24,749 笔、Sync 536 万笔,但标准 Swap(address,uint256,uint256,uint256, uint256,address) 精确计数 恰好为 0(用同一个 topic0 单独 count() 复核过,不是分组 查询漏掉)。两个工厂占了 24,749 笔里的绝大多数(0x8bceaa40b9acdfaedf85adf4ff01f5ad6517937f 14,988 笔、0xfc2e4da3edb2e18100473339c763705d263d20a9 9,798 笔),其余 63 个工厂零星几笔到 一百多笔不等,像是测试/山寨部署。

Sync 在标准 UniswapV2Pair 里 mint/burn/swap 都会触发,因此「有 Sync 无 Swap」大概率 是这批 V2 fork 的 swap() 改了事件签名(不少 fork 为了兼容手续费扣款、MEV 保护等会重写事件), 不是数据管道 bug。结论:dex_pools 保留 V2 池子记录(PairCreated 是真实、可信的池子创建 证据),但 dex_swaps 不含任何 V2 行——没有可验证的原始事件就不编造/猜测式解码,符合 fail-closed。如果后续要支持 V2 交易,先找到这批 fork 实际用的 Swap 事件签名(读一个已知 V2 fork 池子合约的字节码或源码),再单独加 MV,不要复用本文件的假设。

Balancer V2 / Curve:存在但暂不建表

Balancer 19,741 笔量级不小,但结构和 V2/V3/V4 完全不同——只有一个 Vault 合约做全部转账, 没有「按池子建表」的池子创建事件(池子在 Vault 里用 poolId 注册,注册事件是 PoolRegistered,池子本身是共享 Vault 里的余额记账,不是像 V3 那样『地址即池子』)。 Curve 仅 57 笔、3 个池子,量级可以忽略。两者都先记录在案,纳入 dex_pools/dex_swaps 留作 follow-up,不在本次 PR 范围内(避免为个位数量级的数据引入一套新的解码/join 逻辑, 稀释这次真正验证过的 V3/V4 实现的可信度)。

2. 表结构

完整字段/注释见 SQL 文件本身;这里只说设计上的几个关键点。

pool_key:跨协议统一 join key(FixedString(32))

  • V2 / V3:池子合约地址,左边补 12 个零字节到 32 字节(和 topic1/topic2 里地址的 原始形状完全一样,不需要额外转换)。
  • V4:没有独立的池子合约——所有池子共享同一个 PoolManager 单例,用 PoolId (bytes32,Initialize/Swap 的 topic1 本身就是它,未经改动直接用)区分。

「swap 可能比它的池子先到」怎么处理

backfill 按 segment 并行跑,follow 处理重组,两者都不保证「一个池子的 PairCreated/PoolCreated/Initialize 一定比它后续的 Swap 先落盘」。如果 dex_swaps 的物化视图在写入时就去 join dex_pools 找 token0/token1,一笔 swap 因为 它的池子还没到就会被丢掉或者拿到错的(默认值填充的)token——而且这个错误后续也不会自动 修正,因为物化视图只处理"新插入的 logs 行",不会在 dex_pools 后到达时反过来重新处理 已经处理过的 dex_swaps 行。

本方案:dex_swaps 的两个物化视图完全不 join dex_pools,只存 pool_key 和其余 swap 自带的字段(金额、价格、tick、sender…)——这些信息 100% 来自这一条 Swap 日志本身, 不依赖任何其他表,所以插入顺序完全不影响正确性。token0/token1/池子 fee 挪到查询时 用 dex_swaps_enriched 视图(普通 VIEW,每次查询都重新算,不是物化视图)现 join dex_pools:池子还没到就是 NULL,池子一旦落盘,不需要任何重跑,下一次查询自动就能 关联上。

join_use_nulls = 1 在这个视图里是必需设置,不是可选优化:dex_pools.token0 等列本身不是 Nullable,Uniswap V4 又存在 token0 真实等于全零地址(原生代币)的合法情况——如果不开 join_use_nulls,ClickHouse 默认 LEFT JOIN 在没匹配到右表时,也会把右表列填成"该类型的零值", 对 FixedString(20) 来说就是 20 个零字节,和"真实原生代币"完全没法区分。开启后没匹配到的 情况会返回真正的 SQL NULL,两种情况才分得开(已经在本地验证过,见第 4 节)。

3. 验证(真实生产日志 + 本地 ClickHouse scratch db 全链路回填)

第一步(生产,只读):对 5 笔真实 V3 Swap + 5 笔真实 V4 Swap (都取自生产 robinhood.logs 最新区块附近),直接用本文件的 SQL 表达式 (reinterpretAsInt256(reverse(substring(data, ...))) 等)在生产上现算,同时把原始 hex(data) 取回本地,用独立的 Python(int.from_bytes(..., 'big', signed=True), 没有复用任何 ClickHouse/Rust 里的解码逻辑)解码同一段字节,10/10 笔完全一致(金额、 sqrtPriceX96、liquidity、tick,V4 另加 fee)。

第二步(本地 ClickHouse scratch db,全链路):

  1. 用 RPC 端点(走本机分配的私有网关)跑 chain-indexer backfill --sink clickhouse, 区间 [72039000, 72041000)(2000 个真实区块,concurrency=1, batch_blocks=8,全程 约 80 秒、平均 ~25 blocks/s,对共享节点的压力可忽略),写入本地 robinhood 库。

  2. 对本地 robinhood.logs 应用 008_dex.sql(建表 + 建物化视图 + 跑 backfill 段的 INSERT)。

  3. 核对「原始 logs 里满足对应 shape 的计数」==「派生表 FINAL 计数」:

    原始 logs(按 shape 过滤)dex_pools / dex_swaps FINAL
    V3 Swap2,4512,451
    V4 Swap2,7732,773
    V3 PoolCreated22
    V4 Initialize1717
    V2 PairCreated11
  4. 从已经落盘的 dex_swaps 表(不是现算的 SELECT)里各取 5 笔 V3/V4 swap, 关联回 logs 拿原始 data,同样用独立 Python 解码比对,10/10 一致。

  5. 验证 dex_swaps_enriched:这批 2000 个区块里只有 2 个 V3 池子、17 个 V4 池子是在窗口 内新建的,其余绝大多数池子早在这个区间之前就已存在(真实的"swap 先于本地已知的池子" 场景,不是构造的)——统计上 5,003 笔 swap 的 token0 IS NULL(池子确实不在本地 dex_pools 里),221 笔能关联到真实 token0/token1/fee,且关联上的行里能看到 V4 原生代币池子 token0 正确显示为全零地址而不是和"未关联"混淆(前一节 join_use_nulls 修复后的效果)。

4. 示例查询

以下例子都遵循 queries.md 的约定:大整数用 toString(),hex() 起别名 避免和原列同名。

每个池子每天的交易量(按 token0 侧的绝对值粗略衡量,未按 decimals 换算)

SELECT
    protocol,
    hex(pool_key)                    AS pool_key_hex,
    toDate(block_timestamp)          AS day,
    count()                          AS n_swaps,
    toString(sum(abs(amount0)))      AS volume_token0_raw
FROM robinhood.dex_swaps_enriched
WHERE block_timestamp >= '2026-09-01 00:00:00'
GROUP BY protocol, pool_key, day
ORDER BY day, n_swaps DESC
LIMIT 50;

按交易对聚合的热门 pair(跨 V3/V4,只看已关联到池子的行)

SELECT
    protocol,
    hex(token0) AS token0_hex,
    hex(token1) AS token1_hex,
    count()     AS n_swaps
FROM robinhood.dex_swaps_enriched
WHERE token0 IS NOT NULL  -- 池子已在 dex_pools 落盘;见第 2 节的 join_use_nulls 说明
GROUP BY protocol, token0, token1
ORDER BY n_swaps DESC
LIMIT 20;

某个钱包地址的全部 DEX 交易(V3 用 sender+recipient,V4 只有 sender)

SELECT
    protocol,
    hex(pool_key)      AS pool_key_hex,
    block_number,
    hex(tx_hash)        AS tx_hash_hex,
    toString(amount0)   AS amount0_str,
    toString(amount1)   AS amount1_str,
    hex(sender)          AS sender_hex,
    recipient IS NOT NULL AND hex(assumeNotNull(recipient)) = 'YOUR_WALLET_HEX' AS is_recipient
FROM robinhood.dex_swaps_enriched
WHERE sender = unhex('YOUR_WALLET_HEX')
   OR (recipient IS NOT NULL AND recipient = unhex('YOUR_WALLET_HEX'))
ORDER BY block_number DESC, log_index DESC
LIMIT 100;

5. Future work

  • Uniswap V2 fork 的真实 Swap 事件签名(先确认至少一个上表列出的 fork 工厂部署的池子 合约实际用的事件,不要假设等同官方 V2)。
  • Balancer V2(按 PoolRegistered 建 dex_pools 对应行,Swap/PoolBalanceChanged 解码金额)、Curve(TokenExchange/TokenExchangeUnderlying)。
  • dex_pools/dex_swaps 的历史全量 backfill(本 PR 只在本地验证了 2000 个区块;生产 按 003_backfill.sql/README.md 里"大表按 block_number 分块跑"的方式执行,不在本 PR 范围)。

本页目录