BlockVectra

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
TransferSingleTransferSingle(address,address,address,uint256,uint256)0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62
TransferBatchTransferBatch(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. 安装与补算

  1. 依次确保已装 sql/schema.sql、002_erc721_transfers.sql,再装本文件 (sed 's/{db}/robinhood/g' 009_erc1155_nft_owners.sql | clickhouse-client --multiquery, HTTP 接口一次只发一条语句,需按 ; 拆开)。
  2. 建表语句会先建好 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——后两者创建后会立即触发一次 刷新,不需要单独补算。
  3. erc1155_transfers 需要单独跑本文件末尾的两条 INSERT INTO {db}.erc1155_transfers 语句,补历史(建 MV 之前已落盘的 logs)。和 003_backfill.sql 一样按 ReplacingMergeTree 去重,重复运行安全。E 值(回填上界)的取法与踩坑点见 schema/clickhouse/derived/README.md。
  4. 大范围回填按 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;

本页目录