ERC1155 转账与 NFT 持有数据集(erc1155_transfers / erc721_current_owner / erc1155_balances)
对应 SQL:schema/clickhouse/derived/009_erc1155_nft_owners.sql。 依赖已安装的 {db}.logs(sql/schema.sql)和 {db}.erc721_transfers (schema/clickhouse/derived/002_erc721…
对应 SQL:schema/clickhouse/derived/009_erc1155_nft_owners.sql。
依赖已安装的 {db}.logs(sql/schema.sql)和 {db}.erc721_transfers
(schema/clickhouse/derived/002_erc721_transfers.sql)。
1. 事件签名怎么来的(独立验证,不是记忆)
topic0 用本地 pycryptodome(Crypto.Hash.keccak,digest_bits=256)对事件签名字符串
现算 keccak256,逐一核对 32 字节长度后才用于生产/本地查询,不是抄网上的值或凭记忆写:
| 事件 | 签名 | topic0 |
|---|---|---|
TransferSingle | TransferSingle(address,address,address,uint256,uint256) | 0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62 |
TransferBatch | TransferBatch(address,address,address,uint256[],uint256[]) | 0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb |
两个事件的三个 indexed 参数顺序都是 (operator, from, to),即 topic1=operator、
topic2=from、topic3=to;和 ERC20/721 的 (from, to) 顺序不同,写 MV 时容易搞反,已用真实
mint 日志核对过方向(见第 3 节 from=0x00..00)。
2. 表设计
完整字段/注释见 SQL 文件本身,这里只说设计上的关键点。
erc1155_transfers:一条日志可能拆成多行
TransferSingle 的 data 是定长 64 字节(id、value 各一个 word),直接按位置切。
TransferBatch 的 data 是 Solidity 标准 ABI 动态数组编码:
word0 = ids 数组的字节偏移量(相对 data 起始)
word1 = values 数组的字节偏移量
偏移量处 = 数组长度 N,后面跟 N 个 word 的元素ids/values 两个数组按 ERC1155 规范长度必须相等(参考实现/所有主流实现长度不等会直接
revert,不会成为一条成功索引的日志)。实测 Robinhood 链上真实的 TransferBatch 日志里,
values 数组永远紧跟在 ids 数组后面(即标准编译器输出的非 packed 布局,
offset_values = 64 + 32 + N*32),不是任意顺序——用 [第 3 节] 的真实交易验证过。
用 arrayJoin(range(toUInt32(n_ids))) 把一条日志展开成 N 行,batch_index 记录数组内
下标(TransferSingle 恒为 0)。为了不把畸形/非标准日志解码成垃圾数据,三层过滤都是
fail-closed 而不是尽量兼容:
n_ids = n_vals(长度必须相等,否则跳过,不猜测配对方式);n_ids <= 8192(防止畸形data算出一个天文数字,range()直接把内存打爆);length(data) = 64 + 32 + n_ids*32 + 32 + n_vals*32(整条data长度必须和推算出的 数组布局完全吻合,多一字节少一字节都不吃)。
正确性设计:transfers 走方案(a),owner/balance 走方案(b)
erc1155_transfers 是 ReplacingMergeTree,MV 直接把 {db}.logs 的 version/is_deleted
带过来,和 erc20_transfers/erc721_transfers 完全一样的机制(方案 a)。关键问题:
TransferBatch 是一条源日志展开成 N 行,重组(reorg)墓碑安全吗?—— 安全,因为
src/sink_clickhouse.rs 的 tombstone_logs_sql 写墓碑的方式是显式列清单的
INSERT INTO logs (...) SELECT ... FROM logs FINAL WHERE block_number >= {from} AND is_deleted = 0(不带 AS version/AS is_deleted 别名——PR #109 修复了这些墓碑真正写入
的问题),墓碑行的 data 字节和原始行完全一致,只有 version/is_deleted 变了,所以
物化视图会解码出同一组 (token, block_number, log_index, batch_index) 主键,把整批
N 行一起标记删除,不会出现"批量转账只回滚了一半"的情况。
erc721_current_owner(当前拥有者)和 erc1155_balances(当前持仓)都不能用插入触发式
MV 维护:"当前值"是一个"最后写入者获胜"/"净额求和"聚合,而 reorg 墓碑和被替换的行共享
同一个 block_number/log_index(只有 version 更大),不在这两张表的分组/排序键里,
插入触发式聚合(比如用 argMax combinator 或者 SimpleAggregateFunction 累加)看到墓碑时
根本没有信号可以"撤销"之前已经聚合进去的值。改用 REFRESH EVERY 可刷新物化视图,
每次刷新都对 {db}.erc721_transfers / {db}.erc1155_transfers 的 FINAL ... WHERE is_deleted = 0 重新算一遍、整表原子替换——被重组撤销的转账下一次刷新自然就不再贡献,
永远正确,和历史上具体发生过什么改动无关。这正是任务里"正确性规则"的选项(b)
(REFRESH EVERY + 从 FINAL 算闭合结果),这里"闭合周期"就是刷新间隔本身:每次刷新都是
一次完整、幂等的重算,不是增量累加。erc1155_balances 仍是 REFRESH EVERY 5 MINUTE;
erc721_current_owner 因为多了支撑 GET /nfts?owner= 的 p_owner projection
(见 schema/clickhouse/derived/009_erc1155_nft_owners.sql)带来的额外刷新写入成本
(生产实测整表刷新 +30s),已改为 REFRESH EVERY 10 MINUTE。两者都不是硬性要求——当前
数据量(~72M 区块,NFT 活跃度不高)全表重扫成本很低;真长到需要优化,该做的是进一步拉长刷新间隔
或者改成按 _indexer_progress 分段的 partition-replace 脚本(见
schema/clickhouse/derived/README.md 的 E 值模式),而不是退回插入触发式 MV——那样会
重新引入上面这个 reorg 撤销不了的 bug。
开发中真实踩到的坑:UInt256 减法不会报错,会静默环绕
erc1155_balances.balance 最初设计成 UInt256,用 toUInt256(sum(delta)) 把有符号净额
转回无符号。本地验证时(见第 4 节)算出几个巨大到离谱的余额
(115792089237316...129639930,也就是 2^256 减一个小数),一开始以为是解码 bug,
实际是 toUInt256 在输入为负数时不报错、不返回 NULL,直接按位重解释成一个天文数字——
现算验证过:toUInt256(toInt256(-5)) 返回 2^256-5;toUInt256OrNull 甚至不接受
Int256 参数(报 ILLEGAL_TYPE_OF_ARGUMENT),没有现成的"转换失败就报错/给 NULL"的路径。
这正是仓库 Cardinal Rule 1(fail-closed,不能用错误值顶替正确值)要防的那类问题——把它
藏进一个看起来"正常"的巨大 UInt256 里,比让它直接崩掉危险得多。修复:balance 改成
Int256,不做无符号转换。全量从创世区块回填后,真实持有者的余额理论上恒 >= 0(ERC1155
参考实现及所有主流实现里,余额不足的转账会直接 revert,不会成为一条成功索引的转账日志);
但这个不变量是"回填完整历史"这个前提的结果,不是这条 SQL 本身能替调用者保证的——只截取
一段区块窗口做本地验证时(见第 4 节),窗口开始之前铸造、窗口内被销毁的代币就会表现为
负数。保留 Int256 让这种情况以"看得见的负数"暴露出来,而不是被 UInt256 环绕成一个
似是而非的正数悄悄吃掉。
3. 验证(本地 ClickHouse,复用已有真实回填数据 + 独立 Python 解码)
方法说明:本地 Docker ClickHouse(chain-indexer-ch,26.9.1.1629)里的 robinhood
库,是本任务并行的多个 worker 共享的同一份本地镜像,已经由其他 worker 用真实 RPC 端点
对真实 Robinhood 链(chain id 4663)[72039000, 72041000) 附近约 2000 个真实区块跑过
chain-indexer backfill --sink clickhouse 并应用了 001/002。为避免对共享节点(所有
worker 共用同一 IP、约 50 req/s)做重复的 backfill 请求,本次验证直接复用这份已落盘的
真实数据,只新增本文件的表/物化视图(不改、不删任何已有对象)。
3.1 erc1155_transfers:与原始 logs 精确匹配
| 数量 | |
|---|---|
原始 logs(TransferSingle shape,FINAL,is_deleted=0) | 13 |
erc1155_transfers(batch_index=0,按 (token,block_number,log_index) 关联回上面 13 条) | 13,13/13 精确匹配 |
TransferBatch 日志数(独立按 topic0 单独 count) | 1 |
该日志独立解码出的元素个数(硬编码偏移量 data[64:96] 现算,不复用 SQL 里 off_ids 的计算路径) | 4 |
erc1155_transfers 里该日志对应的行数 | 4,4/4 精确匹配 |
erc1155_transfers 总行数(FINAL,is_deleted=0) | 17 = 13 + 4,精确匹配 |
3.2 TransferBatch 解码:纯 Python 独立解码 vs SQL 物化视图
交易 0xd8dbcfc7c8c77037f8d3a96228bbe9369a53b93a8d946a8c6d71f848f58786c(区块
72040460,日志 log_index=6,合约 0x063bdba5c8c29a57c6530f2668cfd040b1282118,
operator=0x035fd063537b0577dfe3472204c3600be166b025,from=0x00...00(铸造),
to=0xebb5c8d140910402fe68228e37df8181d2f04fa1)的原始 hex(data) 取回本地,用一段
不复用 ClickHouse/Rust 任何解码逻辑的纯 Python(int.from_bytes(..., 'big') 手工按
ABI 偏移量解析)独立解码:
ids = [5, 6, 7, 8]
vals = [4, 4, 2, 1]与 erc1155_transfers 物化视图对这条日志算出的 4 行(id/value 逐条比对)完全一致。
3.3 erc721_current_owner:抽样核对"最后一笔转账获胜"
抽样 token=0x07f44c47743a2f36414a82b9f558ecfcf0eedcef、token_id=127220
(该 NFT 在样本窗口内共有 15 笔转账,历史最多的一个)。按 (block_number, log_index)
降序直接读 erc721_transfers FINAL 的最新一行:block_number=72039902, log_index=18, to=0x5f0a32361d55c269c7dcda3e46ee087c6895662b。erc721_current_owner 表里同一
(token, token_id) 的记录:owner=0x5f0a32361d55c269c7dcda3e46ee087c6895662b, block_number=72039902, log_index=18——完全一致。
3.4 erc1155_balances:抽样核对净额
同上 3.2 节的铸造交易之外,再取 token=0x063bdba5...、id=1000、
holder=0xebb5c8d140910402fe68228e37df8181d2f04fa1 这组:样本窗口内该地址对 id=1000
只有一笔记录——TransferSingle(区块 72040709)里 from=holder, to=0x00..00(销毁),
value=240,窗口内无任何该地址接收 id=1000 的记录。erc1155_balances 里这一行
balance = -240——和手工逐行核对的净额完全一致(该地址在窗口开始前应已持有 >= 240 份,
这是"局部窗口"而非"从创世区块起算"的预期表现,见 2 节最后一段)。
4. 安装与补算
- 依次确保已装
sql/schema.sql、002_erc721_transfers.sql,再装本文件 (sed 's/{db}/robinhood/g' 009_erc1155_nft_owners.sql | clickhouse-client --multiquery, HTTP 接口一次只发一条语句,需按;拆开)。 - 建表语句会先建好
erc1155_transfers的两个插入触发 MV(mv_erc1155_transfer_single/mv_erc1155_transfer_batch),再建erc721_current_owner(REFRESH EVERY 10 MINUTE)/erc1155_balances(REFRESH EVERY 5 MINUTE)的两个可刷新 MV——后两者创建后会立即触发一次 刷新,不需要单独补算。 erc1155_transfers需要单独跑本文件末尾的两条INSERT INTO {db}.erc1155_transfers语句,补历史(建 MV 之前已落盘的logs)。和003_backfill.sql一样按ReplacingMergeTree去重,重复运行安全。E值(回填上界)的取法与踩坑点见schema/clickhouse/derived/README.md。- 大范围回填按
block_number分块跑,每跑完一块用第 3 节同样的"原始logs计数 vs 派生表计数"方法复核一次。
5. 示例查询
-- 某地址当前持有的所有 ERC1155(排除铸造/销毁产生的零地址行)
SELECT hex(token) AS token, id, balance
FROM robinhood.erc1155_balances
WHERE holder = unhex('EBB5C8D140910402FE68228E37DF8181D2F04FA1')
AND holder != repeat('\0', 20)
ORDER BY token, id;
-- 某 NFT 当前拥有者
SELECT hex(owner) AS owner, block_number, tx_hash
FROM robinhood.erc721_current_owner
WHERE token = unhex('07F44C47743A2F36414A82B9F558ECFCF0EEDCEF') AND token_id = 127220;
-- 某 ERC1155 合约、某 id 的历史转账(含批量转账里的单个元素)
SELECT block_number, log_index, batch_index, hex(from) AS `from`, hex(to) AS `to`, value
FROM robinhood.erc1155_transfers FINAL
WHERE is_deleted = 0
AND token = unhex('063BDBA5C8C29A57C6530F2668CFD040B1282118')
AND id = 1000
ORDER BY block_number, log_index, batch_index;价格预言机数据集(oracle_feeds / oracle_prices)
Robinhood Chain(chain id 4663)上确实存在链上价格预言机,用于给约 60 多个代币化股票 / 加密资产 / 稳定币提供参考价。经独立验证,其形态是经典 Chainlink FluxAggregator (AggregatorV3Interface + AnswerUpdated/…
WETH 封装代币流水数据集(`weth_flows` & `daily_weth_net_wrap`)
面向直接写 SQL 的内部团队。覆盖 Robinhood Chain(chain id 4663,Arbitrum Orbit)上原生代币封装/解封装(wrap / unwrap)流水与每日净封装量统计。 对应 DDL 与物化视图定义在 schema/clickhouse/derived/025_weth_…