25 / 10 / 24

以太坊验证者追加存款问题 eth validator top-up

总结

本文记录了实现以太坊验证者追加存款(Top-up)功能时遇到的技术难题和解决过程。核心问题在于对 deposit_data_root 的误解——很多开发者错误地认为可以通过调用合约的 get_deposit_root() 函数来获取这个值,但实际上:

  • get_deposit_root() 返回的是存款合约维护的全局 Merkle 树根(包含所有历史存款)

  • deposit_data_root 是单笔存款数据的 SSZ 哈希值,必须根据存款参数计算得出

关键发现:

  1. 计算误区: 不能从链上读取 deposit_data_root,必须根据存款数据(公钥、提款凭证、金额、签名)通过 SSZ Merkleization 算法计算

  2. 实现陷阱: SSZ Merkleization 的实现细节极易出错,尤其是对大于 32 字节字段的分块和哈希处理

  3. 精度问题: JavaScript 浮点数运算可能导致金额转换误差,应使用 ethers.parseEther() 确保精度

通过正确实现 SSZ 规范和使用合适的数值处理方法,最终成功完成了 Top-up 功能。


研究过程

第一步: 初始需求与误解

最初的需求很简单:为已有 32 ETH 的验证者追加存款。查阅 eth2book 文档 后,发现存款合约有一个 get_deposit_root() 函数。

第一个误解产生了: 既然存款需要 deposit_data_root 参数,那直接调用这个函数获取不就行了?

typescript

// ❌ 错误的想法 const depositDataRoot = await depositContract.get_deposit_root()

第二步: 理解 Top-up 的参数要求

深入研究后发现,即使是 Top-up,调用 deposit() 函数仍需要提供 4 个参数:

solidity

function deposit( bytes calldata pubkey, // 48 字节 - 验证者公钥 bytes calldata withdrawal_credentials, // 32 字节 - 提款凭证 bytes calldata signature, // 96 字节 - BLS 签名 bytes32 deposit_data_root // 32 字节 - 存款数据根 ) external payable

但对于已存在的验证者:

  • pubkey - 必须正确(匹配现有验证者)

  • ⚠️ withdrawal_credentials - 会被忽略(可以填零)

  • ⚠️ signature - 不会被验证(可以填零)

  • deposit_data_root - 必须正确计算

这看起来简化了问题,但引出了核心疑问: 如何获取正确的 deposit_data_root?

第三步: 概念澄清的关键突破

经过对比成功和失败的交易数据,发现了根本问题:

我的 deposit_data_root: 0x63a6df1303a70f8485feb059422a4dbe9e5952542ca0a21e58977a4c5744844b 成功的 deposit_data_root: 0x4885dce26e55402f7ffee9822f6b47ce836714fc4c6ffc4634af48dc2e49c301

完全不同! 这暴露了核心误解:

函数含义用途get_deposit_root()合约维护的增量 Merkle 树根包含所有历史存款的树根deposit_data_root单笔存款数据的哈希值验证本次存款数据完整性

关键认知: deposit_data_root 是存款数据的"指纹",无法从链上读取,必须根据存款参数计算!

第四步: SSZ Merkleization 实现

deposit_data_root 的计算需要遵循 SSZ (Simple Serialize) 规范:

DepositData: ├─ pubkey: BLSPubkey (48 bytes) ├─ withdrawal_credentials: Bytes32 (32 bytes) ├─ amount: uint64 (8 bytes, 以 Gwei 为单位) └─ signature: BLSSignature (96 bytes) deposit_data_root = hash_tree_root(DepositData)

对于 Top-up 可以简化:

  • 使用空的 signature (96 个零)

  • 使用空的 withdrawal_credentials (32 个零)

实现步骤:

  1. 字段序列化

typescript

pubkey: 48 字节,直接使用 withdrawal_credentials: 32 字节,全零 amount: 8 字节,小端序 uint64 signature: 96 字节,全零
  1. SSZ Merkleization 规则

    • ≤ 32 字节: 直接 padding 到 32 字节

    • > 32 字节: 分块为 32 字节 chunks,构建 Merkle 树

  2. 计算 Merkle 根

typescript

root = hash( hash(pubkey_root, withdrawal_root), hash(amount_root, signature_root) )

第五步: 实现中的陷阱

陷阱 1: 浮点数精度问题

typescript

// ❌ 错误: JavaScript 浮点数精度问题 const amountInGwei = BigInt(parseFloat(amountInEth) * 1e9) // ✅ 正确: 使用 ethers.js 确保精度 const amountInWei = ethers.parseEther(amountInEth) const amountInGwei = amountInWei / BigInt(1e9)

陷阱 2: SSZ 分块处理错误

最初的错误实现:

typescript

// ❌ 错误: 对每个 chunk 都先 hash 了 let layer: Buffer[] = chunks.map(hashChunk)

正确实现:

typescript

// ✅ 正确: chunks 直接作为叶子节点 function merkleize(chunks: Buffer[]): Buffer { if (chunks.length === 1) return chunks[0] let layer = chunks while (layer.length > 1) { const nextLayer: Buffer[] = [] for (let i = 0; i < layer.length; i += 2) { const left = layer[i] const right = i + 1 < layer.length ? layer[i + 1] : Buffer.alloc(32, 0) // 这里才进行 hash nextLayer.push(hash(Buffer.concat([left, right]))) } layer = nextLayer } return layer[0] }

第六步: 完整正确实现

typescript

import { ethers } from 'ethers' import { sha256 } from 'js-sha256' /** * 计算 Top-up 的 deposit_data_root */ async function calculateTopupDepositRoot( pubkeyHex: string, // 96 hex chars (48 bytes) amountInEth: string // e.g., "2.5" ): Promise<string> { // 1. 准备数据 const pubkey = Buffer.from(pubkeyHex, 'hex') const withdrawalCredentials = Buffer.alloc(32, 0) const amountInWei = ethers.parseEther(amountInEth) const amountInGwei = amountInWei / BigInt(1e9) const signature = Buffer.alloc(96, 0) // 2. 序列化金额 (小端序 uint64) const amountBuffer = Buffer.alloc(32, 0) amountBuffer.writeBigUInt64LE(amountInGwei, 0) // 3. 计算各字段的根 const pubkeyRoot = getSSZRoot(pubkey) // 48 bytes -> 需要 merkleize const withdrawalRoot = withdrawalCredentials // 32 bytes -> 直接用 const amountRoot = amountBuffer // 已 padding 到 32 const signatureRoot = getSSZRoot(signature) // 96 bytes -> 需要 merkleize // 4. 构建最终的 Merkle 树 const layer1Left = hash(Buffer.concat([pubkeyRoot, withdrawalRoot])) const layer1Right = hash(Buffer.concat([amountRoot, signatureRoot])) const root = hash(Buffer.concat([layer1Left, layer1Right])) return '0x' + root.toString('hex') } function hash(data: Buffer): Buffer { return Buffer.from(sha256.array(data)) } function getSSZRoot(data: Buffer): Buffer { const chunks = chunkify(data) if (chunks.length === 1) return chunks[0] return merkleize(chunks) } function chunkify(data: Buffer): Buffer[] { const chunks: Buffer[] = [] for (let i = 0; i < data.length; i += 32) { const chunk = Buffer.alloc(32, 0) data.copy(chunk, 0, i, Math.min(i + 32, data.length)) chunks.push(chunk) } return chunks } function merkleize(chunks: Buffer[]): Buffer { if (chunks.length === 1) return chunks[0] let layer = chunks while (layer.length > 1) { const nextLayer: Buffer[] = [] for (let i = 0; i < layer.length; i += 2) { const left = layer[i] const right = i + 1 < layer.length ? layer[i + 1] : Buffer.alloc(32, 0) nextLayer.push(hash(Buffer.concat([left, right]))) } layer = nextLayer } return layer[0] }

第七步: 验证测试

使用真实数据测试:

typescript

const pubkey = '95be74963601fa66a1a5a38cd34f9cb0c6bbf5d9c0c10d5cb293b3443e531419e12fc8c02b9ccfa9888adfcd1e7a1bb5' const amount = '2.5' const depositRoot = await calculateTopupDepositRoot(pubkey, amount) console.log('Calculated deposit_data_root:', depositRoot)

成功后,交易才能被合约接受!

关键要点总结

  1. 概念区分:

    • get_deposit_root(): 合约全局状态

    • deposit_data_root: 本次存款数据的哈希

  2. 计算必要性: deposit_data_root 无法读取,必须计算

  3. SSZ 规范严格性: 实现细节决定成败

    • 正确的分块策略

    • 正确的 Merkle 树构建

    • 正确的数值序列化

  4. 工程实践:

    • 使用 ethers.parseEther() 避免精度问题

    • 理解 Buffer 操作的字节序

    • 测试对比已知成功案例

  5. 最佳实践: 对于生产环境,建议使用官方的 staking-deposit-cli 工具生成 deposit data,避免手动计算错误

参考资源