创建一个钱包时,我们通常会得到一组助记词。之后,即使更换设备,只要输入这组助记词,也能恢复原来的地址。这背后常用的一套方案,就是 BIP39、BIP32 和 BIP44。
它们分别解决不同的问题:
| 规范 | 作用 |
|---|---|
| BIP39 | 生成助记词,并由助记词和可选口令派生 seed |
| BIP32 | 从 seed 派生出树状结构的密钥 |
| BIP44 | 约定密钥树中各层的用途 |
三者组合起来,可以实现一种常见的分层确定性钱包,也就是 HD Wallet。不过,HD 钱包并不必须使用 BIP39 或 BIP44,它们分别提供了助记词方案和路径约定。BIP39、BIP32、BIP44
本文以通过私钥控制的以太坊外部账户(EOA)为例,介绍从助记词生成地址、恢复地址,以及查询余额的过程。
从一个 seed 派生多个密钥
BIP32 定义了 Hierarchical Deterministic Wallet,即分层确定性钱包。
“分层”指密钥之间存在父子关系,组成一棵树;“确定性”指在 seed 和派生路径相同的情况下,总能得到相同的密钥。
因此,钱包不需要单独备份每一个派生出来的私钥,可以从同一个 seed 重新计算它们。实际恢复时,还需要知道或由钱包识别对应的派生路径与地址规则。
BIP32 中的节点不仅包含密钥,还包含一个参与后续派生的 chain code。密钥和 chain code 组合起来,构成扩展私钥或扩展公钥。
扩展私钥可以派生后代私钥;扩展公钥可以派生非硬化路径上的后代公钥。这让系统可以在不提供私钥的情况下,生成收款地址,实现只读观察和签名能力的分离。这里的能力分离来自密钥派生规则,并不是链上的角色权限系统。BIP32
用派生路径定位密钥
BIP44 基于 BIP32 和 BIP43,为密钥树的各层赋予了明确含义:
m / purpose' / coin_type' / account' / change / address_index
其中,m 表示主私有节点,其余部分含义如下:
| 层级 | 含义 |
|---|---|
purpose' | 在 BIP44 中固定为 44',标识这棵子树使用 BIP44 规则 |
coin_type' | 币种类型,用于划分不同币种的密钥空间 |
account' | 账户索引,从 0 开始 |
change | 0 表示对外收款分支,1 表示内部找零分支 |
address_index | 分支中的地址索引,从 0 开始 |
这里的“外部链”和“内部链”是密钥树中的分支。内部找零分支主要对应比特币等使用 UTXO 模型的场景。BIP44
需要区分的是,BIP44 的 purpose 始终是 44'。例如,比特币原生隔离见证地址可以使用 84',但那属于 BIP84,并不是 BIP44 对比特币的特殊取值。BIP84
币种编号可以在 SLIP-0044 中查询。例如,比特币是 0,以太坊是 60,写在硬化派生路径中时分别表示为 0' 和 60'。这个编号不是连接网络时使用的 Chain ID。SLIP-0044
以太坊中常见的第一个地址路径是:
m/44'/60'/0'/0/0
一些钱包也会在多个 EVM 网络中复用这条路径。例如,Phantom 的一组派生规则会为 Ethereum、Polygon 和 Base 使用同样的以太坊路径。这属于钱包的实现约定,不能据此假设所有 EVM 钱包的路径都相同。Phantom 派生路径说明
路径中的单引号 ' 表示硬化派生。硬化子节点的派生需要父私钥,不能仅从父扩展公钥完成。在 BIP44 中,前三层使用硬化派生,后两层使用普通派生。BIP32
还有一个容易混淆的细节:钱包界面中的“账户”,不一定对应路径中的 account'。
例如,钱包界面里的三个账户,可能分别使用:
m/44'/60'/0'/0/0
m/44'/60'/0'/0/1
m/44'/60'/0'/0/2
这里增加的是最后一层 address_index,而 account' 始终为 0'。ethers 的文档也专门区分了增加账户层与增加地址索引这两种路径习惯。ethers HD 钱包文档
BIP44 确实建议:当前一个账户没有交易历史时,软件应阻止创建后续账户。这个约定是为了让恢复程序能够顺序发现账户,并在遇到未使用的账户时停止。判断依据是交易历史,而不是当前余额。它并不是派生算法的限制,也不能直接套用到所有钱包界面的“添加账户”操作。BIP44 账户发现规则
从随机数生成助记词
BIP39 定义了两个过程:先生成助记词,再从助记词派生 seed。
以 12 个英文单词的助记词为例:
0~2047 的索引,从包含 2048 个词的词表中查出对应单词。这些单词允许重复,但顺序不能打乱。
BIP39 也支持 15、18、21 和 24 个词,分别对应 160、192、224 和 256 位初始熵。除了英文,还有中文、日语等词表;考虑钱包之间的兼容性,规范建议优先使用英文词表。BIP39
接下来,助记词与可选的 passphrase 一起,通过 PBKDF2-HMAC-SHA512 派生出一个 64 字节的 seed。这个过程使用 2048 次迭代,并按规范对输入进行 NFKD 规范化;未设置 passphrase 时,使用空字符串。
因此,完整流程是:
随机熵 + 校验位
↓
助记词 + 可选的 passphrase
↓
seed
↓
BIP32 主节点
↓
按指定路径派生私钥
↓
公钥 → 地址
passphrase 不是钱包应用的解锁密码。它会直接影响 seed:同一组助记词配上不同 passphrase,会得到不同的钱包;输错 passphrase 也可能得到一个正常但完全不同的地址。BIP39:从助记词到 seed
用 JavaScript 实现
下面使用 Node.js,保留助记词生成和密钥派生的分步过程:
bip39:生成、校验助记词,并派生 seed。hdkey:实现 BIP32 密钥派生。ethers:从私钥构造以太坊钱包对象,并通过 RPC 查询链上状态。安装依赖:
npm install bip39 hdkey ethers@6
创建 wallet-demo.mjs 文件:
import bip39 from 'bip39'
import HDKey from 'hdkey'
import { Wallet, hexlify } from 'ethers'
const {
setDefaultWordlist,
generateMnemonic,
validateMnemonic,
mnemonicToSeedSync,
} = bip39
setDefaultWordlist('english')
// 生成 128 位随机熵对应的 12 词助记词。
const mnemonic = generateMnemonic(128)
// BIP39 可选口令;本例不设置。
const passphrase = ''
// 本例仅处理英文 BIP39 助记词。
function createMasterNode(phrase, password = '') {
if (!validateMnemonic(phrase)) {
throw new Error('助记词未通过 BIP39 校验')
}
// 返回 Buffer,不需要先转为十六进制再转回来。
const seed = mnemonicToSeedSync(phrase, password)
return HDKey.fromMasterSeed(seed)
}
function deriveEthereumWallet(masterNode, path) {
const childNode = masterNode.derive(path)
if (!childNode.privateKey) {
throw new Error('派生节点不包含私钥')
}
return new Wallet(hexlify(childNode.privateKey))
}
const masterNode = createMasterNode(mnemonic, passphrase)
const path = "m/44'/60'/0'/0/0"
const wallet = deriveEthereumWallet(masterNode, path)
console.log('派生路径:', path)
console.log('以太坊地址:', wallet.address)
// 使用相同助记词、passphrase 和路径恢复地址。
const restoredMasterNode = createMasterNode(mnemonic, passphrase)
const restoredWallet = deriveEthereumWallet(restoredMasterNode, path)
console.log('恢复地址一致:', restoredWallet.address === wallet.address)
// 演示增加 address_index,派生另一个地址。
const secondPath = "m/44'/60'/0'/0/1"
const secondWallet = deriveEthereumWallet(masterNode, secondPath)
console.log('第二个地址:', secondWallet.address)
运行:
node wallet-demo.mjs
这里使用了 hdkey 完成 BIP32 派生,因此不需要再单独安装 bip32。相关方法可以在 bip39 文档、hdkey 文档 和 ethers 钱包文档 中查阅。
validateMnemonic() 检查的是词表、长度和校验位等格式要求。通过校验不代表它属于某个用户,也不代表它一定是由安全随机数生成的。
这个脚本仅演示内存中的生成和恢复,没有保存助记词。每次重新运行都会生成新的助记词和地址。真正的钱包还需要提供安全的备份与存储流程。
地址是怎样产生的
获得派生私钥后,以太坊使用 secp256k1 曲线生成公钥,再对未压缩公钥的 64 字节坐标部分计算 Keccak-256,取结果的最后 20 字节作为地址。显示时通常加上 0x 前缀。
库已经封装了这个过程,因此示例中直接读取 wallet.address 即可。
到这里,整个过程都可以离线完成。生成密钥和地址不需要先向区块链注册,也不需要支付手续费。资产余额记录在链上,钱包保存和使用的是控制账户所需的密钥。以太坊账户说明
通过节点查询余额
钱包对象能够从私钥计算出地址,但当前余额属于链上状态,需要连接节点查询。
原代码中的:
ethAccount.eth.getBalance(address)
不能使用,因为 @ethereumjs/wallet 提供的是密钥和 keystore 等工具,没有这样的 eth 查询接口。EthereumJS Wallet 文档
使用 ethers v6 时,可以通过 JsonRpcProvider 查询余额。将下面代码追加到前面的文件末尾:
import { JsonRpcProvider, formatEther } from 'ethers'
// 在运行环境中设置 ETH_RPC_URL,指向以太坊主网 RPC。
const rpcUrl = process.env.ETH_RPC_URL
if (!rpcUrl) {
console.log('未设置 ETH_RPC_URL,跳过余额查询')
} else {
const provider = new JsonRpcProvider(rpcUrl)
try {
const network = await provider.getNetwork()
// 本例查询以太坊主网 ETH,先确认连接的网络。
if (network.chainId !== 1n) {
throw new Error('请使用以太坊主网 RPC')
}
const balanceWei = await provider.getBalance(wallet.address)
console.log('余额(wei):', balanceWei.toString())
console.log('余额(ETH):', formatEther(balanceWei))
} catch (error) {
console.error(
'余额查询失败:',
error instanceof Error ? error.message : '未知错误',
)
} finally {
provider.destroy()
}
}
getBalance() 返回以 wei 为单位的 bigint,formatEther() 将其转换为适合展示的 ETH 字符串。查询只需要地址,无须向 RPC 节点提供助记词、seed 或私钥。ethers Provider 文档、单位转换文档
同一个地址在不同网络上的余额需要分别查询。切换 RPC 网络并不会把资产转移到另一条链上。
恢复钱包时,需要保留哪些信息
从上面的过程可以看出,恢复同一个派生地址,需要保持这些输入和规则一致:
这也解释了为什么有时助记词输入正确,新钱包却没有显示原来的地址:两个钱包可能采用了不同路径,或没有扫描到对应索引。MetaMask 的导入说明也明确提示了派生路径的兼容范围。MetaMask 导入说明
备份单个派生私钥,只能恢复它对应的账户,不能据此恢复整个 HD 钱包。完整备份应围绕助记词及其 passphrase 展开,并保留必要的路径信息。
助记词、seed 和私钥都不应进入日志、代码仓库或分析上报系统。示例也没有打印这些内容。实际产品除了派生算法,还需要认真处理随机数来源、密钥加密存储、备份确认和签名流程。
当这些环节连接起来,我们才从“生成一个地址”的演示,走向一个能够安全创建、恢复并使用账户的钱包。