BlockVectra

Fees 数据集

来源:schema/clickhouse/derived/013_fees.sql。给内部用 SQL 查询交易手续费的团队用:单笔交易费用(tx_fees 视图)、按天/按类型的费用统计、每日按地址汇总。

This content is sourced from upstream and is currently available in Chinese only.

来源:schema/clickhouse/derived/013_fees.sql。给内部用 SQL 查询交易手续费的团队用:单笔交易费用(tx_fees 视图)、按天/按类型的费用统计、每日按地址汇总。

字段来源与计算公式

原始字段全部来自 {db}.transactions(回执已合并进这张表,见 sql/schema.sql): gas_used、effective_gas_price(Nullable(UInt256))、gas_used_for_l1(Nullable(UInt64),Arbitrum 适配器专有字段)。

fee_wei    = gas_used * effective_gas_price
l1_fee_wei = gas_used_for_l1 * effective_gas_price
l2_fee_wei = fee_wei - l1_fee_wei

全部是 UInt256 整数乘法,没有任何浮点、没有任何缩放——两个因子都是链上原始整数(gas 单位 × wei/gas 单位),乘积精确无损。

Arbitrum gas 记账简述

Arbitrum(Orbit L2)的每笔交易费用由两部分组成:L2 执行成本(EVM 执行、存储,跟以太坊 L1 是一回事)+ L1 calldata 成本(把这笔交易的数据发布到 L1 要付的钱)。关键点是:L1 成本不是按 L1 自己的 gas 单位计价的——arbOS 的 L1 pricer 把预估的 L1 calldata 字节成本换算成"等价 L2 gas",就是回执里的 gasUsedForL1;所以 gas_used(含 L1 部分的总 L2 gas)和 gas_used_for_l1(其中 L1 部分)用的是同一个 effective_gas_price 相乘,不需要另外查 L1 base fee。官方说明见 Arbitrum docs: How Arbitrum pricing works — L1 gas pricing。

存储模型(2026-09 磁盘瘦身改版)

改版背景:共享磁盘只剩 ~60G 可用,而按全链推算 tx_fees 这张逐笔物化镜像表要 ~59G、erc20_balance_moves 台账要 ~72G,两者合计装不下。本次改版把逐笔镜像全部去掉:

对象旧实现新实现
tx_feesReplacingMergeTree 物化表 + insert-trigger MV + 一次性回填 INSERT,~59G普通 VIEW,查询时从 {db}.transactions FINAL 现算同样的 16 列
daily_fee_stats / daily_fee_stats_by_type / daily_fee_by_address刷新 MV 从物化 tx_fees FINAL 聚合表结构不变;刷新 MV 改为直接从 {db}.transactions FINAL 聚合(不经过任何物化镜像)

tx_fees 的 16 列与旧物化表同名、同顺序、同类型(含 version UInt64/is_deleted UInt8;from/to 显式 CAST 成旧表的 FixedString(20)/Nullable(FixedString(20)),因为 transactions 里是 LowCardinality),所以既有查询不用改:SELECT ... FROM {db}.tx_fees WHERE ... 继续可用;FINAL/is_deleted = 0 写法在视图上仍然是安全的(视图内部已经 FINAL + is_deleted=0 去重,FINAL 在视图上是 no-op,不会报错也不会改变结果)。l2_fee_wei 内部是 UInt256 - UInt256(ClickHouse 会推导成 Int256),外面套了 toUInt256() 保持旧表的 UInt256 类型——链上恒有 gas_used_for_l1 <= gas_used,差不会为负。

表结构

tx_fees(逐笔,普通 VIEW)

CREATE VIEW {db}.tx_fees AS
SELECT block_number, tx_index, hash AS tx_hash, block_timestamp, type, status,
       from, to, gas_used,
       assumeNotNull(effective_gas_price) AS effective_gas_price,
       assumeNotNull(gas_used_for_l1)      AS gas_used_for_l1,
       toUInt256(gas_used) * toUInt256(assumeNotNull(effective_gas_price)) AS fee_wei,
       toUInt256(assumeNotNull(gas_used_for_l1)) * toUInt256(assumeNotNull(effective_gas_price)) AS l1_fee_wei,
       toUInt256(...fee - l1...) AS l2_fee_wei,
       version, is_deleted
FROM {db}.transactions AS t FINAL
WHERE t.is_deleted = 0
  AND t.effective_gas_price IS NOT NULL
  AND t.gas_used_for_l1 IS NOT NULL;

正确性:视图直接读源表 FINAL,transactions 的重复插入(backfill 续跑)与重组墓碑(is_deleted=1 高 version 行)在读取时就已经折叠/丢弃,不存在"第二份物理数据算错/双重计数"的可能(CORRECTNESS RULE 的 fail-safe 形态)。

effective_gas_price/gas_used_for_l1 为 NULL 的行被 WHERE 排除(fail-closed),不会默认成 0——本链族的合并回执实测两个字段总是有值(见下方验证),一旦出现 NULL 说明原始 loader 漏填了这笔回执,宁可缺一行让人发现,也不要悄悄算出一个错误的 0 手续费。

查询写这一列时必须带源表别名限定(t.effective_gas_price):本项目钉住的 ClickHouse 26.9 分析器会把 WHERE 里的未限定列名绑定到同名的 SELECT 别名上,于是 effective_gas_price IS NOT NULL 会变成 assumeNotNull(effective_gas_price) IS NOT NULL(恒真,因为 assumeNotNull 是非 Nullable 类型),NULL 回执行反而被放进来按 fee_wei = 0 计入——正好是这个视图要避免的"悄悄算成 0 手续费"。26.9.1.1629 上实测:同一份数据(4 笔 NULL 回执行 + 100 笔正常交易)不带 t. 限定返回 104 行、带限定返回 100 行。同类坑在 010_contracts.sql 有记录(那里是 tombstone INSERT 被自家别名打掉)。

daily_fee_stats / daily_fee_stats_by_type / daily_fee_by_address(收盘日聚合,CORRECTNESS RULE 方案 (b))

三张都是可刷新物化视图(REFRESH EVERY 1 DAY OFFSET 1 HOUR RANDOMIZE FOR 10 MINUTE),每次刷新直接从 {db}.transactions FINAL 重新计算全部已收盘 UTC 日(toDate(block_timestamp) < today()),然后整表原子替换(已实测确认:TO 目标表的可刷新 MV 每次刷新是整表替换,不是追加——重复刷新幂等)。

选这个方案而不是"插入触发 MV 累加计数器"的原因:SUM/COUNT 类聚合如果用插入触发 MV 直接对 transactions 的原始 INSERT 做累加,backfill 续跑的重复插入、重组的墓碑行都会被重复计入/无法冲正,造成双重计数——这是本项目 CORRECTNESS RULE 点名要避免的坑。改成"只在收盘日之后,从 FINAL 整段重新聚合",天然对重复插入和迟到的重组免疫(UTC 边界附近的罕见重组会在下一次定时刷新时自动修正,不需要人工补算)。

  • daily_fee_stats:date 粒度总计(tx_count、total_fee_wei、total_l1_fee_wei、total_l2_fee_wei、l1_share、median_effective_gas_price)。
  • daily_fee_stats_by_type:(date, type) 粒度,同样的费用口径按 tx type 拆开。
  • daily_fee_by_address:(date, from) 粒度的 tx_count/total_fee_wei,用来查"某天付费最多的地址"——没有做成固定的"Top 10"表,把完整的按地址汇总落下来,查询时自己挑 N 和排序。

三个 SELECT 的金额表达式与 tx_fees 视图内联的表达式完全一致(读 transactions 而不是视图,见 013 文件头),NULL 过滤也一致(视图侧靠源表限定,见上一节),所以它们的行集与视图完全一致;本地对同一份数据核对过三张表按日期/类型/地址加总与视图口径逐列相等。

median_effective_gas_price 用的是 quantileExact(0.5),不是 median()(近似算法,实测与精确中位数不一致——见下方"验证")。l1_share 是展示用比例(Float64),不是金额,允许非整数。

查询成本

  • 视图 = 查询时展开成对 transactions FINAL 的扫描:transactions 是全链最大表(生产上 ~824M 行 / ~104 GiB 压缩),比原来查 59G 的物化镜像更贵,但换来省掉 59G 磁盘。
  • 按 block_number(排序键前缀)或 toDate(block_timestamp) 过滤可以分区/主键剪枝;按 from/to/tx_hash 点查仍能吃到 transactions 自带的 bloom_filter skip index(idx_from/idx_to/idx_hash,GRANULARITY 1)。全表级聚合(不带任何过滤)要扫全表,只在确实需要时用。
  • "某天付费最多的地址"不要对视图做全表 group by,直接查 daily_fee_by_address。

已知边界情况(真实链上数据验证过)

在 Robinhood 链(chain id 4663)区块 [72,000,000, 72,003,000) 的真实回填数据里观测到:

  • type 0x6a(106,ArbitrumInternalTx,"系统交易"):gas/gas_used 恒为 0 → fee_wei = 0。样本区间内 3020 笔,sum(fee_wei) 精确等于 0。这类交易是 arbOS 每个区块开头写入的内部记账交易(比如 L1 base fee 更新),不是用户发起的,不产生任何手续费。
  • Retryable ticket(type 0x69=105 提交 / 0x68=104 执行重试):样本区间内各 4 笔。gas_used_for_l1 观测值均为 0——L1 calldata 成本似乎在创建 retryable ticket 的存款交易那一步就已经收取,提交(105)和重试执行(104)本身只产生 L2 执行费。value 转账发生在 104(重试执行)而不是 105(提交),提交交易本身 value=0。这是从样本观测到的现象,写在这里供后续核对;没有再往下深挖 arbOS 存款交易的记账细节。
  • 失败交易(status=0):不从费用统计里剔除。样本区间内 type=0 的 14506 笔里有 13485 笔失败(status=0),但这些交易依然实打实地消耗了 gas、付了钱——EVM 语义就是失败交易的状态变更会回滚,但已消耗的 gas 费用不退。tx_fees/daily_fee_stats 对 status 不做任何过滤,所有交易(无论成败)的手续费都计入。

验证

改版等价性:新视图 vs 旧物化表(本地真实数据,逐行精确)

本地 scratch 库回填真实区块 [72,000,000, 72,003,000)(3000 个区块、37,532 笔交易),先按改版前的 013 建物化表并快照 FINAL,再按改版后的 013 建视图,两边逐行 EXCEPT 双向比对:

new_minus_old = 0 rows, old_minus_new = 0 rows   (37,532 / 37,532 行完全一致,16 列含类型全同)
view: n=37532 total_fee_wei=180786510327052000 l1_fee_wei=278795103612000 l2_fee_wei=180507715223440000

改版前 013 对同一份数据的 eth_getBlockReceipts 逐笔比对结论(67 笔跨 7 种类型,0 mismatch)针对的原始字段没有变,视图输出的数值与旧物化表逐行一致,所以那批结论继续成立。

聚合:刷新 MV 的输出 vs 对旧物化表的独立聚合

daily_fee_stats* 只收 < today() 的收盘日,而样例区块全部还在同一个未收盘 UTC 日内,所以用一份把过滤条件改成 <= today() 的测试副本在本地触发刷新,并把结果与"直接对旧物化表快照 group by"逐列对比:

MV 刷新结果 : 2026-09-25 n=37532 total_fee_wei=180786510327052000 l1_fee_wei=278795103612000
              l2_fee_wei=180507715223440000 l1_share=0.001542123375840628 median_egp=39676000
旧表快照聚合: 同上,逐列精确相等
daily_fee_stats_by_type: 7 个 type 的 total_fee/l1/l2/tx_count 加总 = 同一天日总额(精确)
daily_fee_by_address:    6573 个地址的 total_fee/tx_count 加总 = 同一天日总额(精确)

median_effective_gas_price 用 quantileExact(0.5)(精确 39676000),此前实测 median() 别名给的是近似值 39680000,两者不一致,所以这里坚持 quantileExact。

安装幂等:install_all.sh 连续跑两次

./scripts/derived/install_all.sh <scratch> --backfill 连跑两次,tx_fees 行数/总额不变(37,532 行、total_fee_wei 不变),与旧物化表快照的逐行差异仍为 0;daily_fee_stats 三个 MV 无异常(system.view_refreshes.exception 为空)。第二次运行会按下面的安装步骤把三张日聚合 MV 先 drop 再建(各触发一次整表替换刷新),刷新后行数/数值仍不变。

NULL 回执行的 fail-closed 回归(视图 WHERE 必须带源表限定)

合成数据:200 笔正常交易 + 4 笔 effective_gas_price/gas_used_for_l1 为 NULL 的回执行(1998-07-09,形状同线上漏填回执行字段的交易)。同一份数据在 26.9.1.1629 上:

改版前 013(物化表)              : tx_fees FINAL 204 行(4 笔 NULL 回执行被算成 fee_wei=0);daily_fee_stats 1998-07-09 tx_count=4 / total_fee_wei=0
改版后 013 视图 WHERE 未加限定    : 视图 204 行(4 笔 fee_wei=0),日聚合按 transactions 正确过滤 → 1998-07-09 整天消失,视图与日聚合行集不一致
改版后 013 视图 WHERE 带 t. 限定  : 视图 200 行、1998-07-09 无行;daily_fee_stats 只剩 2026-09-20(tx_count=200、total_fee_wei=4219900420546700),与视图逐日和逐列精确相等
新视图 vs 旧物化表逐行 EXCEPT     : new-minus-old=0、old-minus-new=4(恰好是那 4 笔 NULL 回执行);16 列名称/顺序/类型全同

即:视图的 NULL 过滤只有在 WHERE 写成 t.effective_gas_price / t.gas_used_for_l1 时才真正生效;否则 tx_fees 与 daily_fee_stats* 会读两套行集,第 4 步的验收查询(sum(fee_wei) vs total_fee_wei)在含 NULL 回执行的日子里必然对不上。

安装与补算

  1. 先决条件:{db}.transactions 已经建好且有数据(chain-indexer init-schema + chain-indexer backfill,见仓库根 README)。
  2. 替换 {db} 后整体执行 013_fees.sql(sed 's/{db}/robinhood/g' 013_fees.sql | clickhouse-client --multiquery,或用 HTTP 接口时按语句拆开逐条 POST——HTTP 接口一次只接受一条语句)。
    • 改版迁移:文件开头的 DROP TABLE IF EXISTS 会删掉旧的 insert-trigger MV(mv_tx_fees)、59G 物化镜像(tx_fees),以及三张日聚合的旧刷新 MV(mv_daily_fee_stats*)。DROP TABLE 对表和视图都适用,重复执行安全;数据不丢,视图随时从 transactions 精确重算,日聚合表在重建的 MV 首次刷新时被整表替换。
    • 三张日聚合 MV 必须先 drop 再建:CREATE MATERIALIZED VIEW IF NOT EXISTS 在 MV 已存在时是静默 no-op(26.9.1.1629 实测:换一个 body 重新执行,body 不变),而 prod 上这三张 MV 是改版前建的、body 里读的还是 {db}.tx_fees。不 drop 的话,prod 会继续跑旧 body、和全新安装跑出两套不同实现(旧 body 过滤条件也因此不同)。
    • 重新执行本文件是幂等的:视图被删掉重建,daily_fee_stats* 表保持 IF NOT EXISTS,三张 MV 重建后各自只做一次整表替换刷新,不会重复计数。
  3. daily_fee_stats* 是可刷新 MV,建 MV 当下会立即跑一次初始刷新(大概率因为"今天"还没收盘、写入 0 行,属预期行为),之后每天 01:00 UTC(±10 分钟随机抖动)自动刷新。手动触发一次:SYSTEM REFRESH VIEW {db}.mv_daily_fee_stats(同理另外两个视图),SYSTEM WAIT VIEW {db}.mv_daily_fee_stats 等它跑完;SELECT * FROM system.view_refreshes WHERE database = '{db}' 看状态/下次时间/异常。
  4. 验证:对任意已收盘日期 D,SELECT sum(fee_wei) FROM {db}.tx_fees WHERE toDate(block_timestamp) = D 应精确等于 daily_fee_stats 里那天的 total_fee_wei;daily_fee_stats_by_type 按 type 分组求和、daily_fee_by_address 按 from 分组求和,两者各自加总也应精确等于同一天的 total_fee_wei。注意视图是"实时"的(含今天),聚合表只到最后一个收盘日。

查询示例

某天付费最多的 10 个地址(走聚合表,不要扫视图):

SELECT hex(from) AS address, tx_count, total_fee_wei
FROM {db}.daily_fee_by_address FINAL
WHERE is_deleted = 0 AND date = '2026-09-24'
ORDER BY total_fee_wei DESC
LIMIT 10;

单笔/单块手续费(视图按 (block_number, tx_index) 主键剪枝):

SELECT tx_hash, fee_wei, l1_fee_wei, l2_fee_wei
FROM {db}.tx_fees
WHERE block_number = 72000000
ORDER BY tx_index;

最近 30 个收盘日、按 tx type 拆开的费用趋势 + L1 占比:

SELECT s.date, s.l1_share, t.type, t.total_fee_wei, t.tx_count
FROM {db}.daily_fee_stats_by_type t FINAL
JOIN {db}.daily_fee_stats s FINAL ON s.date = t.date
WHERE t.is_deleted = 0 AND s.is_deleted = 0 AND s.date >= today() - 30
ORDER BY s.date, t.type;

已知局限

  • tx_fees 是视图:每次查询实时展开成对 transactions FINAL 的扫描/剪枝,不再有独立物理表可以针对 fees 查询单独调优;按地址做的全表级分析请优先用 daily_fee_by_address。
  • daily_fee_stats* 每次刷新都是从 transactions FINAL 重新聚合全部已收盘日期,成本随总历史数据量增长(且 transactions 比原来的物化镜像大)。当前/短期数据量下没问题;真的变慢了,修法是改成只刷新最近 N 天的窗口(+ 一次性脚本回填更早的历史日期),费用公式本身不用动。
  • Retryable(type 104/105)观测到 gas_used_for_l1 恒为 0 的现象只是从样本数据里看到的,没有往下追 arbOS 存款交易的记账代码路径确认原因;如果后续有 traces/deposit 相关数据集覆盖到这块,值得回来交叉验证。

On this page