在 0x02:Secp256k1 中,我们详细介绍了如何通过椭圆曲线标量乘法由私钥生成公钥。本篇继续探讨公钥到以太坊地址的完整转换过程,主要包含两方面的内容:
- 地址生成算法:如何从公钥通过 Keccak256 哈希得到 20 字节地址。
- EIP-55 Checksum 机制:以太坊地址本身不区分大小写,早期应用直接使用原始地址,缺乏校验机制,输错一个字符就可能导致资产永久丢失。EIP-55 校验和机制的引入,大幅降低了这类安全风险。
公钥到地址
未压缩格式公钥
回顾在 0x02:Secp256k1 中,公钥由私钥通过椭圆曲线标量乘法生成,即 \(Q = d \times G\),其中 \(d\) 是私钥,\(G\) 是 secp256k1 曲线的生成元,\(Q\) 是公钥坐标点 \((x, y)\)。
公钥可由未压缩格式(uncompressed format)表示,未压缩公钥格式由三部分组成:
- 前缀:1 字节
0x04(标识未压缩格式) - X 坐标:32 字节(256 位)
- Y 坐标:32 字节(256 位)
总长度:1 + 32 + 32 = 65 字节,十六进制表示为 130 个字符。以太坊地址并非直接使用公钥本身,而是先对公钥进行哈希运算,再从哈希结果中提取出地址部分。
Keccak256 哈希函数
以太坊使用 Keccak256 作为核心哈希函数,其特点是:
- 输入:任意长度字节
- 输出:固定 256 位(32 字节)
- 十六进制表示:64 个字符
地址生成公式
以太坊地址的生成过程可表示为:
其中:
- pubkey[1:] 表示去掉第一个字节(0x04 前缀)后的 64 字节公钥
- Keccak256(...) 对这 64 字节计算哈希,得到 32 字节结果
- last_20_bytes(...) 取哈希结果的最后 20 字节(least significant 20 bytes)
最终地址长度:20 字节 = 160 位,十六进制表示为 40 个字符。
实际案例演示
让我们通过一个具体的例子来演示地址生成过程。假设私钥是:
私钥(十六进制):
0xcd0652345ba4faa39f4451e8bcd743decff19bec0a55ac9920a8fea773e957ba
步骤 1:生成公钥
通过 secp256k1 标量乘法计算公钥:
公钥(未压缩格式):
04d369c8d0e3d227f0766a5f4879655115a65d9b7b24f24971e8f2203bef5b4ac01aa97fe04832ec90d25862265396e096823fd902e2105c8ba3c579cfba99b6ba
(
04 + X 坐标 + Y 坐标)
步骤 2:去掉前缀
去掉 0x04 后的公钥:
d369c8d0e3d227f0766a5f4879655115a65d9b7b24f24971e8f2203bef5b4ac01aa97fe04832ec90d25862265396e096823fd902e2105c8ba3c579cfba99b6ba
长度:128 个十六进制字符 = 64 字节
步骤 3:计算 Keccak256
Keccak256(公钥) =
22204fe4b276dd57557684e456d3259a49cecd07f26560fabbbdc0f5a3d43861
长度:64 个十六进制字符 = 32 字节
步骤 4:取最后 20 字节
最后 20 字节(40 个十六进制字符):
56d3259a49cecd07f26560fabbbdc0f5a3d43861
步骤 5:添加 0x 前缀
最终地址:
0x56d3259a49cecd07f26560fabbbdc0f5a3d43861
地址表示规范
以太坊地址的标准表示遵循以下规范:
- 前缀:始终以
0x开头(标识十六进制格式) - 长度:42 个字符(
0x+ 40 个十六进制字符) - 字节长度:20 字节
- 不区分大小写:在 EIP-55 Checksum 引入之前,地址大小写不影响语义
例如,以下三种表示指向同一个地址:
0x56d3259a49cecd07f26560fabbbdc0f5a3d43861
0x56D3259a49CeCD07f26560fabbBdC0F5a3d43861
0x56D3259a49cecD07f26560fabbbdC0f5a3d43861
Checksum 机制
在以太坊早期,地址的大小写是完全忽略的。这意味着 0xabcd 和 0xABCD 被视为相同的地址。这种设计虽然简单,但带来了巨大的资产丢失风险:用户在抄写或输入地址时,即使输错了字符也无法被检测出来。
一个典型的错误场景:用户想转账到地址 0x1234...abcd,但不小心输入成了 0x1234...abce(最后一个字符从 d 变成了 e)。在没有 checksum 的情况下,这笔交易会被发送到错误的地址,资金将永久丢失。
以太坊开发者的初衷,是希望用更便于人类记忆的域名系统替代原始地址进行使用,但该域名系统的开发进度远慢于预期,推进十分迟缓。为了解决这个问题,Vitalik Buterin 和 Alex Van de Sande 在 2016 年提出了 EIP-55,通过在地址大小写中嵌入校验信息,使得单个字符错误被检测出来的概率可超过 99.9%。
Checksum 计算流程
EIP-55 的核心思想是:将地址的 Keccak256 哈希值编码到地址本身的大小写中。这样,任何转录错误都可能导致大小写不匹配,从而被检测出来。
具体计算步骤如下:
步骤 1:准备地址
将原始地址(20 字节)转换为小写的十六进制字符串(不含 0x 前缀):
原始地址:0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9
小写形式: 001d3f1ef827552ae1114027bd3ecf1f086ba0f9
步骤 2:计算 Keccak256 哈希
对小写地址计算 Keccak256 哈希:
Keccak256("001d3f1ef827552ae1114027bd3ecf1f086ba0f9") =
23a69c1653e4ebbb619b0b2cb8a9bad49892a8b9695d9a19d8f673ca991deae1
步骤 3:对齐并检查每个字符
将哈希值的前 20 字节(40 个十六进制字符)与地址的 40 个字符一一对应。对于地址中的每个字符:
- 如果该字符是字母(
a-f)且对应的哈希值十六进制位 大于等于 8,则将该字符大写 - 否则保持小写
| 位置 | 地址字符 | 哈希字符 | 是否大写 | 结果 |
|---|---|---|---|---|
| 0 | 0 | 2 | 否(数字) | 0 |
| 1 | 0 | 3 | 否(数字) | 0 |
| 2 | 1 | a | 否(数字) | 1 |
| 3 | d | 3 | 否(3 < 8) | d |
| 4 | 3 | 9 | 否(数字) | 3 |
| 5 | f | c | 是(c > 8) | F |
| 6 | 1 | 1 | 否(数字) | 1 |
| 7 | e | 6 | 否(0 < 8) | e |
| ... | ... | ... | ... | ... |
步骤 4:生成 checksum 地址
经过上述处理后,添加 0x 前缀:
最终地址:0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9
Checksum 验证
验证一个地址的 checksum 是否正确非常简单:
- 将地址转换为小写
- 计算小写地址的 Keccak256 哈希
- 检查原始地址的每个字母字符的大小写是否与哈希值匹配
- 如果所有字符都匹配,则 checksum 有效;否则无效
Checksum 地址与原始的小写地址指向同一个账户
大小写仅用于校验,不影响地址的语义。钱包在验证地址时,会检查 checksum 是否正确,该校验属于应用层的校验,而非以太坊系统层的校验,所以即使 checksum 错误,用户依然可以绕过应用层校验而直接向以太坊发送该地址的交易。
0x8 阈值分析
为什么选择 0x8 作为大小写判断的阈值?这涉及十六进制字符的二进制表示。
每个十六进制字符可以用 4 位二进制表示:
| 十六进制 | 二进制 | 最高位 |
|---|---|---|
| 0-7 | 0xxx | 0 |
| 8-F | 1xxx | 1 |
关键观察:
- 十六进制数字 0-7 的二进制表示为 0000 到 0111,最高位为 0
- 十六进制数字 8-f 的二进制表示为 1000 到 1111,最高位为 1
因此,判断哈希值字符是否 ≥ 8 等价于判断其二进制表示的最高位是否为 1。这就使得判断逻辑非常简洁和高效。
准确性分析
EIP-55 的 checksum 机制能够以 >99.9% 的概率检测出单个字符错误。
对于地址中的每个字母字符(a-f):
- 如果被错误输入为另一个字母字符(a-f), checksum 失败的概率为 50%(因为哈希值最高位为 0 或 1 的概率相等)
- 如果被错误输入为数字字符(0-9),由于数字字符永远小写,checksum 一定失败
假设地址中平均有 10 个字母字符(实际以太坊地址中字母的比例约为 50%),单字符检测率为:
更精确的计算考虑了所有可能的错误类型,最终得出 99.986% 的检测率。这意味着在 100,000 次单字符错误中,只有约 14 次会被漏检。
常见特殊地址
根据椭圆曲线和哈希函数的特性,私钥 -> 公钥 -> 地址的转换过程是单向的,整个过程均不可逆。因此,对于一个地址而言,若它并非由合法私钥生成,几乎无法反推出对应的私钥,也就意味着无人能控制该地址。基于这一特性,社区会为部分地址赋予特殊用途,这些用途是社区共同约定的,并非地址本身自带的属性。
下面简单介绍两个被社区广泛认可和使用的特殊地址:一个是零地址,另一个是销毁地址(burn 地址)。
零地址
零地址是全由 0 组成的 20 字节数组:
0x0000000000000000000000000000000000000000
零地址在以太坊中的用途包括但不限于:
- 销毁以太或代币:向零地址发送的以太币或者其他代币将永久丢失,无法被任何人花费。这是合约中"销毁"(burn)代币的常用方式。
- 占位符:在 Solidity 合约中,零地址常用来表示"未设置"或"无效"的地址。例如,
owner地址在初始化前可能为零地址,表示所有者尚未设置。 - 默认值:在 Go 代码中,
Address类型的零值(zero value)就是零地址:var a common.Address,a的值自动为零地址。
Burn 地址
前文提及的零地址具备销毁以太的功能,不过社区也建议设立专门的销毁地址,用于销毁以太及各类代币。这类地址的唯一用途就是销毁资产,以此分担零地址的相关功能。
销毁地址为:
0x000000000000000000000000000000000000dEaD
go-ethereum 源码分析
前面我们介绍了从公钥到以太坊地址的理论流程,现在让我们深入 go-ethereum 源码,看看这些理论在实际工程中是如何实现的。
地址生成核心函数
在 go-ethereum 中,从公钥生成地址的核心逻辑位于 crypto 包和 common 包中。主要涉及的函数包括公钥序列化、哈希计算和地址转换。
PubkeyToAddress:公钥到地址
crypto/crypto.go:253-256 中的 PubkeyToAddress 函数实现了从公钥到地址的完整转换:
// 文件:crypto/crypto.go:253-256
func PubkeyToAddress(p ecdsa.PublicKey) common.Address {
pubBytes := FromECDSAPub(&p) // 序列化公钥为未压缩格式(65 字节)
return common.BytesToAddress(Keccak256(pubBytes[1:])[12:]) // 去掉前缀,哈希,取后 20 字节
}
这个函数只有三行代码,但包含了完整的地址生成逻辑:
FromECDSAPub(&p):将公钥对象序列化为未压缩格式的字节数组(65 字节:0x04+ X + Y)pubBytes[1:]:去掉第一个字节(0x04前缀),保留 64 字节的纯公钥数据Keccak256(...):计算 64 字节公钥的 Keccak256 哈希,得到 32 字节结果[12:]:切片操作,取哈希结果的最后 20 字节(跳过前 12 字节)common.BytesToAddress(...):将 20 字节数组转换为Address类型
FromECDSAPub:公钥序列化
crypto/crypto.go:153-158 中的 FromECDSAPub 函数负责将公钥对象序列化为字节:
// 文件:crypto/crypto.go:153-158
// FromECDSAPub 将 secp256k1 公钥转换为字节
// 注意:它不使用 pub 中的曲线参数,而是始终使用 secp256k1 编码
func FromECDSAPub(pub *ecdsa.PublicKey) []byte {
if pub == nil || pub.X == nil || pub.Y == nil {
return nil
}
return S256().Marshal(pub.X, pub.Y) // 调用 secp256k1 曲线的 Marshal 方法
}
S256().Marshal(pub.X, pub.Y) 会返回 65 字节:
[0x04, X坐标(32字节), Y坐标(32字节)]
BytesToAddress:字节数组转地址
common/types.go:226-230 中的 BytesToAddress 函数将 20 字节数组转换为 Address 类型:
// 文件:common/types.go:226-230
// BytesToAddress 返回值为 b 的 Address
// 如果 b 的长度超过 len(h),将从左侧裁剪
func BytesToAddress(b []byte) Address {
var a Address
a.SetBytes(b)
return a
}
SetBytes 方法的实现(common/types.go:328-333):
// 文件:common/types.go:328-333
func (a *Address) SetBytes(b []byte) {
if len(b) > len(a) {
b = b[len(b)-AddressLength:] // 如果超过 20 字节,取最后 20 字节
}
copy(a[AddressLength-len(b):], b) // 将 b 复制到 a 的右侧
}
EIP-55 Checksum 实现
EIP-55 Checksum 的核心实现位于 common/types.go 中的 checksumHex 函数和 Hex 方法。
checksumHex:Checksum 计算
common/types.go:270-289 中的 checksumHex 函数实现了 EIP-55 的核心算法:
// 文件:common/types.go:270-289
func (a *Address) checksumHex() []byte {
buf := a.hex() // 获取地址的小写十六进制表示(带 0x 前缀)
// 计算 checksum
sha := sha3.NewLegacyKeccak256()
sha.Write(buf[2:]) // 去掉 "0x" 前缀后计算哈希
hash := sha.Sum(nil) // hash 是 32 字节的哈希结果
for i := 2; i < len(buf); i++ { // 遍历地址的每个字符(跳过 0x)
hashByte := hash[(i-2)/2] // 每 2 个字符对应 1 个哈希字节
if i%2 == 0 {
// 偶数位置(字符的高 4 位):右移 4 位取高 nibble
hashByte = hashByte >> 4
} else {
// 奇数位置(字符的低 4 位):按位与 0x0f 取低 nibble
hashByte &= 0xf
}
// 如果字符是字母(a-f)且对应的哈希 nibble >= 8,则大写
if buf[i] > '9' && hashByte > 7 {
buf[i] -= 32 // ASCII 码减 32,小写字母转大写字母
}
}
return buf[:] // 返回带 checksum 的地址字节数组
}
这段代码的关键点:
buf[2:]:跳过0x前缀,只对 40 个十六进制字符计算哈希hash[(i-2)/2]:每 2 个十六进制字符对应 1 个哈希字节(1 字节 = 2 个十六进制字符)i%2 == 0判断:- 偶数位置(i=2,4,6,...):取哈希字节的高 4 位(通过右移 4 位)
- 奇数位置(i=3,5,7,...):取哈希字节的低 4 位(通过按位与
0x0f) buf[i] > '9':判断字符是否为字母(ASCII 码大于 '9' 的十六进制字符是 a-f)hashByte > 7:判断哈希 nibble 是否 >= 8(即最高位为 1)buf[i] -= 32:将小写字母转为大写(ASCII 码中,大写字母 = 小写字母 - 32)
Address.Hex():地址字符串输出
common/types.go:261-263 中的 Hex 方法是用户获取 checksum 地址的标准接口:
// 文件:common/types.go:261-263
// Hex 返回地址的 EIP55 兼容十六进制字符串表示
func (a Address) Hex() string {
return string(a.checksumHex())
}
当用户调用 address.Hex() 时,会自动计算并返回符合 EIP-55 标准的混合大小写地址。
总结
本文深入探讨了以太坊账户地址的生成机制。核心流程是:公钥(未压缩格式 65 字节)→ 去掉前缀 → Keccak256 哈希 → 取最后 20 字节 → 地址。但是由于以太坊地址本身不区分大小写,早期应用直接使用原始地址,缺乏校验机制,经常出现因输错单个字符导致资产永久丢失的情况。EIP-55 校验和机制通过在地址大小写中嵌入校验信息,实现了极高的错误检出率,大幅降低了因地址输入错误造成资产丢失的风险。
-- EOF --
暂无评论,来抢沙发吧!