追梦人物 博主
一直走在追梦的路上。

0x03:Address

2026-04-104 阅读0 评论

0x02:Secp256k1 中,我们详细介绍了如何通过椭圆曲线标量乘法由私钥生成公钥。本篇继续探讨公钥到以太坊地址的完整转换过程,主要包含两方面的内容:

  1. 地址生成算法:如何从公钥通过 Keccak256 哈希得到 20 字节地址。
  2. 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 个字符

地址生成公式

以太坊地址的生成过程可表示为:

\[ \text{address} = \text{last\_20\_bytes}(\text{Keccak256}(\text{pubkey}[1:])) \]

其中:
- 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 机制

在以太坊早期,地址的大小写是完全忽略的。这意味着 0xabcd0xABCD 被视为相同的地址。这种设计虽然简单,但带来了巨大的资产丢失风险:用户在抄写或输入地址时,即使输错了字符也无法被检测出来。

一个典型的错误场景:用户想转账到地址 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 是否正确非常简单:

  1. 将地址转换为小写
  2. 计算小写地址的 Keccak256 哈希
  3. 检查原始地址的每个字母字符的大小写是否与哈希值匹配
  4. 如果所有字符都匹配,则 checksum 有效;否则无效

Checksum 地址与原始的小写地址指向同一个账户

大小写仅用于校验,不影响地址的语义。钱包在验证地址时,会检查 checksum 是否正确,该校验属于应用层的校验,而非以太坊系统层的校验,所以即使 checksum 错误,用户依然可以绕过应用层校验而直接向以太坊发送该地址的交易。

0x8 阈值分析

为什么选择 0x8 作为大小写判断的阈值?这涉及十六进制字符的二进制表示。

每个十六进制字符可以用 4 位二进制表示:

十六进制 二进制 最高位
0-7 0xxx 0
8-F 1xxx 1

关键观察:
- 十六进制数字 0-7 的二进制表示为 00000111最高位为 0
- 十六进制数字 8-f 的二进制表示为 10001111最高位为 1

因此,判断哈希值字符是否 ≥ 8 等价于判断其二进制表示的最高位是否为 1。这就使得判断逻辑非常简洁和高效。

准确性分析

EIP-55 的 checksum 机制能够以 >99.9% 的概率检测出单个字符错误。

对于地址中的每个字母字符(a-f):
- 如果被错误输入为另一个字母字符(a-f), checksum 失败的概率为 50%(因为哈希值最高位为 0 或 1 的概率相等)
- 如果被错误输入为数字字符(0-9),由于数字字符永远小写,checksum 一定失败

假设地址中平均有 10 个字母字符(实际以太坊地址中字母的比例约为 50%),单字符检测率为:

\[ 1 - 0.5^{10} \approx 99.9\% \]

更精确的计算考虑了所有可能的错误类型,最终得出 99.986% 的检测率。这意味着在 100,000 次单字符错误中,只有约 14 次会被漏检。

常见特殊地址

根据椭圆曲线和哈希函数的特性,私钥 -> 公钥 -> 地址的转换过程是单向的,整个过程均不可逆。因此,对于一个地址而言,若它并非由合法私钥生成,几乎无法反推出对应的私钥,也就意味着无人能控制该地址。基于这一特性,社区会为部分地址赋予特殊用途,这些用途是社区共同约定的,并非地址本身自带的属性。

下面简单介绍两个被社区广泛认可和使用的特殊地址:一个是零地址,另一个是销毁地址(burn 地址)。

零地址

零地址是全由 0 组成的 20 字节数组:

0x0000000000000000000000000000000000000000

零地址在以太坊中的用途包括但不限于:

  • 销毁以太或代币:向零地址发送的以太币或者其他代币将永久丢失,无法被任何人花费。这是合约中"销毁"(burn)代币的常用方式。
  • 占位符:在 Solidity 合约中,零地址常用来表示"未设置"或"无效"的地址。例如,owner 地址在初始化前可能为零地址,表示所有者尚未设置。
  • 默认值:在 Go 代码中,Address 类型的零值(zero value)就是零地址:var a common.Addressa 的值自动为零地址。

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 字节
}

这个函数只有三行代码,但包含了完整的地址生成逻辑:

  1. FromECDSAPub(&p):将公钥对象序列化为未压缩格式的字节数组(65 字节:0x04 + X + Y)
  2. pubBytes[1:]:去掉第一个字节(0x04 前缀),保留 64 字节的纯公钥数据
  3. Keccak256(...):计算 64 字节公钥的 Keccak256 哈希,得到 32 字节结果
  4. [12:]:切片操作,取哈希结果的最后 20 字节(跳过前 12 字节)
  5. 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 的地址字节数组
}

这段代码的关键点:

  1. buf[2:]:跳过 0x 前缀,只对 40 个十六进制字符计算哈希
  2. hash[(i-2)/2]:每 2 个十六进制字符对应 1 个哈希字节(1 字节 = 2 个十六进制字符)
  3. i%2 == 0 判断
  4. 偶数位置(i=2,4,6,...):取哈希字节的高 4 位(通过右移 4 位)
  5. 奇数位置(i=3,5,7,...):取哈希字节的低 4 位(通过按位与 0x0f
  6. buf[i] > '9':判断字符是否为字母(ASCII 码大于 '9' 的十六进制字符是 a-f)
  7. hashByte > 7:判断哈希 nibble 是否 >= 8(即最高位为 1)
  8. 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 --

0 评论
登录后回复

暂无评论,来抢沙发吧!