账户模型与私钥关联
以太坊的“世界状态“本质上是所有账户状态的集合。账户结构与交易签名、合约权限和调用上下文共同决定状态转换。EVM 如何读写这些账户状态,见 EVM 与内部状态。
账户类型
传统上区分外部账户(EOA)与合约账户,但账户是否有代码与是否由私钥控制已不能简单划等号。下表按支持 EIP-7702 的协议规则区分:
| 属性 | 普通 EOA | EIP-7702 委托 EOA | 传统合约账户 |
|---|---|---|---|
| 私钥控制 | 有 | 仍有,私钥可签署交易及代码委托授权 | 部署地址不由私钥派生,权限由代码定义 |
| 代码 | 空 | 0xef0100 加 20 字节委托目标地址 | 部署的运行时代码 |
| 发起协议交易 | 可以 | 可以 | 不能以该账户作为原生交易发送者 |
| 执行行为 | 无账户代码 | 在该 EOA 的上下文执行委托目标代码 | 执行自身代码或代理逻辑 |
委托执行使用该 EOA 的地址、余额与存储上下文。委托不是把私钥销毁或把余额迁入目标合约,也不会自动提供安全的多签、会话权限或 ERC-1271 验签;这些需要委托代码实现。EIP-7702
代码委托会持续存在,直到通过有效授权更新或清除。授权元组包含链 ID、委托目标和 authority 的 nonce;链 ID 为 0 的授权允许跨链使用,不能默认为绑定单一链。交易发送者与授权者可以不同,授权者 nonce 也会因有效委托授权推进。
普通 EOA 的 nonce 通常随发出交易推进,合约的 nonce 随创建操作推进;7702 授权又增加了 nonce 更新路径。有效授权的委托更新在后续交易执行失败时不会被回滚,不能将所有协议级状态更新概括为“业务 revert 后全不变”。
code.length > 0 不再能证明地址没有私钥;msg.sender == tx.origin 也不能充当可靠的防重入或业务授权策略。应用应直接检查所需的权限和接口。
私钥、公钥与 EOA 地址的生成与关联
外部账户(EOA)的核心是椭圆曲线签名机制与哈希算法的结合:
[随机数 (私钥)]
│ (椭圆曲线标量乘法: secp256k1)
▼
[公钥 (坐标点 X, Y)]
│ (Keccak-256 哈希)
▼
[32字节 哈希摘要]
│ (截取后 20 字节)
▼
[以太坊 EOA 地址]
1. 私钥(Private Key)
- 生成方式:使用密码学安全的伪随机数生成器(CSPRNG)生成一个 256 位(32 字节) 的随机数。
- 数学约束:私钥 \(k\) 必须满足 \(1 \le k \le n-1\)(\(n\) 是 secp256k1 椭圆曲线的阶(Order))。
2. 公钥(Public Key)
-
算法:基于椭圆曲线数字签名算法(ECDSA)的
secp256k1曲线。 -
计算公式:
$$P = k \times G$$
- \(k\) 为私钥,\(G\) 为曲线的基点(Generator Point)。
- 乘法是椭圆曲线群上的标量乘法。
-
格式:公钥 \(P\) 是曲线上的一个二维坐标点 \((X, Y)\)。
- \(X\) 和 \(Y\) 各占 32 字节,合计 64 字节。
- 完整的未压缩公钥通常带有
0x04前缀(共 65 字节),以太坊在计算地址时会去掉0x04前缀,只保留 64 字节的 \((X, Y)\)。
3. EOA 地址(Address)
-
算法:采用
Keccak-256(以太坊标准的哈希算法,与最终定稿的 FIPS-202 SHA-3 填充标准略有不同)。 -
计算公式:
$$\text{Address}_{EOA} = \text{last20}\Big(\text{Keccak-256}(X \parallel Y)\Big)$$
- 将 64 字节的公钥 \((X \parallel Y)\) 进行 Keccak-256 哈希计算,得到 32 字节的哈希值。
- 取该哈希值的最后 20 个字节(160 位),前面加上
0x,即为以太坊地址。
4. 补充:单向性、校验格式与链上验证
- 单向性:从地址无法反推公钥,更无法反推私钥。公钥只有在首次签名后才暴露在链上。
- EIP-55 校验格式:地址本身不区分大小写(hex 相同),混合大小写只是把地址 hex 与其 keccak256 哈希做位运算生成的校验码,用于防止抄写错误,链上比较时应全部转小写。
// 验签片段;变量由外层定义。原始 ecrecover 不检查 low-s。
require(expectedSigner != address(0), "zero signer");
address recovered = ecrecover(hash, v, r, s);
require(recovered != address(0) && recovered == expectedSigner, "invalid signature");
合约地址的计算:CREATE 与 CREATE2
传统合约的部署地址由哈希规则派生,不会像 EOA 那样生成控制该地址的私钥。主要有两种生成方式:
1. 常规创建(CREATE 操作码)
由创建者地址和创建前的 nonce决定。顶层创建交易使用发送者的交易 nonce,合约内部创建使用创建合约的 nonce:
$$\text{Address}_{Contract} = \text{last20}\Big(\text{Keccak-256}\big(\text{RLP}([\text{sender}, \text{nonce}])\big)\Big)$$
RLP(Recursive Length Prefix):以太坊专用的序列化编码格式。
2. 确定性部署(CREATE2 操作码)
由 EIP-1014 引入,生成与 Nonce 解耦,常用于状态通道、抽象账户及工厂合约预先计算地址:
$$\text{Address}_{Contract} = \text{last20}\Big(\text{Keccak-256}\big(0\text{xff} \parallel \text{sender} \parallel \text{salt} \parallel \text{Keccak-256}(\text{init_code})\big)\Big)$$
0xff:固定单字节防碰撞前缀。salt:调用者传入的 32 字节盐值。init_code:待部署合约的初始化字节码。
salt:CREATE2 的确定性部署参数
CREATE 使用创建者地址与 nonce 派生地址;CREATE2 额外使用显式传入的 salt 和初始化代码哈希。部署工具选择的操作码决定是否需要 salt 参数。
salt 的参数定义
- 数据类型:它就是一个标准的
bytes32(32 字节 / 256 位)数据。 - 内容要求:没有任何特殊格式或数学约束。可以传入任意 32 字节值,例如:
- 一个数字转成的 bytes32:
bytes32(uint256(1)) - 一串字符串的哈希:
keccak256("my_project_v1") - 用户的地址填充:
bytes32(uint256(uint160(userAddress))) - 甚至纯随机数:
0x1234...abcd
- 一个数字转成的 bytes32:
它的本质就是一个“调味剂(Salt)“或“随机数 / 自定义标识符”,作用是让同一个创建者用同一套字节码部署时,通过改变这个 salt,计算出不同但完全可预测的合约地址。
为什么常规部署看不到它?
Hardhat、Foundry 或 Remix 的常规部署流程:
- 默认发起的都是常规交易部署(
CREATE)。 CREATE的地址公式依赖创建者地址与创建时的 nonce。EOA 发出的创建交易使用其交易 nonce,合约执行 CREATE 使用该创建合约的 nonce。- 创建者 nonce 随相应操作推进,生成的地址也随之变化;该地址公式不包含 salt。
显式执行 CREATE2 才需要传入 salt。
salt 在代码里长什么样?
从 Solidity 0.6.2 开始,官方支持了原生的 salt 语法:
1. 现代 Solidity 写法(最直观)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Wallet {
address public owner;
constructor(address _owner) { owner = _owner; }
}
contract Factory {
// 部署函数,显式传入 salt
function deployWallet(bytes32 salt, address owner) external returns (address) {
// 使用 {salt: salt} 语法,底层就会触发 CREATE2 操作码
Wallet newWallet = new Wallet{salt: salt}(owner);
return address(newWallet);
}
}
2. 底层 Assembly (Yul) 写法
底层工厂和部署库可通过汇编执行创建指令。以下为片段,addr 与 bytecode 由外层声明;它不是独立可编译合约:
assembly {
// create2(value, offset, size, salt)
addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
}
应用场景与预测地址的条件
CREATE2 常用于预测钱包地址、工厂部署与链下寻找特定地址前缀。若要跨链得到相同地址,需要创建者地址、salt 和完整 init code 哈希都相同;构造参数也属于 init code,运行时代码相同并不足够。
反事实钱包先预测地址、后部署执行,需要同时核对工厂代码、初始化参数与初始化权限。仅知道 salt 不能保证地址的未来控制者,也不能保证未来代码或链上行为一致。
Uniswap V2 与 V3 的 salt 不同
| 实现 | 排序后的池参数 | salt 编码 |
|---|---|---|
| V2 core v1.0.1 | token0, token1 | keccak256(abi.encodePacked(token0, token1)) |
| V3 core v1.0.0 | token0, token1, fee | keccak256(abi.encode(token0, token1, fee)) |
V3 把 fee 纳入池标识,同一币对可以有不同费率的池。预测时还必须使用该版本的部署工厂地址和 init code 哈希,不能将 V2 的排序和编码公式直接套给 V3。
来源:V2 Factory、V3 PoolDeployer。
曲线关系辨析:地址不在曲线上
地址是 20 字节的标识符,不是椭圆曲线点编码。EOA 地址由公钥哈希派生,传统合约地址由部署规则派生;这说明派生与控制方式不同,不是数学上证明某个 160 位值不存在公钥哈希原像。
- “地址“不是“公钥”:
- 椭圆曲线上的点(公钥)是 512 位的坐标 \((X, Y)\)。
- 地址是 160 位(20 字节)的截断哈希值。哈希运算是单向且有信息损失的,因此无论是 EOA 地址还是合约地址,地址本身都不是曲线上的点。
- EOA 与合约的本质差异:
- EOA:地址背后存在一个对应的椭圆曲线点 \((X, Y)\)(公钥)和私钥 \(k\)。可以通过私钥对交易签名,节点通过 ECDSA 算法能从签名中恢复出公钥点,进而校验地址。
- 合约账户:地址是由哈希算法直接算出的标识符,没有通过部署过程生成或分配的私钥。合约账户的授权由代码策略确定,不能靠寻找部署地址的私钥来控制它。
签名与验证
- ECDSA 签名产生
(v, r, s)三元组,r、s各 32 字节,v用于恢复公钥时判断曲线点的分支——上面 ecrecover 恢复公钥点的数学背景即曲线关系辨析一节。 - 普通 ECDSA 恢复常使用 27/28。EIP-155 的
chainId * 2 + 35/36是受保护的 legacy 交易编码规则;现代 typed 交易单列 chain ID 与 yParity,不能把扩展后的交易 v 直接传给ecrecover。 - 结构化签名推荐 EIP-712,用户能在钱包里看到签名内容的可读版本。
- 传统上只有 EOA 能签名,ERC-4337 账户抽象通过 UserOperation + Bundler 把“发起交易“的能力从 EOA 解耦出来。
持久键值存储(Storage)的格式与底层结构
EVM 读写存储的执行视角(
SSTORE/SLOAD、数据位置对比)见 EVM 与内部状态;Solidity 层面的插槽分配与打包规则见存储布局。本节聚焦协议与物理层。
在以太坊规范中,每个账户的存储在逻辑上是一个 \(2^{256} \to 2^{256}\) 的键值空间。其格式分为逻辑层和物理存储层:
1. 逻辑层(EVM 视角)
- 格式:
mapping(bytes32 => bytes32) - 容量:Key 和 Value 都严格是 256 位(32 字节)字。
- 稀疏性:共有 \(2^{256}\) 个存储插槽(Slots)。所有未显式写入过的插槽默认值均为
0(读取默认返回0x0,无需占用实际物理存储)。
2. 状态树层(以太坊客户端协议视角)
每个账户在以太坊全局状态树(State Trie)中记录为一个节点,包含 4 个字段:
$$\text{Account} = (\text{nonce}, \text{balance}, \text{storageRoot}, \text{codeHash})$$
storageRoot:该账户私有存储树的根哈希(256 位)。- 每个有存储的账户都拥有独立的一棵 Merkle Patricia Trie (MPT) 树(简称 Storage Trie)。
- Storage Trie 的结构:
- Trie Key:\(\text{Keccak-256}(\text{slot_index})\)(对 32 字节的插槽号做哈希,防止恶意构造使树退化)。
- Trie Value:存储内容的 RLP 编码值(会自动剥除高位的零字节以节省空间)。
3. 物理层(底层数据库视角)
协议规定状态承诺与编码规则,不要求所有客户端采用同一数据库布局。传统按哈希存储的 trie 节点可用节点哈希作为键;路径存储、平坦状态、裁剪和快照会采用不同布局。具体键值格式应对应客户端版本,不能把 LevelDB 或 Pebble 的使用当成协议要求。
与 Solana 账户模型的对比
- 以太坊:账户是“状态容器“,合约代码与状态绑定在同一个账户上;EOA 与合约账户的控制及交易能力不同,代码委托使 EOA 也能具有代码执行行为。
- Solana:一切皆账户,代码、状态、程序分离——合约(Program)本身无状态,状态存放在独立的数据账户中,通过 PDA(Program Derived Address)让程序“无私钥地控制“账户。
- 详见 Solana 账户模型。
账户机制的应用
- 地址派生用于校验交易签名、预测 CREATE/CREATE2 部署地址和检查工厂部署结果。
- 状态根、MPT 与存储寻址用于构造和验证账户或存储证明。
- 代理分析需要区分代码来源、执行地址和存储归属。
- 客户端及状态转换证明实现需要遵循相同的账户、执行与编码规则。