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 V3 | Swap | 0xc42079f9...115fbcca67 | 26,911,768 | 129,572 个不同 pool 地址发出过该事件 |
| Uniswap V4 | Swap | 0x40e9cecb...84ad7112f | 7,335,173 | 单例 PoolManager:99.9994%(7,421,818/7,421,836)来自同一地址 0x8366a39cc670b4001a1121b8f6a443a643e40951 |
| Uniswap V2(及同 ABI 的 fork) | Sync | 0x1c411e9a...9fffbbad1 | 5,363,397 | 见下方「V2 的异常」 |
| Uniswap V3 | PoolCreated | 0x783cca1c...b4e6b7118 | 141,401 | 98.5%(142,282/144,443)来自单一工厂 0x1f7d7550b1b028f7571e69a784071f0205fd2efa |
| Uniswap V4 | Initialize | 0xdd466e67...0838d6438 | 93,907 | 发出者恒为上面同一个 PoolManager |
| Uniswap V2(及 fork) | PairCreated | 0x0d3648bd...1afa28d0e9 | 24,749 | 65 个不同工厂地址,见下 |
| Balancer V2 | Swap | 0x2170c741...b9e0b207b | 19,741 | 100% 来自单一 Vault 合约 0xd315a9c38ec871068fec378e4ce78af528c76293(Balancer 架构本身就是单 Vault 代所有池子转账,这是预期形状,不是异常) |
| Curve | TokenExchange | 0x8b3e96f2...15a3dd97140 | 57 | 仅 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,全链路):
-
用 RPC 端点(走本机分配的私有网关)跑
chain-indexer backfill --sink clickhouse, 区间[72039000, 72041000)(2000 个真实区块,concurrency=1, batch_blocks=8,全程 约 80 秒、平均 ~25 blocks/s,对共享节点的压力可忽略),写入本地robinhood库。 -
对本地
robinhood.logs应用008_dex.sql(建表 + 建物化视图 + 跑 backfill 段的INSERT)。 -
核对「原始
logs里满足对应 shape 的计数」==「派生表FINAL计数」:原始 logs(按 shape 过滤) dex_pools / dex_swaps FINAL V3 Swap 2,451 2,451 V4 Swap 2,773 2,773 V3 PoolCreated 2 2 V4 Initialize 17 17 V2 PairCreated 1 1 -
从已经落盘的
dex_swaps表(不是现算的 SELECT)里各取 5 笔 V3/V4 swap, 关联回logs拿原始data,同样用独立 Python 解码比对,10/10 一致。 -
验证
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 范围)。