技术笔记
区块链协议、智能合约、密码学、Rust 与网络机制的技术记录。涉及具体库和协议版本的行为,以相应规范、源码与执行环境为依据。
本站使用 GoatCounter 统计访问情况。
作者
徐
资源导航
遇到收费等情况自行斟酌,仅做推荐
电子书站点
全国图书馆参考咨询联盟 ->声名不显的网站,在国内流通的任何书籍无论冷门热门均有,包括冷门教材。获取书籍方法需要渠道,可淘宝问。
文献
娱乐影视
前端部分
Ethereum
概览(官方介绍)
什么是以太坊?
以太坊是一个由世界各地的计算机组成的网络,遵循一套称为以太坊协议的规则。以太坊网络提供了一个基础,任何人都可以在上面构建和使用社区、应用程序、组织和数字资产。
谁在运行以太坊?
以太坊不受任何特定实体控制。只要计算机运行遵循以太坊协议的软件,相互连接,并为以太坊区块链添加区块,以太坊就会存在。其中每一台计算机都被称为节点。任何人都可以运行节点,但必须要质押以太币(以太坊的原生代币)才能参与保护网络安全的工作。任何人无需许可,都可以用 32 个以太币参与质押。 甚至以太坊的源代码也不是由单个实体生成的。任何人都可以建议更改协议并讨论升级。有一些以太坊协议的实现是由独立组织用多种编程语言完成的,它们通常建立在开放的基础上并鼓励来自社区的贡献。
以太坊基础
以太坊是支持智能合约的分布式状态机。节点依据相同的交易顺序和执行规则计算状态变化,共识协议确定区块历史。执行层负责交易和 EVM 执行,共识层负责区块提议、分叉选择与最终性。
智能合约与 EVM
智能合约由部署后的字节码和账户状态组成。Solidity、Vyper 等语言编译为 EVM 字节码,EVM 按操作码执行。合约函数由交易或调用触发,不会仅因某个时间点到达而自行运行。
每次执行都受 Gas 预算限制。EVM 支持通用计算,但一笔交易无法无限执行。节点重放交易需要得到相同结果,合约不能任意访问本地文件或外部网络。
合约业务可以通过代理实现升级:代理保存状态并委托给实现合约执行。升级替换实现地址,不等同于任意改写已经部署的字节码。权限、存储兼容与调用语义分别影响升级安全。
账户与状态
| 账户 | 控制方式 | 交易与调用 |
|---|---|---|
| 外部账户(EOA) | 由私钥签署交易和授权 | 能发起协议层交易;EIP-7702 允许设置代码委托标记 |
| 合约账户 | 由合约代码规定操作条件 | 在交易执行中接收调用并发出消息调用,没有与账户天然对应的私钥 |
EIP-7702 下的 EOA 仍保留私钥控制能力,因此代码是否为空不能单独作为账户权限或签名方式的判断依据。合约钱包的授权规则由其代码定义,可通过 ERC-1271 等接口验证签名。
账户状态包含 nonce、ETH 余额、代码哈希和存储根。ERC-20 余额通常保存在代币合约的映射中,与账户的原生 ETH 余额不同。账户结构与地址派生见账户模型。
交易、Gas 与 ETH
交易包含发送者签名、nonce、目标地址、价值、调用数据及费用参数。nonce 约束同一发送账户的交易顺序;执行失败会回滚业务状态变化,但已经消耗的 Gas 仍需付费。
EIP-1559 交易通过 maxFeePerGas 和 maxPriorityFeePerGas 约束费用。基础费由协议根据区块使用情况调整并销毁;优先费支付给区块的执行层费用接收地址。
在费用上限足以覆盖基础费时,执行 Gas 的有效单价为:
$$ \text{effectiveGasPrice}=\min(\text{maxFeePerGas},\text{baseFeePerGas}+\text{maxPriorityFeePerGas}) $$
Gas 是资源计量单位,ETH 是支付资产,二者不能混为同一单位。1 ETH = 10^{18} Wei,1 Gwei = 10^9 Wei。Blob 交易另有独立的数据费用,见 EIP-4844。
ETH 的供应变化受共识奖励发行和费用销毁共同影响。基础费销毁不保证每个时期的 ETH 总供应都下降。
区块与节点职责
合并后的信标区块包含共识层信息及执行载荷。执行载荷包含交易和执行结果承诺;执行层区块头记录状态根、交易根、收据根等字段。完整状态树和收据树不是逐份嵌入区块的内容。
节点从交易和历史状态计算新状态,并校验其根与区块承诺一致。日志属于执行收据,查询日志与读取合约存储是不同操作。PoW 时代的挖矿和叔块规则属于历史机制。
执行客户端和共识客户端通过 Engine API 协作。验证者客户端承担提议与证明签署;运行普通节点本身不要求质押,参与独立验证者激活则有最低质押要求。
权益证明与最终性
以太坊于 2022 年合并后使用权益证明。主网时间划分为 slot 和 epoch;一个 slot 为 12 秒,一个 epoch 为 32 个 slot。某些 slot 可能没有区块,因此 slot 长度不是每两个实际区块之间必然相同的时间间隔。
验证者根据质押权重参与区块提议和证明。LMD-GHOST 选择链头,Casper FFG 通过检查点投票提供最终性。链头确认与检查点最终确认具有不同含义,机制见 Gasper。
扩容与数据可用性
Rollup 在链外执行交易,并将验证状态转换所需的承诺、证明或数据按其协议发布到结算层。安全性同时依赖证明机制、数据可用性、升级权限和退出路径。
以太坊早期路线图曾讨论多个执行分片。以 Rollup 为中心的扩容强调数据供给,EIP-4844 引入 Blob,后续数据可用性采样等机制继续扩展该能力。执行分片旧规划不能当作已经运行的主网结构。
侧链具有独立的共识和安全假设。Polygon PoS、Gnosis Chain 等侧链不能仅因与以太坊有桥接关系,就视为具有与 Rollup 相同的结算安全性。
主要历史升级
| 升级 | 时间 | 主要变化 |
|---|---|---|
| Frontier | 2015 年 | 主网启动 |
| Byzantium | 2017 年 | 新增椭圆曲线等预编译,支持更高效的证明验证 |
| London | 2021 年 | EIP-1559 基础费与销毁机制 |
| The Merge | 2022 年 | 主网从 PoW 转为 PoS |
| Shanghai / Capella | 2023 年 | 质押提款 |
| Cancun / Deneb | 2024 年 | Blob 交易与瞬态存储等机制 |
| Prague / Electra(Pectra) | 2025 年 | EOA 代码委托与验证者机制调整等变化 |
这些升级分别改变执行或共识规则,不能把某次升级概括为自动提供隐私、无限吞吐量或消除合约风险。
合约生态与开发环境
ERC-20、ERC-721、ERC-1155 分别定义常见代币接口,ERC-4626 定义代币化金库接口。接口兼容性与业务安全、价格可靠性、管理员权限属于不同问题。
开发工具包括 Remix、Hardhat 和 Foundry;链上交互可使用 ethers.js 等库。Sepolia 用于合约和应用测试,Hoodi 用于验证者及质押基础设施测试。Goerli、Holesky 属于已停用的历史测试网络。
参考资料
- Ethereum:账户
- Ethereum:区块
- Ethereum:权益证明
- EIP-1559:交易费用市场
- EIP-7702:EOA 代码委托
- Ethereum:Danksharding
- Ethereum:测试网络
- Ethereum:协议升级历史
账户模型与私钥关联
以太坊的“世界状态“本质上是所有账户状态的集合。账户结构与交易签名、合约权限和调用上下文共同决定状态转换。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 与存储寻址用于构造和验证账户或存储证明。
- 代理分析需要区分代码来源、执行地址和存储归属。
- 客户端及状态转换证明实现需要遵循相同的账户、执行与编码规则。
EVM 与内部状态
EVM(Ethereum Virtual Machine)是以太坊的执行引擎。这里记录它的架构、它眼中的“状态“长什么样、以及一笔交易如何驱动状态迁移。账户如何进入这棵状态树,见 账户模型与私钥。
架构总览
- 基于栈的虚拟机:不是寄存器机。操作数与中间结果都压在栈上,指令从栈顶取参。
- 栈深度上限 1024,栈项固定 256 bit——与 keccak256、
SSTORE等原生操作的字宽一致,也是uint256成为默认类型的根本原因。 - 确定性:同样的输入(状态 + 交易数据)在任何节点上必然得到同样的输出,没有随机数指令、没有系统时间。时间戳与
PREVRANDAO等来自区块上下文,不能直接当作不可操纵的安全随机数。 - 沙箱:EVM 无法访问网络、文件系统、进程,能触碰的只有自己的状态与上下文消息。
数据位置
| 位置 | 生命周期 | 大小 | 访问成本 |
|---|---|---|---|
| Stack | 单次调用 | 1024 项 × 256 bit | 最便宜(部分指令免费) |
| Memory | 单次调用 | 字节寻址,按需扩展,扩展有二次方 gas | 便宜 |
| Storage | 永久,随账户写入状态树 | 每 slot 32 字节 | 昂贵(EIP-2929 下 SLOAD 冷读 2100 gas,写入另行计费) |
| Transient (EIP-1153) | 单笔交易内 | 同 storage 的 slot 模型 | 介于 memory 与 storage 之间 |
| Calldata | 单次调用,只读 | 交易附带的字节数组 | 读取另有指令成本;交易数据费用见 Gas 优化 |
| Log | 永久,但合约不可读 | 事件数据 | 仅 LOG 系列指令写入 |
- Transient storage(Cancun 升级,2024)适合跨调用临时记账(如重入锁),语言层对应
transient关键字,使用与 storage 类似的槽位编号,但拥有独立的存储空间;同编号的持久槽与瞬态槽互不覆盖。瞬态状态按所属合约隔离,DELEGATECALL使用调用者的空间。 - Log 只能由链下索引读取,合约自己也无法读取——事件不是存储。
Memory 扩展的二次方成本
Memory 以 32 字节(word)为单位扩展,访问超出已分配范围的地址会触发扩展并收费:
$$C(a) = 3a + \left\lfloor \frac{a^2}{512} \right\rfloor$$
其中 \(a\) 是扩展后所需的 word 数。二次方项让海量内存的成本急剧上升——这是对“恶意合约撑爆节点内存“的定价防御,也是大数组应尽量留在 calldata 的原因。
冷/热访问:Access Set(EIP-2929)
Berlin 起,EVM 为每笔交易维护 accessed_addresses 与 accessed_storage_keys 两个集合:
- 冷读:首次访问未入集合的地址或 storage slot,昂贵——
SLOAD冷读 2100 gas。 - 热读:已入集合后的再次访问,便宜——
SLOAD热读仅 100 gas。
同一交易内对同一 slot 的反复访问(如重入场景)从第二次起都走热读价格。
状态模型
以太坊执行层使用 Merkle Patricia Trie 组织账户、交易和回执。区块头直接记录 stateRoot、transactionsRoot 与 receiptsRoot;各账户的存储根通过账户状态间接承诺:
-
World State Trie:
地址 → 账户的映射。账户节点即账户状态四元组。 -
Storage Trie:每个合约账户一棵,逻辑上是
slot → 32 字节值的稀疏映射,Trie Key 取keccak256(slot),根哈希就是账户的storageRoot。slot 空间名义上 2²⁵⁶,实际是稀疏的——没有写过的 slot 根本不存在。协议与物理层的完整拆解见持久键值存储。 -
Transactions Trie:区块内交易列表。
-
Receipts Trie:每笔交易的执行结果(状态码、gas 消耗、Log)。
-
状态的“修改“实际上是新建节点、自底向上重算哈希,旧节点在短期内仍可被引用——这就是历史状态查询(如
eth_getStorageAt指定旧区块)的实现基础。
回滚的实现:Journal(撤销日志)
“revert 状态回滚“在节点内部并不靠重建或拷贝 Trie:
- 执行期间,节点(如 Geth)在内存中维护一个 Journal:每次
SSTORE、余额变更、账户创建都追加一条逆向操作(“把 slot A 改回旧值 V”)。 REVERT或异常时,从 Journal 倒序弹出即可恢复状态,复杂度 \(O(k)\)(\(k\) 为本次变更条数),与 Trie 规模无关。- 交易执行结果先进入客户端的状态缓存;状态根计算与数据库持久化由客户端在区块处理和提交过程中完成。具体存储实现不属于 EVM 指令语义。
状态膨胀与 Trie 演进
- World State 节点已达数十亿级,MPT 深度变长带来 I/O 放大,witness(状态证明)也随之膨胀。
- 演进方向是 Statelessness:用 Verkle Tree(多项式承诺)或 Binary Trie 替换 MPT,把 witness 压缩到无状态节点可验证的量级——节点只需验证证明,不再存储全量状态。
一笔交易的执行流程
- 交易进入内存池(交易池与网络层),节点验证签名(ecrecover),nonce、余额、gas 合法性检查。
- 打包进区块后按序执行:从发送者扣 gas 预付款,nonce +1。
to为空时走顶层合约创建,地址由发送者与 nonce 决定;否则加载目标账户代码,在新的调用帧中执行。CREATE2是合约内部执行的部署指令。- 执行过程中
SSTORE/SLOAD读写 storage trie,产生 Log,可能内部调用其他合约(同一交易内)。 - 正常结束:剩余 gas 按协议退款;revert:撤销执行产生的状态修改与日志,但发送者的 nonce 增量和已消耗 gas 的费用仍保留。普通
REVERT会退还未消耗的 gas,不能与耗尽 gas 的异常混为一谈。 - 结果写入 Receipts Trie,所有 Log 与状态变更最终汇集为新的 World State Root。
调用指令的上下文矩阵
第 4 步的“内部调用“由不同的调用指令驱动。每条指令都会压入一个新的调用帧(Call Frame),对上下文的继承各不相同:
| 指令 | 执行的代码 | address(this) | msg.sender | Storage | msg.value |
|---|---|---|---|---|---|
CALL | 目标合约 | 目标地址 | 调用者 | 目标合约 | 显式传递 |
CALLCODE(已废弃) | 目标合约 | 调用者 | 调用者 | 调用者 | 显式传递 |
DELEGATECALL | 目标合约 | 调用者 | 沿用上一调用帧的 sender | 调用者 | 沿用上层 |
STATICCALL | 目标合约 | 目标地址 | 调用者 | 目标合约 | 强制为 0 |
STATICCALL(EIP-214)会置起 read-only 标志:此状态下触发任何状态变更指令(SSTORE、CREATE、LOG0-LOG4、SELFDESTRUCT、TSTORE)都会直接异常终止。
Opcodes 概览
- 分类:算术(
ADD/MULMOD…)、比较与位运算、栈与内存(PUSH/DUP/MLOAD)、存储(SSTORE/SLOAD/TSTORE)、环境(CALLER/GASPRICE)、调用(CALL/DELEGATECALL/STATICCALL)、终止(RETURN/REVERT/SELFDESTRUCT)。 - 完整清单与各 EIP 版本的 gas 对照:evm.codes
DELEGATECALL的上下文共享语义是重入攻击与代理合约(见 USDC 代理合约)共同的基础。
字节码的演进:EOF(EVM Object Format)
Legacy 字节码是一串无格式的操作码流:PUSH 携带的数据与可执行指令难以区分,栈深检查只能留到运行时。EOF(EIP-3540 系列)曾计划用结构化容器解决:
- 字节码划分为 Header、Code Section(纯指令)与 Data Section(只读数据,取代
CODECOPY夹带部署数据的惯例)。 - 禁止
JUMP跳到任意字节偏移,只允许跳到指令边界。 - 部署期即可静态验证最大栈深,消除运行时 1024 栈检查的部分开销。
不过 EOF 的落地进程一再推迟,2025 年 4 月已从 Fusaka 升级中移除,主网至今仍执行 legacy 字节码——其设计思想仍是理解 EVM 演进方向的参照。
参考资料
交易池与网络层
一笔交易从签名到被 EVM 执行之间还有一段完全链下的旅程:先经 P2P 网络传播到全网节点,再在各节点的内存池里排队等待打包。这段路程决定了一笔交易的确认速度与可见性,也是 MEV 的发源地。交易进入执行引擎之后发生了什么,见 EVM 与内部状态。
P2P 网络:交易如何流动
以太坊节点的组网由三层协议叠成:
- 节点发现(discv4):跑在 UDP 上的 Kademlia 风格分布式哈希表。新节点从内置的 bootnodes 入网,逐跳交换邻居信息,最终维持几百个对等连接。
- 传输层(RLPx):TCP 上的加密多路复用协议,握手完成后所有业务数据都走这条加密通道。
- 应用层(eth 协议):定义“说什么“。交易的传播采用“先广播哈希、按需拉全文“:节点收到新交易并验证通过后,向部分 peers 发送
NewPooledTransactionHashes(哈希 + 类型 + 大小);对方发现自己缺这笔,再用GetPooledTransactions取回全文——全文只在必要时传输,控制带宽。
合并(The Merge)后区块的产生与传播由共识层主导:出块者经 Engine API 把执行 payload 交给执行层,执行层再经 eth 协议向 peers 同步。交易与区块的路径不同,但共用同一套 devp2p 底座。
关键性质:gossip 即公开。交易一旦走上公共传播路径,数十秒内全网可见——这是私有内存池存在的根本原因。
交易池(TxPool)
交易池是节点内存里的“待办队列“。它不属于共识协议,只是节点实现的好意,各客户端细节不同(下以 Geth 为准):
- 入池预检:签名(ecrecover)、chainID、nonce、余额、gas 上限全部合法才收——进不了池的交易不会被继续广播。
- pending / queued 两个子池:pending 收纳“当前就能执行“的交易(nonce 顺序接得上、余额足够);nonce 有空洞或余额不足的进 queued,缺口补上后升入 pending。出块方只从 pending 按序打包。
- 排序规则:同一账户严格按 nonce 串行;不同账户按 gas price 竞争——价高者先入块,这是“加价插队“的机制基础。
- 替换(replace-by-price):同账户同 nonce 的新交易可以顶替旧交易,但 gas price 必须高出一定比例(Geth 默认 10%),防止零成本刷池。
- 驱逐:池有容量上限,满了优先驱逐最“不值钱“的远端交易(price 最低、nonce 最靠后的)。本地(local)交易享受豁免:不驱逐、替换无需加价。
- 无强制保证:池是节点的好意而非协议义务——gas price 跌出队伍的交易可能被驱逐后再无人重新广播,只能由用户重新提交。
公共池的透明性问题
公共池人人可见,机器人因此能做一件普通用户做不到的事:在交易上链前模拟它。模拟出高利润就复制交易、出更高 gas price 抢先上链——抢跑(frontrunning)由此而生;围绕同一笔 DEX 交易做“前置买入 + 跟随卖出“则构成三明治(sandwich)攻击。二者都不是合约漏洞,而是内存池透明性与公开排序规则的直接产物,与授权竞争攻击同属“交易可见性“这一攻击面。
私有内存池
思路是绕开公共 gossip,把交易只交给少数可信的出块参与者:
- 私有 RPC(如 Flashbots Protect):交易直接发给 builder 而非广播全网,上链前不出现在任何公共池里;配合 MEV-Share 还可以选择只向 builder 回传加密提示,backrun 收益与用户分成。
- Bundle:若干交易打包成一个原子单元,要么全部上链要么全部不上链;模拟失败的 bundle 直接丢弃,用户不消耗 gas。
- 信任边界:私有是“防搜索者“,不是匿名——builder 看得到全部内容。把交易交给谁,就是把模拟与排序的机会交给谁。
MEV-Boost:出块权与出块内容的分离
合并后以太坊引入提议者-构建者分离(PBS):builder 专职构建区块并竞价,relay 负责中转与验证报价,验证者(proposer)只挑标价最高的区块头签名——出块权与出块内容解耦。公共池交易与私有 bundle 最终都汇入 builder 的竞价,MEV 从“矿工的隐性收入“变成 builder 市场上的显式拍卖。Builder 生态与隐私通道的协议细节见 Flashbots 文档。
合约开发
Solidity
Solidity 是面向 EVM 的智能合约语言,编译结果为字节码及 ABI。状态存储、调用上下文和回滚语义共同决定程序行为,语法正确或编译成功不等同于业务安全。
语言与执行机制
- 值类型、引用类型和函数可见性定义数据及接口边界。
storage、memory、calldata区分数据位置;映射与数组寻址遵循存储布局。- 事件写入执行日志,错误和
revert影响调用结果及状态回滚。 - 继承、接口和库组织合约代码;
CALL与DELEGATECALL具有不同执行上下文。 - ERC 接口规定交互约定,权限、代理升级及 Gas 成本需要分别分析。
状态转换示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public value;
event Increased(address indexed caller, uint256 value);
function increment() external {
value += 1;
emit Increased(msg.sender, value);
}
}
value 写入持久存储,msg.sender 来自当前调用帧,事件写入日志。日志可由链外索引和查询,合约不能直接读取过去的事件日志。
合约行为检查
- 编译器、优化器和目标 EVM 版本与执行环境一致。
- 外部输入满足相应边界和权限约束。
- 时间、区块和外部报价的使用符合业务假设。
- 升级保持既有存储布局和状态含义兼容。
- 状态转换验证覆盖回滚、事件、授权和外部回调路径。
状态变量存储布局 (Storage Layout)
EVM 层面,storage 是每个合约账户一棵 slot → 32 字节值 的稀疏树(见 EVM 与内部状态)。Solidity 编译器的工作是决定状态变量如何映射到这些 slot。这条映射规则不是语言细节——代理合约的存储冲突、Gas 优化、forge inspect 的布局检查都建立在这套规则之上。
参考来源:Solidity 官方文档 · Layout of State Variables in Storage。
基本规则与打包
- 变量从 slot 0 开始按声明顺序分配,逐个往后排。
- 长度不足 32 字节的类型(
uint128、bool、address…)会与相邻变量打包进同一个 slot,从 slot 的低位开始放置(这不表示整个 EVM 采用小端字节序)。 - 打包的前提是放得下:任何变量都不会跨 slot。放不下就换新 slot,剩余空间作废。
- 一个 slot 里多个变量时,gas 上省的是一次冷
SLOAD(2100),但每次写都要读写整个 slot——“读改写“成本需要权衡,这也是变量优化的出发点。
contract SlotLayout {
uint256 a; // slot 0:独占一个 slot
uint128 b; // slot 1:低 16 字节
uint128 c; // slot 1:高 16 字节(与 b 打包)
address d; // slot 2:低 20 字节
bool e; // slot 2:字节 20(与 d 打包)
uint256 f; // slot 3:独占(不与 slot 2 的剩余空间拼合)
}
mapping 与动态数组的定位
两者都为声明本身保留一个完整槽 p,实际元素通过哈希派生位置存放:
- mapping:键
k的值位于keccak256(h(k) . p),p是 mapping 声明所在的 slot 序号,.拼接。h按键类型做定宽填充(uint等补齐 32 字节;string/bytes使用未经填充的原始字节,不先单独哈希)。 - 动态数组:
p存数组长度;对于uint256[],第i个元素位于keccak256(p) + i;较小的元素会在槽内打包,多槽结构体则按自身布局计算步长。 bytes/string特例:长度 ≤ 31 字节时数据与(长度 × 2)一起内联在 slotp;更长时 slotp存长度 × 2 + 1,数据从keccak256(p)开始连续存放。
contract Locator {
mapping(address => uint256) balances; // 声明于 slot 0
uint256[] items; // 声明于 slot 1
function balanceSlot(address who) public pure returns (bytes32) {
return keccak256(abi.encode(who, uint256(0)));
}
function itemSlot(uint256 i) public view returns (bytes32) {
return bytes32(uint256(keccak256(abi.encode(uint256(1)))) + i);
}
}
这套定位规则正是 forge inspect <contract> storage-layout、Foundry 的 stdStorage 以及各种链上存储读取工具的工作原理。
继承下的布局顺序
- 状态变量的 slot 分配遵循 C3 线性化:从最基础的合约开始,按继承链顺序依次分配,同一合约内按声明顺序。
- 基类与派生类的变量可能共享一个槽。升级风险来自改变已有字段类型、顺序或继承布局,而不是来自把升级函数放在基类。
immutable 与 constant
constant:编译期内联进字节码,不占任何 slot。immutable:也不占 slot,构造结束时把值复制进运行时字节码(PUSH 指令)。代理调用也会读取实现合约字节码中的同一值,不能把它当作每个代理独有的可变状态。
与代理合约的关系
- 代理模式下,代理合约与逻辑合约共享同一套 storage,两边各自的 slot 0 起步布局可能冲突。
- EIP-1967 用“哈希值减一“的 slot(如
keccak256("eip1967.proxy.implementation") - 1)把 implementation 地址挪出常规空间,避开冲突。 - 更彻底的方案是存储分离(如 EIP-2535 Diamond 或自定命名空间存储)。
- 实例分析:USDC 代理合约。
布局检查工具
forge inspect <Contract> storage-layout:输出每个变量的 slot、偏移、类型。solc --storage-layout:编译器原生 JSON 输出,适合 CI 中对比升级前后布局是否兼容。
智能合约 Gas 优化总览
在以太坊虚拟机(EVM)中,计算与存储资源受到严格的物理约束,用户执行交易必须为其消耗的计算步骤(Opcodes)和存储状态支付 Gas 费用。合理的 Gas 优化不仅能够大幅降低终端用户的交互成本,还能提升系统抵御拒绝服务(DoS)的韧性,并精简合约部署字节码体积(Contract Size)。
然而,Gas 优化并非盲目追求指令削减,必须在安全性、可维护性、复杂度与经济性之间取得严格平衡。
优化哲学:空间换时间与业务确定性
在合约设计中,“浪费”有时恰恰是最高维度的优化。
最经典的工程案例莫过于 Uniswap V2 Factory 的币对索引设计:
function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS');
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IUniswapV2Pair(pair).initialize(token0, token1);
// 双向存储记录
getPair[token0][token1] = pair;
getPair[token1][token0] = pair; // 反向冗余记录
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
架构权衡解析
- 物理上的“浪费”:对于每个币对,
getPair映射中同时存储了(token0, token1)和(token1, token0)两个方向的引用,多消耗了一次SSTORE存储槽。 - 全局上的“优化”:所有外部调用者与路由器在查询币对流动性时,完全无需在链上先执行一次大小排序比较,直接使用
getPair[tokenA][tokenB]即可常数级查出,将计算复杂度从所有的查询端转移并消除,极大降低了整个生态系统的交互复杂度与集成 Gas。
模块导读索引
本专题将 Gas 优化划分为五个循序渐进的工程模块:
- 变量打包与存储优化:EVM 256 位槽位打包规则、结构体字段对齐、存储数据位置(
storagevsmemoryvscalldata)的开销对比。 - 循环展开与边界优化:数组长度缓存、
unchecked计数器、内存指针连续遍历,以及破除所谓的“笛卡尔积扁平化”伪优化陷阱。 - 算术运算与位操作优化:移位替代 2 次幂乘除、常量折叠与编译期预计算、代数等价防溢出比较、单字位图(Bitmask)状态压缩。
- 内联汇编 (Yul) 极致优化:复用草稿空间实现零分配哈希、4 字节 Custom Error 极简回滚、Optimism
SafeCall内存隔离防炸弹、SoladySafeTransferLib全兼容极速代币划转。 - 内存扩张与瞬态存储 (EIP-1153):内存二次方扩展成本推导、避免循环内动态内存泄漏、
TSTORE/TLOAD100 Gas 重入锁、Uniswap V4 单例闪电记账(Flash Accounting)。
针对数据类型和数据结构的优化
在 Solidity 中,合理选择数据结构和优化数据大小可以显著降低 Gas 消耗。以下是一些关键优化策略:
基本常识:
-
变量存储槽 (Storage Slot) 大小: 在以太坊的 EVM (以太坊虚拟机) 中,用于存储合约状态变量的存储空间被划分为一个个大小为 256 位 (32 字节) 的存储槽。每个状态变量都会被分配到一个或多个连续的存储槽。
-
变量三种声明 (数据位置 - Data Location): 在 Solidity 中,变量可以声明在三个不同的数据位置,这决定了变量的生命周期和存储方式:
-
memory:- 用于存储函数调用期间的临时变量。
- 当函数执行结束时,
memory中分配的内存会被自动释放。 memory中的变量是不可持久化的,合约的状态不会保存在memory中。- 函数参数(除了
storage类型)、局部变量以及使用new关键字创建的动态类型(如动态数组、bytes和string)默认声明在memory中。
-
calldata:- 用于存储函数调用的输入数据。
- 类似于
memory,calldata中的数据也是临时的,在函数调用结束后失效。 calldata主要用于接收外部函数调用的参数,特别是结构体或动态数组等复杂类型。calldata是只读数据位置,不允许直接修改;在适合的访问模式下可避免复制到 memory。- 外部函数的参数默认声明在
calldata中。
-
storage:- 用于存储合约的状态变量。
storage中的数据是持久化的,会存储在区块链的状态中,并且在合约的所有函数调用之间都保持不变。- 访问和修改
storage中的数据通常比memory和calldata更昂贵 (消耗更多的 Gas)。 - 在合约中直接声明的变量(在函数外部)默认声明在
storage中。
-
总结一下它们的主要区别:
| 特性 | memory |
calldata |
storage |
|---|---|---|---|
| 存储位置 | EVM 的临时内存 | 函数调用的输入数据 | 区块链的状态存储 |
| 生命周期 | 函数调用期间 | 函数调用期间 | 合约的整个生命周期 |
| 持久性 | 非持久化 | 非持久化 | 持久化 |
| 可修改性 | 可读写 | 只读 | 可读写 |
| Gas 成本 | 相对较低 | 相对较低 | 相对较高 |
| 默认用于 | 函数局部变量、new 创建的动态类型 |
外部函数参数 | 合约状态变量 |
基本数据类型优化
使用最小够用的数据类型:
uint8而非uint256(当数值范围允许时)bytes1到bytes32而非bytes或string(对于固定长度数据)
变量打包:
- 变量打包机制:EVM 会按照合约声明的变量顺序进行打包,一个存储槽无法将对应变量装入时会新增存储槽
核心打包规则
-
顺序严格按声明顺序
EVM 不会自动重新排序变量,而是严格按照结构体或合约中的定义顺序,从左到右、从上到下尝试打包。 -
能打包的条件
- 当前存储槽剩余空间 ≥ 变量大小
- 变量类型允许打包(动态数组、映射等复杂类型不能打包)
-
打包失败时
如果当前槽剩余空间不足,变量会占用一个全新的存储槽(即使它本身很小)。
实际案例对比
案例 1:高效打包(节省 Gas)
struct Efficient {
uint64 a; // 64位 → 槽0 [0-64)
uint64 b; // 64位 → 槽0 [64-128)
uint256 c; //发现槽0无法存储 占用全新存储槽 256位 → 槽1 [0-256)
// 总计:2个存储槽
}
案例 2:未优化顺序(浪费 Gas)
struct Inefficient {
uint64 a; // 64位 → 槽0 [0-64)
uint256 b; // 256位 → 槽0剩余192位不够,新开槽1
uint64 c; // 64位 → 槽2(因为槽1已被占满)
// 总计:3个存储槽!
}
案例 3:动态类型无法打包
struct WithDynamic {
uint64 a; // 槽0 [0-64)
string name; // 动态类型,强制新开槽1
uint64 b; // 槽2(因为槽1被string占用)
// 总计:3个存储槽
}
关键优化技巧
-
降序排列变量
将uint256、bytes32等大类型放在前面,小类型(uint64、bool等)放在后面,减少“碎片“。- 此处作为功能开发完全后进行的操作,通过变更数据位置而进一步优化。至于能否由小到大排序,自然也是可以的。不过这样要精确计算各变量能否组合,共同放入槽内,不如由大到小最后交给编译程序判断。
-
避免穿插动态类型
动态数组、映射、string会强制新开槽,尽量将它们放在结构体末尾。 -
显式使用
uint打包
如果变量逻辑相关,可以手动位操作打包到uint256中(极端优化场景用)。 -
继承结构体的顺序
父合约的变量优先存储,子合约的变量后续存储,需整体考虑打包。
数据结构选择
-
数组优化:
- 对于小型固定集合,使用固定大小数组
uint[10]而非动态数组uint[] - 考虑使用
bytes32[]而非uint256[]如果数据可以适应
- 对于小型固定集合,使用固定大小数组
-
映射 vs 数组:
- 映射(
mapping)通常比数组更省 Gas(特别是稀疏数据时) - 但需要遍历时数组更高效
- 映射(
-
高效的结构体设计:
struct GasEfficient { uint32 time; uint64 value; address user; // 160位 // 总共256位,正好一个存储槽 }
存储布局优化
-
变量顺序:
- 将一起使用的变量声明在一起,帮助编译器打包
- 将
uint和bytes32等 256 位类型放在前面
-
存储与内存:
- 尽可能使用
memory而非storage(内存操作更便宜) - 对临时变量使用
calldata(最便宜)
- 尽可能使用
高级技巧
-
位压缩:
uint256 packedData; function setData(uint16 a, uint16 b, uint16 c) internal { packedData = uint256(a) << 32 | uint256(b) << 16 | uint256(c); }
- 对于追求低存储空间占用的程序尤为有效。可以以同一个存储槽通过各个位级操作记录不同的信息。
- 比如 USDC 中占用最高位的 blacklist。在账户余额存储占用有限的状况下可以节省 1bit 作为账号是否拉黑的标记,而不用多生成一个 mapping。(详见代码分析篇)
-
使用 EVM 字长:
- EVM 处理 256 位(32 字节)最有效
- 即使小数据类型也会占用 256 位存储(除非打包)
-
替代数据结构:
- 考虑使用 Merkle 树或 Patricia 树处理大量数据
- 对于枚举类型,使用
uint8足够
实际应用示例
// 优化前
struct User {
string name; // 昂贵
uint256 balance;
uint256 lastActive; // 时间戳不需要uint256
}
// 优化后
struct OptimizedUser {
bytes32 name; // 固定长度
uint128 balance; // 足够表示大多数代币金额
uint64 lastActive; // 足够表示到公元3000年
}
记住:在优化时要权衡 Gas 节省与代码可读性/维护性,过度优化有时会导致代码难以理解和维护。
循环与迭代优化
在以太坊虚拟机(EVM)中,循环结构(for / while)是 Gas 消耗的放大器。由于 EVM 按照指令逐步计费,且循环体内部的每一次迭代都会重复执行条件判断、跳转与状态读取,未经优化的循环极易引发巨额 Gas 浪费甚至触及区块 Gas 限制(Block Gas Limit)导致合约瘫痪。
循环成本由每次迭代的操作、迭代次数和编译器优化共同决定。常数开销优化与算法复杂度变化需要分别比较。
典型伪优化陷阱:所谓的“嵌套循环扁平化(笛卡尔积合并)”
在传统高层语言(如某些 JIT 优化的 Web 运行时)中,有时会通过将两层嵌套循环压缩为单层循环来减少循环边界检查。然而,在 EVM 底层,这种做法是严重的负优化(Gas Regression 陷阱)。
1. 常见错误写法对比
// 方案 A:标准双层循环 (10 x 10 = 100 次迭代)
for (uint256 i = 0; i < 10; ) {
for (uint256 j = 0; j < 10; ) {
// 核心业务处理
unchecked { ++j; }
}
unchecked { ++i; }
}
// 方案 B:所谓的“扁平化优化”(错误观念)
for (uint256 k = 0; k < 100; ) {
uint256 i = k / 10; // 严重负优化:每次迭代强制引入 DIV 运算
uint256 j = k % 10; // 严重负优化:每次迭代强制引入 MOD 运算
// 核心业务处理
unchecked { ++k; }
}
2. EVM 底层操作码成本真实核算
方案 A 与方案 B 的差异可按生成的指令和访存操作比较:
- 方案 A(双层嵌套):
- 外层循环重置内层计数器
j = 0仅发生 10 次(10 次极低成本的堆栈压栈操作,约 30 Gas)。 - 跳转与累加指令均在栈顶或局部变量就地完成,总开销平稳。
- 外层循环重置内层计数器
- 方案 B(扁平化单层循环):
- 在整整 100 次迭代中的每一次,都必须强制执行
k / 10与k % 10! DIV与MOD操作码在 EVM 中各自消耗 5 Gas,配合参数压栈与栈置换(DUP,SWAP,POP),每次迭代额外引入至少 30-40 Gas 的无谓计算开销。- 结果核算:100 次迭代下来,方案 B 比方案 A 额外多消耗了 3,000 至 4,000 Gas!同时使原本直观的双重坐标遍历变得极其晦涩。
- 在整整 100 次迭代中的每一次,都必须强制执行
工程准则:严禁为了盲目消除一层循环而引入高阶的运行时除法与求模运算。
真实有效的循环工程优化范式
1. 数组长度缓存 (Cache Array Length)
在读取动态数组(memory 或 storage)进行遍历时,Solidity 高级语法在每次循环条件判断中若直接书写 i < arr.length:
- 若
arr位于storage:每次迭代强制执行一次SLOAD(冷读 2100 Gas / 暖读 100 Gas),构成严重的存储浪费。 - 若
arr位于memory:每次条件判断都会触发一次MLOAD读取长度字段。
// ✅ 正确范式:提前将长度缓存至栈变量中
function processMemory(uint256[] memory arr) external pure {
uint256 len = arr.length; // 一次 MLOAD 读入寄存器
for (uint256 i = 0; i < len; ) {
_doSomething(arr[i]);
unchecked { ++i; } // 关闭自增溢出校验
}
}
2. 循环自增使用 unchecked { ++i; }
在 Solidity 0.8 之后,算术操作默认开启溢出断言。对于循环计数器 i 而言,只要循环边界被限定在合理的数值范围(如 len < 2**256 - 1),自增操作在数学上绝不可能发生溢出。
- 普通
i++:编译后会插入额外的溢出检查分支与条件回滚指令,单次消耗约 20-30 Gas。 unchecked { ++i; }:仅生成纯粹的ADD与压栈指令,单次仅消耗约 5 Gas。前置自增++i在部分旧版编译器中比后置自增i++略省临时变量压栈。
3. Yul 汇编内存批量遍历 (Assembly Array Traversal)
Solidity 访问 arr[i] 时,编译器会在每次数组下标寻址时隐式插入一段边界安全检查指令(若 i >= arr.length 则触发 Panic)。在明确保证安全的只读计算中,使用内联汇编直接操作内存指针可完全抹去该开销:
function sumAssembly(uint256[] memory arr) public pure returns (uint256 total) {
assembly {
let len := mload(arr)
let dataPtr := add(arr, 0x20) // 跳过前 32 字节长度字段
let endPtr := add(dataPtr, mul(len, 0x20))
for {} lt(dataPtr, endPtr) { dataPtr := add(dataPtr, 0x20) } {
total := add(total, mload(dataPtr))
}
}
}
- 优化原理:消除每轮的边界大小比较与算术乘法偏移量计算,指针线性向后单调步进(步长 32 字节),充分利用 CPU 预取与 EVM 连续内存读取优势。
4. 循环展开 (Loop Unrolling)
对于确定且迭代轮次极小(如 3 - 5 轮)的计算逻辑,手动展开循环:
// 手动展开固定 4 轮的数据处理
function processFour(bytes32[4] memory data) internal pure {
_processItem(data[0]);
_processItem(data[1]);
_processItem(data[2]);
_processItem(data[3]);
}
- 收益:彻底消除计数器更新、条件判定及跳转指令(
JUMP/JUMPI),消除了循环控制流本身的额外开销。
终极架构设计:从循环遍历转向映射与链下证明
在智能合约架构设计中,避免循环的价值永远大于优化循环:
- 状态查询:映射(
mapping)优于数组遍历:- 数组寻找目标需要 \(O(N)\) 遍历,而映射提供确定性的 \(O(1)\) 常数级哈希寻址。
- 批量结算:链下计算 + 链上 Merkle 证明:
- 大规模白名单分发、空投、代币申领严禁在链上通过循环遍历数组。
- 应在链下将所有资格树归纳为一颗 Merkle 树,链上仅保存 32 字节的
merkleRoot,用户提取时提交单一 Merkle Proof 进行原子验证,将 \(O(N)\) 循环彻底降维为 \(O(1)\) 验证。
参考文献与规范
算术运算与位操作优化
在以太坊虚拟机(EVM)中,不同操作码(Opcodes)在计算与执行资源上的消耗存在显著差异。
乘除、条件分支和求幂的执行成本由生成的字节码决定。数学与位操作优化需要同时保持数值语义,并在固定编译配置下比较实际 Gas。
EVM 算术操作码 Gas 消耗参考表
根据 EVM Codes 现行计费规范,常用算术与位操作的单次执行基础成本如下:
| 操作码类别 | 具体操作码 | 执行 Gas 开销 | 语义与物理操作 |
|---|---|---|---|
| 位级移位 (Shift) | SHL, SHR, SAR | 3 Gas | 二进制左移、逻辑右移、算术右移 |
| 基础逻辑 (Logic) | AND, OR, XOR, NOT | 3 Gas | 按位与、或、异或、取反 |
| 加减运算 (Add/Sub) | ADD, SUB | 3 Gas | 256 位模加法与模减法 |
| 高阶乘除 (Mul/Div) | MUL, DIV, SDIV, MOD | 5 Gas | 256 位高精度乘除与求模 |
| 指数求幂 (Exp) | EXP | 10 Gas + 50 Gas/字节 | 动态阶梯计费(指数所占字节越多越昂贵) |
2 的幂次方:位移替代乘除法
对于涉及 2 的整数幂(\(2, 4, 8, 16, \dots\))的无符号整数运算,移位可能降低指令成本。先确认语义,再在相同编译器、优化器与 EVM 目标下测量;优化器可能已完成替换:
1. 乘法向左移位 (SHL)
// 原生乘法:消耗 MUL (5 Gas)
uint256 y = x * 8;
// 只有确认 x <= type(uint256).max / 8 时,才与上面的受检乘法等价
uint256 y = x << 3;
2. 除法向右移位 (SHR)
// 原生除法:消耗 DIV (5 Gas)
uint256 y = x / 4;
// 对 uint256,右移 2 位与除以 4 的取整结果一致
uint256 y = x >> 2;
注意与边界:Solidity 0.8+ 的乘法默认检查溢出,左移则截断高位而不检查溢出;不能无条件把
x * 8换成x << 3。有符号负数的右移向负无穷取整,除法向零取整,也不能直接替换。
指令成本与完整成本:位移操作仅适用于非负无符号整数(
uint256),且不可将任意非 2 次幂拆分(例如 \(x \times 3\) 拆分为 \(x + (x \ll 1)\) 会引入额外的ADD,总开销 \(3 + 3 = 6 \text{ Gas}\),反而高于直接MUL的 5 Gas)。
常量折叠与编译期预计算 (Constant Folding)
常量表达式可以在编译期求值。下面两种秒数写法都会折叠为相同常量;是否收费还取决于调用、返回与完整字节码,不能称为“0 运行时 Gas”:
// 两种写法都能在编译期折叠,不会每次执行乘法
uint256 public constant SECONDS_PER_DAY = 24 * 60 * 60;
uint256 public constant SECONDS_IN_DAY = 86400;
// 内建属性直接表达类型上限
uint256 public constant MAX_UINT = type(uint256).max;
比较操作与除法防溢出
在涉及乘除混合的大小比较判断时,应通过代数等价变形消除潜在的算术溢出与 Revert 异常:
// ❌ 存在中间乘法溢出导致业务意外 Revert 的风险
if (a * b > c) {
// 若 a * b 超过 2^256 - 1,Solidity 0.8 会直接报错中断
}
// ✅ 代数等价变换:先除后比较,消除溢出隐患(需保证 b > 0)
if (a > c / b) {
// 逻辑安全且避免了乘法异常中断
}
多属性位压缩与掩码提取 (Bit Packing & Bitmasks)
在追求极致存储密度的场景下,利用位运算将多个布尔标记或离散枚举压缩进同一个 256 位字中,是大幅减少 SSTORE 槽位占用的关键武器:
// 单一 uint256 管理多个状态标志的简化示例,与 USDC 实现不同
uint256 private _stateFlags;
uint256 private constant FLAG_PAUSED = 1 << 0; // 第 0 位:暂停标记
uint256 private constant FLAG_BLACKLIST = 1 << 1; // 第 1 位:黑名单标记
uint256 private constant FLAG_INITIALIZED = 1 << 2;// 第 2 位:初始化标记
// 设置标记 (Set Flag)
function setPaused(bool paused) internal {
if (paused) {
_stateFlags |= FLAG_PAUSED;
} else {
_stateFlags &= ~FLAG_PAUSED;
}
}
// 查询标记 (Check Flag)
function isPaused() internal view returns (bool) {
return (_stateFlags & FLAG_PAUSED) != 0;
}
参考文献与规范
内联汇编 (Yul) 极致优化与工业库实战
在 Solidity 高级语言层面,编译器为了保证类型安全与内存边界规范,在函数调用、参数打包、哈希运算及错误回滚中隐式注入了大量边界断言(Sanity Checks)与内存冗余复制。
在吞吐量密集、调用频次极高的系统关键路径(如 Uniswap V4、Seaport、跨链桥、代币路由)中,采用**内联汇编(Inline Assembly / Yul)**直接操作 EVM 堆栈、内存指针与调用原语,能够大幅降低执行开销。
相关实现包括 Optimism 的 SafeCall.sol 与 Solady 的内存和调用工具。Yul 优化需要显式维护内存、返回数据和异常处理约束。
零分配哈希运算 (Zero-Allocation Hashing)
在 Solidity 中计算两变量的哈希通常编写为:
// 高级语法:需分配新的内存缓冲区,更新自由内存指针
bytes32 digest = keccak256(abi.encode(a, b));
abi.encode 会读取 0x40 处的自由内存指针(Free Memory Pointer),将 a 与 b 依次写入新分配的内存字,并推进自由内存指针。这不仅产生了额外的 MLOAD/MSTORE 开销,而且由于内存单向扩充,会累加后续内存扩展的二次方成本。
工业级汇编实现:复用草稿空间 (Scratch Space)
EVM 内存布局的前 64 字节(0x00 - 0x3f)被规定为临时草稿空间(Scratch Space),用于哈希运算等短期计算,不属于常规内存分配区。
function efficientHash(bytes32 a, bytes32 b) internal pure returns (bytes32 result) {
assembly {
// 直接就地覆写 0x00 - 0x3f 草稿空间,零内存分配
mstore(0x00, a)
mstore(0x20, b)
result := keccak256(0x00, 0x40)
}
}
- Gas 收益:节省约 60 - 120 Gas。
- 安全约束:该区域随时可被重写,仅用于纯临时计算,严禁在跨外部调用或跨内部函数调用的上下文中假设该空间的值持久有效。
自定义紧凑错误回滚 (Custom Error Revert via Yul)
带字符串的高级 require(condition, "Insufficient balance") 在编译后会生成包含字符串长度、偏移量及完整 UTF-8 字节的庞大 ABI 编码载荷。
Solidity 0.8.4+ 引入了自定义错误(Custom Error),但在高级语法中调用 revert CustomError(arg1) 依然会在内存中开辟动态空间。
汇编极简回滚
对于无参数的自定义错误(如 error Unauthorized()),其 4 字节选择器可直接借助草稿空间就地回滚:
error Unauthorized(); // 假设选择器为 0x82b42900
function checkOwner(address owner) internal view {
assembly {
if iszero(eq(caller(), owner)) {
// 将 4 字节选择器左对齐存储至 0x00
mstore(0x00, 0x82b42900)
// 从 0x1c 开始截取 4 字节直接回滚
revert(0x1c, 0x04)
}
}
}
- 二进制精简:避免引入多余的
revert模板代码,显著缩减合约部署字节码体积(Contract Size)。
绕过 ABI 编码的低级调用:Optimism SafeCall 案例
在跨合约调用中,标准写法 target.call(payload) 会执行一次由 Solidity 管理的内存复制,并在调用返回时将返回数据再次无截断地拷贝进主调合约内存(极易遭受 Return Bomb 拒绝服务攻击)。
Optimism SafeCall.sol 核心实现
以太坊 Optimism 核心跨链桥与 L1 门户在执行外部调用时,全面采用底层 Yul 封装:
library SafeCall {
/**
* @notice 执行安全低级调用,并限制返回数据的内存复制深度
*/
function callWithMinGas(
address target,
uint256 minGas,
uint256 value,
bytes memory payload
) internal returns (bool success) {
assembly {
// 校验剩余 Gas 是否满足最低执行门槛,防范 EIP-150 63/64 Gas Griefing
if lt(gas(), minGas) {
invalid()
}
// 直接透传 payload 的内存起始指针与长度
// 接收参数设为 (0, 0),禁止 EVM 自动向当前内存复制返回值
success := call(
gas(),
target,
value,
add(payload, 0x20), // 跳过动态数组前 32 字节的长度字段
mload(payload), // 实际 payload 长度
0,
0
)
}
}
}
优化解析
- 零多余内存开辟:直接利用
payload已有的内存缓冲区偏移指针add(payload, 0x20),省去中间转录。 - 阻断内存二次方炸弹:通过显式指定
out = 0, outsize = 0,EVM 在外部合约返回超大规模恶意数据时不会强制扩展本地内存。
代币极速转账:Solady SafeTransferLib 深度解构
在标准 ERC-20 代币转账中,OpenZeppelin 的 SafeERC20.safeTransfer 包含了繁琐的方法检查、try/catch 逻辑与完整的状态解码。
知名优化库 Solady 的 SafeTransferLib 将 ERC-20 转账压缩为纯 Yul 指令,在维持极致鲁棒性的同时将 Gas 压榨到理论极限:
function safeTransfer(address token, address to, uint256 amount) internal {
assembly {
// 1. 获取自由内存指针
let m := mload(0x40)
// 2. 构造 transfer(address,uint256) 调用帧
// 方法选择器 0xa9059cbb 左对齐存入
mstore(m, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
// 存入 to 地址 (低 160 位清除非地址脏数据)
mstore(add(m, 0x04), and(to, 0xffffffffffffffffffffffffffffffffffffffff))
// 存入转账金额
mstore(add(m, 0x24), amount)
// 3. 发起底层调用
// 参数总长: 4 (selector) + 32 (to) + 32 (amount) = 68 字节 (0x44)
let success := call(gas(), token, 0, m, 0x44, 0x00, 0x20)
// 4. 复合成功断言:
// 兼容返回 true 的标准代币,以及无返回值(如 USDT)的旧实现
// 条件:call 执行成功 且 (返回数据为空 或 返回数据解码为 1)
if iszero(and(
success,
or(iszero(returndatasize()), and(gt(returndatasize(), 0x1f), eq(mload(0x00), 1)))
)) {
// 回滚并抛出 TransferFailed() 错误
mstore(0x00, 0x90b8ec18)
revert(0x1c, 0x04)
}
}
}
调用模式与兼容条件
- 全格式代币兼容:一并兼顾了 EIP-20 标准代币(返回
bool true)与早期非标代币(如 Tether USDT,执行成功但不返回任何数据)。 - 零全局状态污染:利用现有内存空间并在执行完毕后无需重置指针,单次转账比传统 SafeERC20 节约 500 - 1500 Gas。
生产级 Yul 优化安全边界与准则
- 遵守自由内存指针约定 (Respect
0x40):- 临时使用
0x00 - 0x3f是安全的。 - 若汇编逻辑分配了超过
0x40之后的内存,必须更新0x40处的指针值(mstore(0x40, newPointer)),否则后续 Solidity 高级代码分配内存时会覆盖该区域造成数据损毁。
- 临时使用
- 注意局部变量掩码清理 (Bitmask Cleaning):
- Solidity 高级语法在处理
uint8、address、bool时会自动在寄存器高位执行清理。 - 在 Yul 中,底层操作码均将所有变量视为原始 256 位字(Word)。将外部参数传给汇编时,必须显式使用位与运算(如
and(addr, 0xff...ff))清除非法则位脏数据。
- Solidity 高级语法在处理
- 可维护性与审计权衡:
- 内联汇编关闭了编译器类型系统与静态检查,非核心高频路径严禁为了微小的 Gas 节省随意使用汇编,优先保证代码可读性与可维护性。
参考文献与开源库
- Yul Language Specification (Solidity Official Docs)
- Optimism Bedrock Monorepo · SafeCall.sol
- Solady (Vectorized & Transmissions11) · SafeTransferLib.sol
EVM 内存扩张模型与瞬态存储 (EIP-1153)
在 EVM 性能调优中,开发者往往将全部精力投入到持久化存储(SSTORE / SLOAD)的优化上,却极易忽视内存扩展(Memory Expansion)的非线性成本以及 Cancun 升级引入的**瞬态存储(Transient Storage)**革命性优化。
内存与瞬态存储的 Gas 数字采用 Cancun 指令计费规则。实际成本取决于编译器、优化器、目标网络和完整调用路径。
EVM 内存管理机制与二次方成本模型
EVM 的内存(Memory)是一个线性的、按字节寻址的连续数组。与常规物理机内存可以自由释放(free())不同,EVM 内存只能单向向高地址追加扩展,在单次调用生命周期内绝不支持释放回收。
1. 内存扩展 Gas 计费公式
当内存使用量由 0 扩展至 \(a\) 个字(Word,每个字 32 字节)时,累积消耗的 Gas 为:
$$ C(a) = 3a + \left\lfloor \frac{a^2}{512} \right\rfloor $$
当分配较小内存时,线性项 \(3a\) 起主导作用;当分配规模持续增加时,二次方项 \(\frac{a^2}{512}\) 会引发 Gas 成本呈超线性急剧攀升:
| 内存大小 (Bytes) | 占用 Word 数 (\(a\)) | 累积内存 Gas 成本 | 扩展边际成本 |
|---|---|---|---|
| 32 B | 1 | 3 Gas | 极低 |
| 1 KB | 32 | 98 Gas | 轻微 |
| 32 KB | 1,024 | 5,120 Gas | 中等 |
| 256 KB | 8,192 | 155,648 Gas | 较高;21,000 gas 是普通 ETH 转账的基础费用,不是交易上限 |
| 1 MB | 32,768 | 2,195,456 Gas | 很高;能否执行取决于交易 gas limit 和网络限制 |
2. Solidity 内存默认布局与自由内存指针
Solidity 编译器固定保留了前 128 字节的内存空间:
0x00 - 0x3f(64 字节):计算哈希专用的临时草稿空间(Scratch Space)。0x40 - 0x5f(32 字节):自由内存指针(Free Memory Pointer)。其存储的值即为当前可用内存的起始物理地址(系统初始化时为0x80)。0x60 - 0x7f(32 字节):零槽位(Zero Slot),用作未赋值动态数组的默认初始引用。
// 危险的大循环内存泄漏示例
function processData(uint256[] calldata data) external pure {
for (uint256 i = 0; i < data.length; i++) {
// 严重缺陷:在循环内部反复 new 动态数组或拼接 bytes
// 自由内存指针持续向右推进,触发高额二次方内存扩展惩罚
bytes memory temp = abi.encodePacked(data[i], i);
_consume(temp);
}
}
防范策略:先限制输入规模,再考虑复用缓冲区以减少重复分配。汇编覆写必须保证长度、边界和自由内存指针正确;循环内分配不是必然错误,应按完整操作的 Gas 预算判断。
EIP-1153 瞬态存储 (Transient Storage) 深度实战
以太坊坎昆(Cancun)硬分叉引入的 EIP-1153 带来了两个全新的操作码:
TSTORE (0x5d):将 32 字节值写入指定的瞬态槽。TLOAD (0x5c):从指定的瞬态槽读取 32 字节值。
1. 瞬态存储的核心行为特征
| 维度 | 常规存储 (Storage) | 内存 (Memory) | 瞬态存储 (Transient Storage) |
|---|---|---|---|
| 持久性 | 永久持久化上链 | 调用帧结束即销毁 | 在当前整笔交易上下文有效,交易结束丢弃 |
| 调用域 | 按所属合约隔离 | 当前调用帧;internal 调用共享 | 同一合约的调用帧共享;DELEGATECALL 使用调用者空间 |
| 读写 Gas | 冷 SLOAD 2100;SSTORE 取决于原值、现值、新值与访问温度 | 指令费加扩展费 | Cancun 下 TLOAD / TSTORE 各 100 Gas |
| 回滚行为 | 回滚对应调用及其子调用的修改 | 子调用的 memory 与父帧独立 | 回滚对应调用及其子调用的瞬态修改 |
典型应用场景一:极简无冷态重入锁
传统的 ReentrancyGuard 依赖持久化 Storage:
- 进入时执行
SSTORE设置锁定标记(即使从 1 变 2,暖写也需 2900 Gas)。 - 退出时再次
SSTORE复位。 - 消耗高达 3000 - 5000 Gas。
基于 TSTORE 的瞬态重入锁
以下为简化实现,要求 evm_version = "cancun" 及支持 EIP-1153 的网络。OpenZeppelin 的 ReentrancyGuardTransient 提供独立的重入锁实现。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
abstract contract ReentrancyGuardTransient {
// 命名空间伪随机槽位
bytes32 private constant REENTRANCY_GUARD_SLOT =
0x8e94fed44239eb2314ab7a406345e6c5a8f0ccedf3b600de3d004e672c33abf4;
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() private {
assembly {
if tload(REENTRANCY_GUARD_SLOT) {
// 已处于锁定状态,直接回滚
mstore(0x00, 0x3ee5aeb5) // ReentrancyGuardReentrantCall()
revert(0x1c, 0x04)
}
// 加锁:仅消耗 100 Gas
tstore(REENTRANCY_GUARD_SLOT, 1)
}
}
function _nonReentrantAfter() private {
assembly {
// 解锁:仅消耗 100 Gas
tstore(REENTRANCY_GUARD_SLOT, 0)
}
}
}
- 成本边界:一次正常保护包含一次
TLOAD检查和两次TSTORE写入,三个指令合计 300 Gas,还需加上判断、栈操作等成本。不能把两次写入的 200 Gas 当作整个修饰器的成本。 - 清理边界:正常退出时仍需解锁,否则同一交易中后续合法调用会被阻止。交易结束时自动清除不能替代函数出口的清理。
典型应用场景二:Uniswap V4 单例闪电记账 (Flash Accounting)
在 Uniswap V3 中,每个交易对是一个独立的智能合约实例。每次代币兑换(Swap)必须在当前池内完成物理资产转移(Transfer),多跳路由(如 ETH \(\rightarrow\) USDC \(\rightarrow\) DAI)会导致频繁的 ERC-20 外部代币划转与 balanceOf 检查。
Uniswap V4 的瞬态记账范式
Uniswap V4 采用了单例架构(Singleton Pattern - PoolManager),所有池子集中在单一合约中管理:
- 整个交易流程引入“先欠账后结算”机制。用户在多跳兑换中产生的代币债务和盈余,全部记录在瞬态存储
TSTORE中。 - 内部多跳计算完全是在瞬态存储中修改数字,不产生任何实际的 ERC-20 代币转账。
- 路由在解锁回调内通过结算与提取消除各币种的债务和盈余。PoolManager 在回调结束时检查所有 delta 归零;资产转账次数取决于路由与币种,不能概括为固定一次。
sequenceDiagram autonumber actor User as 交易用户 / 路由 participant PoolManager as Uniswap V4 PoolManager (单例) User->>PoolManager: 发起多跳兑换 Swap(TokenA -> TokenB -> TokenC) Note over PoolManager: 步骤 1: 瞬态借记 TokenA (TSTORE 记录债务) Note over PoolManager: 步骤 2: 瞬态计算 TokenB 中间换算 (0 物理划转) Note over PoolManager: 步骤 3: 瞬态贷记 TokenC (TSTORE 记录盈余) Note over PoolManager: 终局结算: 仅在出口进行 1 次 TokenA 存入与 1 次 TokenC 提取 PoolManager-->>User: 交付最终代币
参考文献与规范
- EIP-1153: Transient Storage Opcodes
- EVM Codes: Opcodes Gas Costs & Memory Expansion
- Uniswap V4 Core: Flash Accounting Architecture
- OpenZeppelin ReentrancyGuardTransient.sol
Ethereum 开发工具链
以太坊开发通常分为四层:Solidity 编译器负责把合约变成 ABI 和字节码,开发框架负责测试与部署,客户端库负责与节点交互,节点或 RPC 服务负责提供链上数据。
如何选择工具
| 任务 | 推荐工具 | 说明 |
|---|---|---|
| 编译、单元测试、Gas 检查 | Foundry | Rust 编写,命令行快,Solidity 测试体验好 |
| JavaScript/TypeScript 项目 | Hardhat | 适合前端团队、脚本和插件生态 |
| 链上交互 | ethers v6 | Provider、Signer、Contract API 清晰 |
| 本地节点 | Anvil 或 Hardhat Network | 可重置、可持久化主网分叉 |
| 部署记录 | 脚本 + 网络配置 + 交易哈希 | 不要只依赖终端输出 |
工具不会替代合约审计。编译器版本、OpenZeppelin 版本、目标网络和部署账户都应写入项目配置,并在 CI 中固定。
一条可重复的开发流程
- 用 Solidity 编写接口和状态转换,先定义权限和不变量。
- 编译并检查 ABI、字节码和存储布局。
- 用本地节点测试成功路径、失败路径、权限边界和事件。
- 在测试网部署,保存链 ID、合约地址、部署交易和源码验证结果。
- 用 ethers 读取链上状态,发送交易并等待确认。
- 主网部署前复核构造参数、管理员、多签和升级权限。
相关入口:Solidity、Foundry、Hardhat、Ethers.js。
Foundry:Solidity 项目的命令行工作流
Foundry 是一套由 Rust 编写的以太坊开发工具,核心命令是 forge(编译、测试、部署)、cast(RPC 和 ABI 交互)和 anvil(本地节点)。官方文档见这里。
创建与编译
forge init my-token
cd my-token
forge build
forge test
项目通常包含 src/ 合约、test/ Solidity 测试、script/ 部署脚本和 lib/ 依赖。用 foundry.toml 固定 Solidity 版本、优化器和源码目录,避免本地环境与 CI 产生不同字节码。
测试的最小结构
function test_InitialSupply() public {
assertEq(token.totalSupply(), 1_000 ether);
}
function test_RevertWhen_NotOwner() public {
vm.prank(address(0xBEEF));
vm.expectRevert();
token.mint(address(0xBEEF), 1 ether);
}
测试不仅要覆盖成功路径,还应覆盖权限、零地址、边界值、事件和回滚。vm.prank、vm.expectRevert、vm.warp 等 cheatcode 只能用于测试环境,不能写进生产合约。
本地节点与链上交互
anvil
cast call 0xToken "totalSupply()(uint256)" --rpc-url http://127.0.0.1:8545
cast send 0xToken "transfer(address,uint256)" 0xRecipient 1000 \
--private-key "$PRIVATE_KEY" --rpc-url "$RPC_URL"
私钥应来自环境变量或硬件钱包,不能写入脚本。部署脚本应记录 chain ID、合约地址、构造参数和交易哈希,并在部署后验证源码。
相关命令:Foundry 基础、forge clone、forge soldeer。
Foundry 基础检查清单
一个可维护的 Foundry 项目至少应固定编译器、依赖和网络配置:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
常用命令:
forge fmt --check
forge build
forge test -vvv
forge test --gas-report
forge inspect Counter storage-layout
提交前检查:
- 测试覆盖成功、失败、权限和边界路径。
- 依赖版本锁定,部署网络的 chain ID 明确。
- 生产私钥不出现在
.env、脚本、日志和 CI 输出中。 - 代理合约升级前保存旧版本 storage layout,并进行 diff。
- 部署后验证源码,保存地址、交易哈希和构造参数。
forge clone
forge clone 命令可用于克隆已经验证的合约,并以 forge 自己的文件管理方式创建项目 默认情况下将从 ETH 主网的 etherscan 上获取数据
格式:
forge clone `<address>` `<repository-name>`
- 效果:从 Etherscan 主网获取到已验证的
<address>地址对应的合约,并创建名为<repository-name>的目录保存
实例:
forge clone 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 WETH9
常见错误
-
处理场景:未创建 foundry 项目时执行上述命令
-
无效的 Etherscan api
-
原因:一般项目执行 forge clone 时会读取当前目录 forge 仓库中的 forge.toml 文件。而此处未创建项目的情况下无任何配置文件读取。
-
解决办法: 使用-e 指令指定 etherscan api key (此方法不会主动在新创建的 foundry.toml 中添加入私人 apikey)
forge clone 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 WETH9 -e your-etherscan-api
-
soldeer 一个 solidity 包管理工具
常见错误
- 处理场景
- 在原生 git bash 环境中初次调用 forge soldeer init 无法完成初始化 原因: git bash 原生的字符集以及输入支持有隐藏问题,尝试使用 vscode 等嵌入的 terminal 调用 bash 执行命令。去除隐藏字符问题 解决效果图:
Hardhat:TypeScript 项目的合约开发框架
Hardhat 适合已经使用 JavaScript/TypeScript 的项目。它提供编译、测试、脚本、网络配置和插件体系;ethers 或 viem 负责具体的链上交互。
基本流程
mkdir hardhat-app && cd hardhat-app
npm init -y
npm install --save-dev hardhat typescript ts-node
npx hardhat
npx hardhat compile
npx hardhat test
网络配置应明确 RPC URL、chain ID、账户来源和确认数。私钥从环境变量读取,不要把真实账户直接写进配置文件。
测试重点
Hardhat 测试应覆盖:
- 部署后的初始状态和构造参数。
- 正常调用产生的事件、余额和权限变化。
- 非授权调用、零地址、边界金额和重复调用的 revert。
- 不同区块时间、账户余额和网络状态下的行为。
本地网络适合快速反馈,不能代替测试网验证。部署脚本应等待交易确认,并把部署结果写入网络和版本对应的 JSON 文件。
与 Foundry 的选择
如果测试主要是 Solidity、需要快速 fuzz/invariant 测试,可优先 Foundry;如果项目依赖 TypeScript、前端构建或复杂脚本,Hardhat 更顺手。两者可以共存,但应避免让两个工具分别管理同一份部署状态。
相关内容:Hardhat Ignition。
Hardhat Ignition:可追踪的模块化部署
Ignition 用模块描述部署依赖关系,并保存执行记录。相比在脚本里手动按顺序发送交易,模块化部署更容易重试、复用参数和审查变更。
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("CounterModule", (m) => {
const counter = m.contract("Counter");
return { counter };
});
执行部署:
npx hardhat ignition deploy ignition/modules/Counter.ts --network sepolia
生产部署前确认:
- 网络名称和 chain ID 正确。
- 构造参数来自版本化配置,而不是临时命令行输入。
- 模块执行记录、合约地址和交易哈希已保存。
- 升级模块明确代理管理员、实现地址和初始化调用。
- 失败后先检查已完成的部署步骤,再决定是否重试或恢复。
Ignition 记录的是部署过程,不会自动证明合约逻辑安全;部署后仍需验证源码、检查权限并运行链上状态核对脚本。
重新部署
ignition再次部署合约报错
重新部署合约时,由于ignition有记录部署过本合约而拒绝重新部署
[ Transaction ] reconciliation failed ⛔
The module contains changes to executed futures:
Transaction#GLDToken:
- From account has been changed from 0x0988258112531ef09ebfbdb5a0b5a537bc4b860c to 0x373d36662619ed994e9778c5be4f7044f005ff9b
Consider modifying your module to remove the inconsistencies with deployed futures.
解决方法:
npx hardhat ignition wipe deploymentId futureId
Hardhat官方说明文档
Handling errors | Ethereum development environment for professionals by Nomic Foundation
部署环境说明:
Ganache,非公链
补充说明
deploymentId –目前等同于chainid,命名方式为chain-xxx
futureId –对应每个需要部署的合约
deploymentId
npx hardhat ignition deployments
效果如下
futureId
官方文档
npx hardhat ignition status deploymentId
项目中实际命令
npx hardhat ignition status chain-1337
效果如下
wipe命令擦除原本部署的合约记录
官方文档
npx hardhat ignition wipe deploymentId futureId
实际命令
npx hardhat ignition wipe chain-1337 Transaction#GLDToken
效果如下
重新部署测试
npx hardhat ignition deploy ignition/modules/GLD.js --network ganache
效果如下
//注意部署脚本的位置替换为自己脚本的路径
ethers.js v6:从 RPC 读取和发送交易
ethers.js 把节点连接分成两个概念:Provider 只负责读取链上数据和发送已签名交易;Signer 代表能签名的账户。Contract 连接 Provider 时只能调用 view/pure 方法,连接 Signer 后才能发送状态交易。
读取合约状态
import { Contract, JsonRpcProvider, Wallet, formatUnits } from "ethers";
const provider = new JsonRpcProvider(process.env.RPC_URL);
const token = new Contract(
process.env.TOKEN_ADDRESS,
["function balanceOf(address) view returns (uint256)",
"function decimals() view returns (uint8)",
"function transfer(address,uint256) returns (bool)"],
provider,
);
const raw = await token.balanceOf(process.env.USER_ADDRESS);
console.log(formatUnits(raw, await token.decimals()));
RPC URL、私钥和合约地址应通过环境变量注入,不要提交到仓库。代币金额始终使用 bigint,不要用 JavaScript number。
发送交易并等待确认
const signer = new Wallet(process.env.PRIVATE_KEY, provider);
const writable = token.connect(signer);
const tx = await writable.transfer(process.env.RECIPIENT_ADDRESS, 1_000_000n);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error("交易未成功确认");
console.log(receipt.hash, receipt.status);
以上片段使用 ethers v6,需要设置 RPC_URL、TOKEN_ADDRESS、USER_ADDRESS、RECIPIENT_ADDRESS 和 PRIVATE_KEY。只有节点管理并解锁了账户时,JsonRpcProvider.getSigner() 才适用;公共 RPC 场景通常需要本地 Wallet,浏览器钱包则使用 BrowserProvider。
发送前应区分三种状态:交易尚未进入区块、交易已确认但执行失败、交易成功并产生事件。wait() 返回的 receipt 需要检查 status,不能只根据 RPC 请求是否成功判断业务成功。
部署和交互时的检查
- 检查
provider.getNetwork()返回的 chain ID,避免把主网交易发到错误网络。 - 对用户输入使用
parseUnits,根据代币实际decimals转换。 - 估算 Gas 只能作为参考,合约状态可能在发送前变化。
- 监听事件时保存区块号和交易哈希,并处理链重组带来的重复或撤销。
- 签名消息时明确域、用途和有效期,避免把交易签名和普通消息混用。
更完整的代币示例见 ERC-20。
合约交互
Ethers.js 通过私钥还原公钥
一、什么是私钥和公钥?
在以太坊和其他区块链平台中,私钥是一个源信息,用于命令链上账户进行操作。公钥则是从私钥中运算出的,用于验证操作的合法性和完整性。
二、安装 ethers.js
在实现私钥还原公钥之前,需要先安装 ethers.js:
npm install ethers@6
三、使用 ethers.js 运算公钥
以下是通过 ethers.js 将私钥转换为公钥的完整代码:
const { Wallet } = require('ethers');
// 私钥(确保保密!)
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) throw new Error("请设置 PRIVATE_KEY 环境变量");
// 从私钥生成钱包实例
const wallet = new Wallet(privateKey);
// 获取公钥
const publicKey = wallet.signingKey.publicKey;
console.log("公钥:", publicKey);
输出格式
- 私钥:输入值,64 个十六进制字符,前缀为
0x。 - 公钥:未压缩格式,65 字节(520 bits),以
0x04开头表示未压缩格式,随后为 X、Y 坐标。
以上代码使用 ethers v6 的 wallet.signingKey.publicKey,只输出公钥。私钥通过环境变量提供,不应写入源码或日志。
四、运算原理解析
使用的曲线:secp256k1
ethers.js 使用了 secp256k1 椭圆曲线,这是比特币和以太坊等区块链系统使用的标准曲线。secp256k1 定义了一个椭圆曲线方程:y^2 = x^3 + 7,并基于此定义了一个基点 G。公钥是通过私钥对基点 G 进行椭圆曲线点乘计算得出的。这个过程确保了私钥与公钥之间的唯一性和安全性,同时公钥可以公开用于验证签名的合法性。
ERC
ERC 是 Ethereum Request for Comments(以太坊请求评论)的缩写。它是以太坊社区定义的一系列智能合约标准。这些标准为以太坊上的智能合约开发提供了规则和指南,确保不同开发者创建的合约能够相互交互和兼容。
ERC 的主要作用
互操作性
在以太坊生态系统中 ,不同的开发者可能会创建各种各样的智能合约。ERC 标准允许这些合约以一种统一的方式进行交互。例如,在去中心化金融(DeFi)领域,有许多不同的应用程序,如借贷平台、去中心化交易所等。ERC - 20 标准的存在使得各种通证能够在这些不同的平台之间方便地流通和交易。
标准化开发
为开发者提供了清晰的开发指南。以 ERC - 721 为例,这个标准专门用于非同质化通证(NFT)。它规定了 NFT 合约必须实现的一些基本方法,如balanceOf(查询某个地址拥有的 NFT 数量)、ownerOf(查询某个 NFT 的所有者)和transferFrom(转移 NFT 的所有权)等。这使得开发者在创建 NFT 项目时能够遵循一套既定的规则,减少开发过程中的混乱,并确保所开发的 NFT 能够被市场上的各种钱包和应用程序所支持。
常见的 ERC 标准
ERC - 20
这是最著名的 ERC 标准之一,主要用于创建可互换的数字资产,也就是同质化通证。在以太坊上发行的大多数加密货币(如以太坊本身之外的许多山寨币)都遵循 ERC - 20 标准。它定义了一套接口,包括totalSupply(总供应量)、balanceOf(账户余额)、transfer(转账)、transferFrom(授权转账)、approve(授权)等方法。
ERC - 721:
专为非同质化通证(NFT)设计。与 ERC - 20 不同,ERC - 721 中的每个通证都是独一无二的,不能与其他通证互换。NFT 在数字艺术、游戏道具、虚拟房地产等领域有广泛的应用。通过 ERC - 721 标准,这些独特的资产可以在以太坊区块链上安全地创建、拥有和交易。
ERC - 1155:
这是一种多令牌标准,它结合了 ERC - 20 和 ERC - 721 的特点。ERC - 1155 允许在一个智能合约中同时管理多种类型的通证,包括同质化和非同质化通证。这种标准在游戏开发等场景中特别有用,因为游戏中可能同时存在可互换的游戏货币和独一无二的游戏道具。
标准目录
| 标准 | 主题 |
|---|---|
| ERC-20 | 同质化代币基础 |
| EIP-712 | 结构化数据链下签名 |
| ERC-721 | 非同质化代币(NFT) |
| ERC-725 | 链上身份 |
| ERC-777 | 同质化代币(操作员 + 钩子) |
| ERC-1155 | 多代币(半同质化类型) |
| EIP-2612 | permit 链下授权 |
| EIP-3009 | 签名转账(transferWithAuthorization) |
| ERC-4337 | 账户抽象 |
| EIP-4626 | 收益金库标准 |
| ERC-1400 | 证券型代币(分区 + 错误码) |
| ERC-3525 | 半同质化代币(SLOT + VALUE) |
| ERC-3643 | T-REX 受监管证券代币 |
| EIP-7201 | 命名空间存储布局 |
ERC-20:可互换代币接口与安全交互
ERC-20 是一组应用层接口约定,不是代币合约的完整实现。只要合约按照约定提供余额、转账、授权和事件,钱包、区块浏览器和 DeFi 协议就能用统一方式与它交互。标准本身不规定发行、销毁、权限、暂停或升级策略,这些功能都属于具体实现。
1. 核心状态与接口
最小实现通常维护余额、授权额度和总供应量三个状态:
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
必需函数和事件如下:
| 接口 | 作用 | 关键语义 |
|---|---|---|
totalSupply() | 查询总供应量 | 应与所有账户余额之和一致 |
balanceOf(account) | 查询余额 | 不改变状态 |
transfer(to, amount) | 发送者转账 | 成功时必须触发 Transfer |
approve(spender, amount) | 设置授权额度 | 覆盖旧额度,不是累加 |
allowance(owner, spender) | 查询授权额度 | transferFrom 的上限 |
transferFrom(owner, to, amount) | 代扣转账 | 消耗有限额度并触发 Transfer;部分实现对最大值额度不递减 |
Transfer(from, to, amount) | 记录转账及实现采用的发行变化 | 铸造通常使用零地址作为 from,销毁常用零地址作为 to;标准不规定销毁接口 |
Approval(owner, spender, amount) | approve 成功时记录设置的额度 | transferFrom 是否另发授权变化事件取决于实现,不能只靠此事件重建剩余额度 |
name、symbol 和 decimals 是常见元数据接口。它们有助于展示,但不是所有 ERC-20 实现都必须提供。decimals 只影响展示换算,不改变链上整数余额;例如 decimals = 6 的 1 USDC 在链上表示为 1_000_000。
2. 转账与授权的安全边界
approve 使用的是“覆盖”语义。用户把额度从旧值改成新值时,恶意 spender 可能在两笔交易之间抢先使用旧额度,形成经典的 allowance race。前端通常采用“先设置 0,再设置新额度”,但清零交易确认前,旧额度仍可被使用。增减授权接口不是 ERC-20 的必需接口;具体风险与兼容性见授权竞争。
授权本质上是把资产支配权交给另一个地址。用户应核对 spender 地址、额度及其业务权限;标准 ERC-20 allowance 不自带有效期,无限授权会扩大钓鱼网站或协议漏洞的损失范围。协议实现也不应假设所有代币都严格遵守返回 bool 的习惯:历史上存在不返回值、返回非标准值或转账收取手续费的代币。
安全的代币交互通常使用 OpenZeppelin 的 SafeERC20,它会兼容“不返回值但成功”的旧代币,并在失败时回滚。使用原始低级调用时,必须同时检查调用是否成功和返回数据是否表示成功。
3. 一个可运行的最小实现
简化实现包含余额、授权、铸造和销毁。实际代币还需要明确铸造权限、暂停机制和升级策略;接口兼容性不等于这些业务约束已经得到验证。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract ExampleToken {
string public name = "Example Token";
string public symbol = "EXT";
uint8 public immutable decimals = 18;
uint256 public totalSupply;
address public immutable owner;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor(uint256 initialSupply) {
owner = msg.sender;
_mint(msg.sender, initialSupply);
}
function transfer(address to, uint256 value) external returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) external returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) external returns (bool) {
uint256 current = allowance[from][msg.sender];
require(current >= value, "allowance exceeded");
unchecked {
allowance[from][msg.sender] = current - value;
}
emit Approval(from, msg.sender, allowance[from][msg.sender]);
_transfer(from, to, value);
return true;
}
function mint(address to, uint256 value) external onlyOwner {
_mint(to, value);
}
function burn(uint256 value) external {
require(balanceOf[msg.sender] >= value, "balance exceeded");
unchecked {
balanceOf[msg.sender] -= value;
}
totalSupply -= value;
emit Transfer(msg.sender, address(0), value);
}
function _mint(address to, uint256 value) internal {
require(to != address(0), "mint to zero");
totalSupply += value;
balanceOf[to] += value;
emit Transfer(address(0), to, value);
}
function _transfer(address from, address to, uint256 value) internal {
require(from != address(0) && to != address(0), "zero address");
require(balanceOf[from] >= value, "balance exceeded");
unchecked {
balanceOf[from] -= value;
}
balanceOf[to] += value;
emit Transfer(from, to, value);
}
}
这里的 unchecked 只出现在已经完成边界检查的减法上;加法仍保留 Solidity 0.8 的溢出检查。真实项目还需要测试零值转账、授权变化、铸造销毁事件、权限转移和总供应量不变量。
4. 使用 ethers.js 读取余额
ethers v6 使用 Contract 实例直接调用 ABI。读取操作不需要发送交易,也不需要钱包签名:
import { Contract, JsonRpcProvider, Wallet, formatUnits, parseUnits } from "ethers";
const provider = new JsonRpcProvider(process.env.RPC_URL);
const token = new Contract(
process.env.TOKEN_ADDRESS,
["function balanceOf(address) view returns (uint256)",
"function decimals() view returns (uint8)",
"function approve(address,uint256) returns (bool)"],
provider,
);
const raw = await token.balanceOf(process.env.USER_ADDRESS);
const decimals = await token.decimals();
console.log(formatUnits(raw, decimals));
发送 approve 则需要连接签名者,并等待交易确认:
const signer = new Wallet(process.env.PRIVATE_KEY, provider);
const writable = token.connect(signer);
const amount = parseUnits("1", Number(decimals)); // 1 个代币,按真实 decimals 换算
const tx = await writable.approve(process.env.SPENDER_ADDRESS, amount);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error("交易未成功确认");
该示例需要 ethers v6,并设置 RPC_URL、TOKEN_ADDRESS、USER_ADDRESS、SPENDER_ADDRESS 和 PRIVATE_KEY。Signer 的私钥仅在本地读取;不要输出私钥。普通公共 RPC 不管理用户私钥,不能直接依靠 provider.getSigner() 获得签名账户。
金额应始终使用整数和 parseUnits/formatUnits 转换,避免用 JavaScript 浮点数表示代币数量。
5. 用 Foundry 验证转账、授权和销毁
完整工程在 examples/ethereum-signatures,固定 Solidity 0.8.24、OpenZeppelin 5.2.0。上面的自写 ExampleToken 也存放在该工程中,正文直接引用同一文件,避免测试与展示代码不一致。
npm ci --ignore-scripts
forge test --match-test 'testExampleToken|testERC20|testApprovalRace' -vv
自写实现验证发行、代扣、销毁、铸造权限与失败转账时额度回滚;OpenZeppelin 教学代币还验证 18 位 decimals 的整数单位及旧额度加新额度的竞争序列。10 ether 在这里表示整数 10 × 10^18 个最小代币单位,不是往合约发送 ETH。
测试只覆盖指定场景。上面的 ethers 代码需要独立安装 ethers v6 并配置 RPC 与地址;本轮没有使用真实 RPC 执行该交互。
6. 阅读一个 ERC-20 合约时的检查清单
Transfer是否覆盖转账、铸造和销毁,且事件参数正确。totalSupply是否始终等于所有余额之和。approve的覆盖语义、授权 race 和无限授权风险是否被说明。- 铸造、销毁、暂停和升级权限是否明确,是否由多签或治理控制。
- 是否考虑非标准代币:无返回值、手续费代币、强制转账和回调代币。
- 前端是否显示真实
decimals,并避免浮点运算。
ERC-20 的难点不在函数数量,而在这些接口被大量协议组合使用后形成的权限、会计和兼容性边界。
参考资料
EIP-712:编码签名内容,绑定应用上下文
EIP-712 定义结构化消息的类型、编码和摘要,使钱包能够展示字段并按同一规则签名。它不提供完整的业务授权协议,也明确不自行解决重放:nonce、取消、有效期和执行权限需要应用设计。规范
学习顺序是“域 → 结构体哈希 → 最终摘要 → 验签 → 消费业务授权”。知道签名来自谁之后,还要确认它允许谁收款、执行多少次、在何处执行。
1. 最终摘要
最终待签名摘要是三个定长部分的哈希:
digest = keccak256(0x1901 || domainSeparator || hashStruct(message))
前缀 0x19、版本 0x01 来自 EIP-191 的结构化消息签名格式,用于区分消息编码语境。跨应用或跨链的隔离依赖实际签入的域字段,不能用交易的 RLP 编码规则代替消息防重放设计。EIP-191
此处各部分长度固定,使用 abi.encodePacked 拼接这些定长部分没有动态字段分界歧义。风险在于把多个未哈希的动态字符串直接拼接成业务消息。
2. 域字段与版本边界
常见域包含 name、version、chainId、verifyingContract。salt 也可用于额外区分域。规范允许应用选择所需字段;类型字符串和编码必须与实际选用的字段一致,不能声明五项却只编码四项。
本例使用的域类型是:
EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
签名业务通常需要绑定链 ID 和实际验签合约地址。两个网络若使用相同链 ID、相同地址及域内容,仅靠这些字段无法区分它们。协议升级若改变授权含义,也应考虑域版本或业务版本如何变化。
静态缓存什么时候有问题?
| 场景 | 应核对的边界 |
|---|---|
| 同一已部署实例的链 ID 后来变化 | 缓存仍带旧 ID 时,可能继续接受旧域签名 |
| 在另一条链重新执行构造函数部署 | 构造函数通常读取该链当前 ID,不会自动沿用原链缓存 |
| 多个代理委托调用同一实现 | 域中的验签地址应对应各代理执行上下文 |
| 分叉后两边仍使用同一链 ID | 动态读取 chain ID 本身也无法区分两条分叉 |
本例继承 OpenZeppelin 5.2.0 EIP712,由 _hashTypedDataV4 使用当前域。库在执行地址或链 ID 与缓存不同的情况下重算。可升级项目应使用相应的可升级实现和初始化流程,本例不是代理合约。固定版本源码
3. 结构体编码
先得到 typeHash = keccak256(encodeType),再计算 hashStruct = keccak256(abi.encode(typeHash, ...字段编码))。主类型先写,其依赖的其他结构体去重后按类型名称排序追加;字段顺序保持类型声明中的顺序。
| 字段类型 | 编码方式 |
|---|---|
| 整数、地址、bool、定长 bytes | 按规范扩展或填充成 32 字节;有符号整数需符号扩展 |
string、动态 bytes | 内容的 Keccak-256 哈希 |
| 嵌套结构体 | 子结构体的 hashStruct |
| 定长或动态数组 | 各元素的规范编码连接后取哈希;嵌套元素递归处理 |
下例只使用地址与整数,便于直接对照编码。若增加字符串、结构体或数组,不能照搬普通 abi.encode 的动态类型布局冒充 EIP-712 的字段编码。
4. 一份签名转账的完整实现
场景:Maker 预先给合约授权代币,签署收款人、金额、nonce 和 deadline;任意 Relayer 可提交,但不能更换收款人。
代码固定为 Solidity 0.8.24、OpenZeppelin Contracts 5.2.0,保存在 examples/ethereum-signatures/src/SignedTransfer.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
// Teaching model: fixed standard token, signed recipient, permissionless relayer.
contract SignedTransfer is EIP712, ReentrancyGuard {
using SafeERC20 for IERC20;
struct Order {
address maker;
address recipient;
uint256 amount;
uint256 nonce;
uint256 deadline;
}
bytes32 public constant ORDER_TYPEHASH =
keccak256("Order(address maker,address recipient,uint256 amount,uint256 nonce,uint256 deadline)");
IERC20 public immutable token;
mapping(address => mapping(uint256 => bool)) public usedNonce;
error Expired();
error NonceUsed();
error InvalidSignature();
constructor(IERC20 token_) EIP712("LearningTransfer", "1") {
token = token_;
}
function digest(Order calldata order) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(
abi.encode(ORDER_TYPEHASH, order.maker, order.recipient, order.amount, order.nonce, order.deadline)
)
);
}
function execute(Order calldata order, bytes calldata signature) external nonReentrant {
if (block.timestamp > order.deadline) revert Expired();
if (usedNonce[order.maker][order.nonce]) revert NonceUsed();
if (!SignatureChecker.isValidSignatureNow(order.maker, digest(order), signature)) {
revert InvalidSignature();
}
usedNonce[order.maker][order.nonce] = true;
token.safeTransferFrom(order.maker, order.recipient, order.amount);
}
}
这里的 nonce 与 deadline 是本应用的选择,不是 EIP-712 强制字段。一个允许重复使用的登录声明或查询签名可能需要不同策略;一次性资产转账则必须明确消费规则。
代币地址在构造时固定,每份订单的收款人和金额进入摘要,域绑定当前合约。若改成多代币路由,必须将代币及其他影响执行结果的参数纳入签名,不能保留一个可任意填写的执行参数。
execute 在转账前标记 nonce,并保护执行入口免于重入。转账失败导致本次交易回滚,nonce 也回滚。ERC-1271 验签仍可能有外部读取行为,因此锁与业务状态顺序都需要明确。
5. 运行和检查结果
完整工程:examples/ethereum-signatures。测试用 Foundry vm.sign 生成 ECDSA 签名,不使用真实钱包资金或公共 RPC。
npm ci --ignore-scripts
forge test --match-test 'testSignedTransfer|testContractWallet' -vv
测试覆盖成功转账、修改收款人失败、重复 nonce 失败、另一合约域验签失败、链 ID 改变时重算域、过期订单失败,以及转账失败时 nonce 回滚。合约钱包按 ERC-1271 策略验证的路径也单独测试。
这份实现用于讲解固定标准代币的签名转账,没有费用、报价、取消接口或手续费代币会计。扩展业务前,应重新列出签名必须绑定的参数和状态不变量。
参考资料
ERC-1271:查询合约账户的签名策略
EOA 可以用私钥生成 ECDSA 签名。合约账户的地址由部署规则产生,其签名有效性由代码决定:例如核对多签阈值、会话密钥或已批准的摘要。ERC-1271 为调用方提供统一的查询接口。规范
EIP-7702 委托账户仍有私钥,同时可以执行委托代码。这与传统合约账户不同;是否走 ECDSA 或 ERC-1271,应看应用约定与所用验签库的具体版本,不能简单等同为“有代码就没有私钥”。
1. 接口与返回值
interface IERC1271 {
function isValidSignature(bytes32 hash, bytes calldata signature)
external view returns (bytes4);
}
成功必须返回 0x1626ba7e,即该接口的函数选择器。签名字节可以采用钱包自定义格式,不必是 65 字节 ECDSA 签名。调用方不能自行解包后假设只有一个签名者。
低级调用的 success == true 只说明调用未回滚。空 fallback 也可能正常返回;必须继续检查返回数据和魔数。Solidity 对空数据进行普通 ABI 返回值解码会失败,不会产生“内存未清零导致的随机 bool”。
在本例固定的 OpenZeppelin 5.2.0 中,库要求成功返回至少 32 字节 ABI 编码数据,并核对魔数。接口返回的是 bytes4,但正常 ABI 返回值占一个 32 字节槽。源码
2. 当前有效不代表以后有效
钱包可能修改所有者、阈值、模块或摘要批准状态。同一组 (signer, hash, signature) 在两个区块上可以得到不同结果。
链下撮合时的成功验证适合做预检查,实际结算仍应在执行时验证。不要把昨天验证成功的结果永久缓存为今天的授权。业务协议自己的 nonce、取消状态和有效期也必须独立检查。
3. 静态调用的能力边界
验签不得修改状态。现代 EVM 调用方通常用 STATICCALL 执行该查询,其静态约束也传递到子调用。钱包不能在这个调用树内写存储、发日志或通过非零 value 调用转移 ETH。
这不等于完全隔离外部调用:钱包仍可读取其他合约、递归调用 view 接口,并观察调用方的中间状态。若报价或权限依赖未完成的状态更新,仍需分析只读重入及状态一致性。
钱包验签还会消耗 Gas,也可能回滚。批处理应定义单项失败的处理方式和总体预算。ERC-1271 不规定统一 Gas 上限;硬编码较小上限可能排除合法的复杂多签钱包,不能无条件作为兼容性建议。
4. 与业务授权一起使用
本博客的 EIP-712 示例 使用 SignatureChecker.isValidSignatureNow。固定版本 5.2.0 在 code.length == 0 时尝试 ECDSA,否则调用 ERC-1271。这是库的分派规则,不是 EOA/合约账户的永久分类法。
委托后的 EOA 若按这个库接入,需要委托代码实现约定的 ERC-1271 策略。支持未部署的反事实钱包等情形,还需要额外协议,不能从 ERC-1271 接口本身推导。
签名摘要必须绑定真实业务参数。只对调用者提供的 hash 返回 true,随后却按另一组 recipient、amount 执行转账,仍然会形成授权漏洞。
5. 可运行的边界测试
仓库 examples/ethereum-signatures 使用一个仅供测试的 PolicyWallet:批准特定摘要时返回魔数,撤销后返回失败值。它不是可部署的钱包方案。
npm ci --ignore-scripts
forge test --match-test 'testContractWallet|testEmptyFallback' -vv
测试确认:合约钱包可完成签名转账;策略改变后原签名失效;空 fallback 的成功返回不能冒充有效签名。测试用钱包的配置入口故意简化,真实钱包必须具备配置权限控制。
参考资料
ERC-721:从所有权、授权到安全转移
ERC-721 用 (合约地址, tokenId) 标识非同质化代币。balanceOf 返回持有的 NFT 数量,ownerOf(tokenId) 返回某个 NFT 的持有地址。标准接口不自动证明链外物品的所有权,也不规定发行权限。规范
本节沿着“发行一个 NFT → 授权 → 转移 → 接收回调”学习。示例固定为 Solidity 0.8.24、OpenZeppelin Contracts 5.2.0;完整工程与测试在仓库的 examples/ethereum-signatures。
1. 两种授权的范围
| 操作 | 含义 | 转移后的变化 |
|---|---|---|
approve(operator, tokenId) | 授权操作某一个 NFT | 该 NFT 的单独授权被清除 |
setApprovalForAll(operator, true) | 授权操作该所有者在此合约中的全部 NFT | 不因转移其中一个 NFT 而撤销 |
approve(address(0), tokenId) | 撤销单独授权 | 不改变所有权 |
setApprovalForAll(operator, false) | 撤销全局操作员授权 | 不改变所有权 |
所有者、该 NFT 的被授权地址、或所有者的全局操作员可以发起转移。NFT 转给新持有者后,旧持有者的全局授权仍属于旧持有者;它不会自动赋予操作员对新持有者资产的权限。
Approval 和 ApprovalForAll 记录授权设置,Transfer 记录所有权变化。索引器不能只保留历史 Approval,还必须处理转移清除单独授权的规则。
2. 一个最小发行合约
保存为示例工程中的 src/LearningNFT.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract LearningNFT is ERC721, Ownable {
constructor() ERC721("Learning NFT", "LNFT") Ownable(msg.sender) {}
function mint(address recipient, uint256 tokenId) external onlyOwner {
_safeMint(recipient, tokenId);
}
}
mint 只允许合约管理员调用;_safeMint 向合约地址发行时会检查接收回调。发行权限是本例的业务规则,不属于 ERC-721 的必需接口。
ERC-721 的 metadata 和 enumerable 是可选扩展。tokenURI 属于 metadata;本例没有设置图片或 JSON URI,也没有实现全部 token 的枚举。支持某个接口应通过 ERC-165 查询,不能只从名字猜测。
3. 授权与转移的完整路径
假设 Alice 持有 tokenId = 1,Operator 要把它转给 Bob:
- 管理员调用
mint(Alice, 1),检查ownerOf(1) == Alice。 - Alice 调用
approve(Operator, 1),检查getApproved(1) == Operator。 - Operator 调用
safeTransferFrom(Alice, Bob, 1)。 - 检查
ownerOf(1) == Bob、getApproved(1) == address(0),并检查双方 NFT 数量。
from 必须匹配当前所有者,不能通过随意填写 from 绕过权限。第一次转移后,Operator 不能继续凭旧的单独授权转走 Bob 的 NFT。
这条路径对应 testNFTApprovedTransferClearsApproval。全局操作员转移多个 NFT 与未授权转移失败也有独立测试。
4. safeTransferFrom 的 safe 指什么?
目标地址有代码时,安全转移会调用 onERC721Received,要求返回 0x150b7a02。回调拒绝、返回错误值或缺失必要接口,会使整次转移回滚。它用于检查接收协议,不保证接收者可信,也不是防盗或防重入机制。
在本例使用的 OpenZeppelin 5.2.0 实现中,先更新所有权并清除单独授权,再调用接收者。因此回调能读到新的所有权,也能再次调用 NFT 或其他业务合约。固定版本源码
sequenceDiagram
participant O as 已授权操作员
participant N as NFT 合约
participant R as 接收合约
O->>N: safeTransferFrom(from, R, tokenId)
N->>N: 检查权限、更新所有权、清除单独授权
N->>R: onERC721Received(...)
R-->>N: 返回接收魔数或拒绝
Note over N,R: 拒绝时本次调用的状态变化回滚
测试中的 ForwardingReceiver 在回调中把 NFT 再转给 Bob,整笔交易仍成功。这说明安全转移结束后,最终所有者未必就是最初的接收合约。
市场、质押或借贷协议若在转移 NFT 后才更新订单、抵押或领取状态,可能受到回调重入影响。应按业务不变量安排检查和状态更新,并在需要的入口使用重入保护;不能把 _safeMint 或 safeTransferFrom 当作锁。
transferFrom 不做接收回调检查,可能把 NFT 转进无法操作它的合约。向尚未部署代码的预测地址转移也不会验证未来代码的接收能力。
5. 运行与观察
在示例工程中执行:
npm ci --ignore-scripts
forge test --match-test testNFT -vv
测试覆盖授权清除、全局授权、未授权失败、回调读取新状态、拒绝回调时的所有权及授权回滚,以及回调中的再次转移。它验证这份教学实现,不代表对任意 NFT 或业务协议的审计。
参考资料
ERC-725: 区块链身份标准
摘要
ERC-725 是一个为基于区块链的身份(或任何事物)建立的通用、可扩展的标准。它允许智能合约拥有一个去中心化的身份,并通过一个统一的接口来管理与之关联的数据和权限。这个标准主要分为两部分:ERC-725X (执行) 和 ERC-725Y (数据)。
1. 背景:为什么需要链上身份?
账户地址本身不能完整描述其代表的人、组织、设备或应用。ERC-725 提供标准化的执行与数据存储接口,身份信息及权限策略由具体实现和扩展定义。
ERC-725 旨在为任何地址(无论是合约还是未来的账户抽象钱包)创建一个可编程的、自我主权的身份。
2. 核心概念
ERC-725 身份由两个核心部分组成,它们通常在同一个智能合约中实现。
ERC-725Y: 通用键值数据存储
ERC-725Y 提出了一种使用键值对来存储身份数据的标准。这使得身份信息变得非常灵活和可扩展。
- 数据键 (Data Key):是一个
bytes32值,用于标识一段特定的数据。为了避免冲突并实现互操作性,社区定义了一个通用标准 LSP2 (LUKSO Standard Proposal 2) 来规范如何生成这些键。例如,一个名为UserProfile的 JSON 对象的键可以被标准化地哈希为一个bytes32值。 - 数据值 (Data Value):是与键关联的
bytes数据。它可以是任何信息,例如个人资料、认证声明、其他资产的地址等。
通过 getData(bytes32 key) 和 setData(bytes32 key, bytes value) 这两个核心函数,任何外部应用都可以以标准化的方式读取或写入(如果权限允许)一个身份的数据。
示例: 一个用户的身份合约可以存储以下信息:
keccak256("LSP3Profile")->0x...(指向一个包含个人资料 JSON 文件的 IPFS CID)keccak256("LSP5ReceivedAssets[]")->[0x..., 0x...](该身份拥有的资产列表)
ERC-725X: 通用执行
ERC-725X 允许身份的所有者或授权的密钥,通过该身份合约执行任意操作。
execute(uint256 operationType, address target, uint256 value, bytes calldata):这是 ERC-725X 的核心函数。它允许身份合约与其他智能合约进行交互。operationType: 定义了操作类型,如CALL,CREATE,STATICCALL等。target: 目标合约地址。value: 发送的 ETH 数量。calldata: 要执行的函数调用数据。
这种设计将 “谁可以执行” (权限管理) 和 “可以执行什么” (具体操作) 分离开来。身份合约的所有者可以授予不同的密钥不同的权限,例如:
- 一个密钥只能用于在社交媒体 DApp 上发帖 (
execute调用该 DApp 的post函数)。 - 另一个密钥可以转移资产 (
execute调用 ERC20 的transfer函数)。 - 主密钥拥有管理其他密钥的最高权限。
3. 权限管理与密钥
ERC-725 的强大之处在于其灵活的权限管理系统。身份的所有权不是由单个私钥(像 EOA 那样)控制,而是由一组具有不同权限的密钥来管理。
- 密钥 (Key):可以是 EOA 地址或另一个合约地址。
- 目的 (Purpose):每个密钥都被分配一个或多个“目的”,定义了它能做什么。例如:
PURPOSE_MANAGEMENT: 管理密钥和权限。PURPOSE_EXECUTION: 执行交易。PURPOSE_ENCRYPTION: 用于加密数据。
- 方案 (Scheme):定义了签名的类型,例如
ECDSA(用于 EOA) 或EIP1271(用于合约签名)。
这种模型被称为 “元交易 (Meta-Transactions)” 的一种实现,因为交易的验证逻辑(签名和权限检查)是在合约内部处理的,而不是由以太坊协议本身处理。
4. 用例
ERC-725 的应用场景非常广泛:
- 自我主权身份 (Self-Sovereign Identity, SSI):用户可以完全控制自己的数字身份,管理个人数据,并有选择地向 DApp 授予访问权限,而无需依赖中心化服务商。
- 通用 DApp 配置文件:用户可以在其 ERC-725 身份上存储一次个人资料(如用户名、头像),所有集成了该标准的 DApp 都可以读取这份资料,无需重复创建。
- 设备与物联网 (IoT):为物联网设备创建一个链上身份,使其能够自主地进行交易、注册或与其他设备交互。
- 组织与 DAO:为去中心化自治组织 (DAO) 创建一个身份,管理其成员、投票权和资产。
结论
ERC-725 是构建去中心化社会 (DeSoc) 和 Web3 的一个基础构件。它通过提供一个标准化的、可扩展的身份层,将以太坊从一个纯粹的金融交易网络,转变为一个能够承载复杂社会和经济活动的平台。随着账户抽象 (ERC-4337) 的普及,基于 ERC-725 的智能合约钱包有望成为未来用户进入 Web3 的主要入口。
ERC-777: Token Standard
与 ERC-20 的异同点
相同点
-
基本功能:
- 都支持代币的转账和余额查询
- 都实现了代币的基本属性(名称、符号、精度等)
- 都支持代币的授权机制
-
兼容性:
- ERC-777 完全兼容 ERC-20
- 可以通过 ERC-20 接口访问 ERC-777 代币
- 支持相同的代币查询方法
不同点
-
授权机制:
- ERC-20:使用 approve/transferFrom 模式,需要两步操作
- ERC-777:使用操作者(Operator)机制,更灵活且支持批量操作
-
转账通知:
- ERC-20:无内置通知机制,需要额外的事件监听
- ERC-777:提供内置的钩子函数(tokensReceived/tokensToSend)
-
数据支持:
- ERC-20:不支持在转账时附加额外数据
- ERC-777:支持在转账时附加任意数据(data 和 operatorData)
-
燃烧机制:
- ERC-20:无标准燃烧接口,需要自行实现
- ERC-777:提供标准燃烧接口,支持操作者燃烧
-
安全性:
- ERC-20:存在 approve/transferFrom 的安全隐患
- ERC-777:提供更安全的操作者机制和钩子函数
-
功能扩展:
- ERC-20:功能相对简单,扩展性有限
- ERC-777:支持更复杂的业务逻辑,扩展性更强
ERC-777 是一个以太坊代币标准,它在 ERC-20 的基础上进行了改进,提供了更高级的功能和更好的安全性。该标准由 Jacques Dafflon、Jordi Baylina 和 Thomas Shababi 于 2017 年 11 月提出。
标准概述
ERC-777 的主要目标是:
- 提供更直观的代币操作方式
- 改进 ERC-20 的批准机制
- 增加代币发送和接收的钩子函数
- 保持与 ERC-20 的向后兼容性
核心接口
interface IERC777 {
// 返回代币名称
function name() external view returns (string memory);
// 返回代币符号
function symbol() external view returns (string memory);
// 返回代币精度
function granularity() external view returns (uint256);
// 返回总供应量
function totalSupply() external view returns (uint256);
// 返回账户余额
function balanceOf(address holder) external view returns (uint256);
// 发送代币
function send(address recipient, uint256 amount, bytes calldata data) external;
// 燃烧代币
function burn(uint256 amount, bytes calldata data) external;
// 检查是否是操作者
function isOperatorFor(address operator, address tokenHolder) external view returns (bool);
// 授权操作者
function authorizeOperator(address operator) external;
// 撤销操作者
function revokeOperator(address operator) external;
// 默认操作者发送代币
function operatorSend(
address sender,
address recipient,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
// 默认操作者燃烧代币
function operatorBurn(
address account,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
}
interface IERC777Recipient {
// 代币接收钩子
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
}
interface IERC777Sender {
// 代币发送钩子
function tokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
}
主要特性
-
钩子函数:
tokensReceived:接收代币时的回调tokensToSend:发送代币时的回调- 允许合约在代币转移时执行自定义逻辑
-
操作者机制:
- 替代 ERC-20 的 approve/transferFrom 模式
- 更灵活和安全的授权系统
- 支持批量操作
-
数据字段:
- 支持在转账时附加额外数据
- 便于实现更复杂的业务逻辑
-
向后兼容性:
- 完全兼容 ERC-20
- 可以通过 ERC-20 接口访问
安全考虑
-
重入攻击防护:
- 钩子函数可能被用于重入攻击
- 实现时需要使用重入锁或检查-效果-交互模式
-
操作者权限:
- 需要仔细管理操作者权限
- 建议实现操作者白名单机制
-
数据验证:
- 需要验证传入的数据
- 防止恶意数据导致合约异常
应用场景
-
代币化资产:
- 股票、债券等金融工具
- 房地产等实物资产
-
支付系统:
- 自动支付处理
- 订阅服务
-
投票系统:
- 代币持有者投票
- 治理机制
-
奖励系统:
- 忠诚度计划
- 空投分发
最佳实践
-
实现检查:
- 实现所有必需的功能
- 确保符合 ERC-777 标准
-
事件记录:
- 记录所有重要操作
- 包括发送、接收、授权等
-
错误处理:
- 提供清晰的错误信息
- 使用 revert 而不是 require
-
测试覆盖:
- 编写完整的测试用例
- 包括钩子函数和操作者功能
与 ERC-20 的区别
-
授权机制:
- ERC-20:使用 approve/transferFrom
- ERC-777:使用操作者机制
-
转账通知:
- ERC-20:无通知机制
- ERC-777:提供钩子函数
-
数据支持:
- ERC-20:不支持额外数据
- ERC-777:支持附加数据
-
燃烧机制:
- ERC-20:无标准燃烧接口
- ERC-777:提供标准燃烧接口
ERC-777 是一个功能更强大的代币标准,它在保持与 ERC-20 兼容的同时,提供了更灵活和安全的代币操作方式。通过钩子函数和操作者机制,它能够支持更复杂的业务场景,同时提供了更好的安全性保障。在实现时,需要注意安全性、兼容性和用户体验等方面。
ABI 定义
[
{
"constant": true,
"inputs": [],
"name": "name",
"outputs": [{ "name": "", "type": "string" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [{ "name": "", "type": "string" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "granularity",
"outputs": [{ "name": "", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "totalSupply",
"outputs": [{ "name": "", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [{ "name": "holder", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "recipient", "type": "address" },
{ "name": "amount", "type": "uint256" },
{ "name": "data", "type": "bytes" }
],
"name": "send",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "amount", "type": "uint256" },
{ "name": "data", "type": "bytes" }
],
"name": "burn",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [
{ "name": "operator", "type": "address" },
{ "name": "tokenHolder", "type": "address" }
],
"name": "isOperatorFor",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [{ "name": "operator", "type": "address" }],
"name": "authorizeOperator",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [{ "name": "operator", "type": "address" }],
"name": "revokeOperator",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "sender", "type": "address" },
{ "name": "recipient", "type": "address" },
{ "name": "amount", "type": "uint256" },
{ "name": "data", "type": "bytes" },
{ "name": "operatorData", "type": "bytes" }
],
"name": "operatorSend",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "account", "type": "address" },
{ "name": "amount", "type": "uint256" },
{ "name": "data", "type": "bytes" },
{ "name": "operatorData", "type": "bytes" }
],
"name": "operatorBurn",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "operator", "type": "address" },
{ "indexed": true, "name": "to", "type": "address" },
{ "indexed": true, "name": "from", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" },
{ "indexed": false, "name": "data", "type": "bytes" },
{ "indexed": false, "name": "operatorData", "type": "bytes" }
],
"name": "Sent",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "operator", "type": "address" },
{ "indexed": true, "name": "from", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" },
{ "indexed": false, "name": "data", "type": "bytes" },
{ "indexed": false, "name": "operatorData", "type": "bytes" }
],
"name": "Burned",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "operator", "type": "address" },
{ "indexed": true, "name": "tokenHolder", "type": "address" }
],
"name": "AuthorizedOperator",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "operator", "type": "address" },
{ "indexed": true, "name": "tokenHolder", "type": "address" }
],
"name": "RevokedOperator",
"type": "event"
}
]
ERC-1155
ERC-1155 是一种以太坊代币标准,它定义了一种智能合约接口,可以管理多种代币类型。与 ERC-721(非同质化代币)和 ERC-20(同质化代币)不同,ERC-1155 允许在一个智能合约中同时处理这两种类型的代币,甚至还支持“半同质化”代币。
ERC-1155 的关键特性:
- 多重代币支持:
- ERC-1155 合约可以管理多种代币类型,包括:
- 同质化代币(如游戏中的金币或虚拟货币)
- 非同质化代币(如独特的游戏道具或数字艺术品)
- 半同质化代币(例如,一定数量的游戏门票,这些门票具有相同的属性,但是总数有限)
- ERC-1155 合约可以管理多种代币类型,包括:
- 批量转移:
- ERC-1155 允许一次交易转移多种代币,从而显著提高了效率并降低了 gas 成本。
- 高效性:
- 通过共享相同的合约逻辑,ERC-1155 减少了部署和管理多个合约的需要,从而节省了 gas 费用。
- 元数据 URI:
- ERC-1155 定义了一种标准的方法来存储代币的元数据(如名称、描述和图像),从而简化了数字资产的管理和显示。
- 安全转移:
- ERC-1155定义了安全转移代币的机制,避免了代币意外丢失或者被盗的情况。
ERC-1155 的应用场景:
- 游戏:
- 管理游戏中的各种资产,如道具、角色和货币。
- 数字艺术品:
- 表示限量版数字艺术品或收藏品。
- 供应链管理:
- 跟踪和管理供应链中的各种资产。
- 票务:
- 管理活动门票,优惠券等等。
ERC-1155 的优势:
- 提高了代币管理的灵活性和效率。
- 降低了交易成本。
- 简化了数字资产的开发和集成。
ERC-1155 方法:
balanceOf(address account, uint256 id):- 查询指定账户拥有的指定 ID 的代币数量。
balanceOfBatch(address[] accounts, uint256[] ids):- 批量查询多个账户拥有的多个 ID 的代币数量。
safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes data):- 安全地将指定数量的指定 ID 的代币从一个账户转移到另一个账户。
safeBatchTransferFrom(address from, address to, uint256[] ids, uint256[] amounts, bytes data):- 安全地批量将多个 ID 的代币从一个账户转移到另一个账户。
setApprovalForAll(address operator, bool approved):- 允许或禁止指定操作员代表调用者管理其所有代币。
isApprovedForAll(address account, address operator):- 查询指定操作员是否被授权代表指定账户管理其所有代币。
ERC-1155 事件:
TransferSingle(address operator, address from, address to, uint256 id, uint256 value):- 当单个代币被转移时发出。
TransferBatch(address operator, address from, address to, uint256[] ids, uint256[] values):- 当多个代币被批量转移时发出。
ApprovalForAll(address operator, address account, bool value):- 当操作员的授权状态发生变化时发出。
URI(string value, uint256 id):- 当代币的 URI(统一资源标识符)发生变化时发出。URI 通常指向包含代币元数据的 JSON 文件。
关键概念:
- ID:
- 用于唯一标识 ERC-1155 合约中不同代币的标识符。
- Amount:
- 表示要转移或查询的代币数量。
- Operator:
- 被授权代表账户管理代币的地址。
- URI:
- 统一资源标识符,指向代币元数据。
- 元数据:
- 对tokenId的描述,通常包括名称,描述,图片url等等信息。
[
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
},
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address[]",
"name": "accounts",
"type": "address[]"
},
{
"internalType": "uint256[]",
"name": "ids",
"type": "uint256[]"
}
],
"name": "balanceOfBatch",
"outputs": [
{
"internalType": "uint256[]",
"name": "",
"type": "uint256[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256[]",
"name": "ids",
"type": "uint256[]"
},
{
"internalType": "uint256[]",
"name": "amounts",
"type": "uint256[]"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "safeBatchTransferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "safeTransferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "setApprovalForAll",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
},
{
"internalType": "address",
"name": "operator",
"type": "address"
}
],
"name": "isApprovedForAll",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256[]",
"name": "ids",
"type": "uint256[]"
},
{
"indexed": false,
"internalType": "uint256[]",
"name": "values",
"type": "uint256[]"
}
],
"name": "TransferBatch",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "id",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "TransferSingle",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "account",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "value",
"type": "bool"
}
],
"name": "ApprovalForAll",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "string",
"name": "value",
"type": "string"
},
{
"indexed": true,
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "URI",
"type": "event"
}
]
EIP-2612:签名授权与业务代扣的边界
EIP-2612 为 ERC-20 增加签名授权:Owner 签署许可,任何提交者都能调用 permit 更新额度。签名不转移代币,也不自动执行 deposit 或 swap;业务合约需要主动组合调用。规范
1. 接口与签名内容
标准追加三个接口:permit(owner, spender, value, deadline, v, r, s)、nonces(owner) 和 DOMAIN_SEPARATOR()。PERMIT_TYPEHASH 是实现常用的常量,标准不要求通过一个公开 getter 暴露它。
签名的类型字符串为:
Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)
摘要计算中的地址和整数必须按照 ABI 编码补齐,不能直接拼接 20 字节地址来替代字段编码:
structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonce, deadline))
digest = keccak256(0x1901 || DOMAIN_SEPARATOR || structHash)
域和类型化哈希的编码规则见 EIP-712。DAI 风格 permit 的字段与语义不同,不能因为函数名相同就使用这里的签名结构。
2. 成功后改变了什么?
验证要求包括 Owner 非零、签名有效、签入的 nonce 匹配当前 nonce、当前时间不晚于 deadline。成功后覆盖 allowance[owner][spender]、推进 nonce 并发出 Approval;失败时该调用回滚。
deadline 是提交 permit 的截止时间,不是授权额度的有效期。例如在 12:00 前成功授权 100 个单位,12:01 时 Spender 仍可使用剩余额度;期限到达不会自动把 allowance 清零。
标准允许任何人提交已签好的 permit,所以 Relayer 可以抢先消费 nonce。这不能让其修改签名里的 Spender 或金额,但可能使后续重复提交的 permit 回滚。nonce 防止同一许可重复接受,不会自动撤销过去的 allowance。
3. 一个可运行的代币
本例固定为 Solidity 0.8.24、OpenZeppelin Contracts 5.2.0。初始持有人得到 1,000 个、18 位 decimals 的教学代币:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract LearningToken is ERC20, ERC20Burnable, ERC20Permit {
constructor(address initialOwner) ERC20("Learning Token", "LRN") ERC20Permit("Learning Token") {
_mint(initialOwner, 1_000 ether);
}
}
本版本的 ERC20Permit 用 ECDSA 验证,不自动支持 ERC-1271 合约签名。某些代币另有扩展;应按具体代币的源码与部署版本确认,不能从 ERC-1271 标准推导所有 permit 都兼容合约钱包。固定版本源码
4. 为什么可以容忍某些 permit 失败?
若把 permit 与业务代扣放在一笔交易中,授权成功但代扣失败时,本次交易里的 permit 更新也会回滚。这需要业务没有捕获后续转账失败并假装成功;不是 EIP-2612 自动组合两笔交易。
若有人已抢先提交同一许可,重复 permit 会因 nonce 不匹配而失败,但合法 allowance 已存在。OpenZeppelin 文档给出容忍该失败、继续实际代扣的模式。关键是业务身份仍有独立保障,而非只看 permit 有没有成功。集成说明
下面的教学合约绑定 owner = msg.sender、固定代币,并只给同一个调用者记账:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
// Teaching model: a fixed standard token, no shares, yield, or withdrawal logic.
contract PermitDeposit is ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable token;
mapping(address => uint256) public deposited;
constructor(IERC20 token_) {
token = token_;
}
function deposit(uint256 amount) external nonReentrant {
_deposit(amount);
}
function depositWithPermit(uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external nonReentrant {
try IERC20Permit(address(token)).permit(msg.sender, address(this), amount, deadline, v, r, s) {} catch {}
_deposit(amount);
}
function _deposit(uint256 amount) private {
token.safeTransferFrom(msg.sender, address(this), amount);
deposited[msg.sender] += amount;
}
}
try/catch 后的 safeTransferFrom 仍必须成功。没有足额 allowance 或余额就回滚,不会凭空增加 deposited。已有额度时,即使当前签名错误或过期,该接口也可以完成普通存款;这是明确选择的集成语义,不代表无效签名被接受。
该模型不适合直接复制成允许任意 Relayer、任意 from 和任意收款人的路由。若需要代提交,应额外验证绑定 Owner、金额、收款人、执行条件和消费状态的业务签名,或采用明确的可信转发上下文。
本例也不处理手续费代币、rebasing 或提款。生产会计若使用不保证到账数量的代币,需要按真实到账量和业务模型设计,不能将 amount 无条件当作收到的资产。
5. Permit2 不是同一种额度模型
用户通常先在原始 ERC-20 上授权 Permit2,之后由 Permit2 验证业务许可。基础 ERC-20 授权仍是单独的权限层。
| Permit2 模块 | 许可的使用方式 | 撤销时要考虑什么 |
|---|---|---|
AllowanceTransfer | 按 Owner、Token、Spender 维护额度、expiration 与 nonce | 当前额度和尚未提交的有效许可分别处理 |
SignatureTransfer | 用一次性签名请求转账,消费无序 nonce | 按 nonce bitmap 使未使用签名失效 |
Witness 用来把额外业务条件纳入签名,不是所有 Permit2 调用必有的“一次性票据”。路由仍必须绑定预期调用者及收款行为,不能把一份给路由的许可当成任意提款权。Permit2 概览、SignatureTransfer
6. 运行测试
完整工程:examples/ethereum-signatures。进入该目录后执行:
npm ci --ignore-scripts
forge test --match-test 'testPermit|testFrontRun|testExpiredPermit|testTransferFailure|testFakePermit|testInvalidPermit' -vv
观察成功许可与代扣只消费一个 nonce;重放失败;抢先提交后 Owner 仍可存款;deadline 后旧额度仍可使用;后续转账失败会回滚许可;无额度不能靠错误签名完成存款。
参考资料
EIP-3009: 签名划转与原子化代扣标准 (Transfer With Authorization)
EIP-3009 定义了基于 EIP-712 签名的代币直接转移与代扣授权扩展标准。
与 EIP-2612(仅更新 allowance 映射、仍需随后调用 transferFrom)不同,EIP-3009 允许签名人授权直接将资金从其账户中划出或接收,一步完成资金物理转移(Zero-step Transfer)。该标准由 Centre 机构(Circle & Coinbase)制定,并在以太坊原生 USDC (FiatTokenV2 及以上版本) 中作为核心支付基础设施全面部署。
核心接口规范
EIP-3009 定义了单向划转(Transfer)、双向接收(Receive)以及作废授权的核心接口:
interface IERC3009 {
/**
* @notice 基于签名直接执行转账操作(任何人均可作为 relayer 代为提交)
*/
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external;
/**
* @notice 基于签名接收转账,强制要求调用者(msg.sender)必须等于 to 地址,防范抢跑
*/
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external;
/**
* @notice 提前作废指定的一组授权 Nonce
*/
function cancelAuthorization(
address authorizer,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external;
/**
* @notice 查询指定地址的 Nonce 状态
*/
function authorizationState(address authorizer, bytes32 nonce) external view returns (bool);
}
状态机模型:位图散列 Nonce vs 自增 Nonce
EIP-3009 与 EIP-2612 在防重放状态机设计上存在根本区别:
| 设计维度 | EIP-2612 (Permit) | EIP-3009 (Transfer With Authorization) |
|---|---|---|
| Nonce 类型 | 严格单调递增整数 uint256 | 任意 32 字节随机哈希 bytes32 |
| 执行时序约束 | 强时序耦合(Nonce \(N\) 未被执行前,无法执行 \(N+1\)) | 完全乱序并发(Out-of-order Execution) |
| 存储标记结构 | 单一存储槽更新 _nonces[owner]++ | 离散状态映射 mapping(address => mapping(bytes32 => bool)) |
| 适用业务场景 | 账户额度变更、借贷按序存入 | 高并发微支付、无序批量结算、去中心化电商结账 |
核心数学定义与 TypeHash
1. 结构体类型哈希
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256(
"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
);
bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256(
"ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
);
2. 结构化哈希计算
$$ \text{structHash} = \text{keccak256}\Big(\text{TYPEHASH} ;\parallel; from ;\parallel; to ;\parallel; value ;\parallel; validAfter ;\parallel; validBefore ;\parallel; nonce\Big) $$
$$ \text{Digest} = \text{keccak256}\Big(\texttt{0x1901} ;\parallel; \text{DOMAIN_SEPARATOR} ;\parallel; \text{structHash}\Big) $$
防抢跑机制:receiveWithAuthorization 的安全设计
在去中心化支付与自动化订单结算中,单纯依赖 transferWithAuthorization 存在被套利机器人(MEV Searcher)在公开内存池中“前置截留(Front-running)”代为提交的现象(虽然资金依然流向指定 to,但破坏了收款合约要求由其发起调用的原子上下文)。
receiveWithAuthorization 强制引入了调用者绑定:
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v, bytes32 r, bytes32 s
) external {
// 强制防抢跑约束:仅允许收款人(to)以 msg.sender 身份触发
require(msg.sender == to, "FiatTokenV2: caller must be the payee");
// 校验有效时间区间
require(block.timestamp > validAfter, "FiatTokenV2: auth not yet valid");
require(block.timestamp < validBefore, "FiatTokenV2: auth expired");
// 校验并作废 Nonce
require(!_authorizationStates[from][nonce], "FiatTokenV2: auth already used");
_authorizationStates[from][nonce] = true;
// 验签并直接划转资金
bytes32 digest = _computeDigest(...);
require(ECDSA.recover(digest, v, r, s) == from, "FiatTokenV2: invalid signature");
_transfer(from, to, value);
}
收款合约(如商户网关、质押合约)可以在自身业务逻辑中调用该函数,安全地将用户的 USDC 一步直接拉入自身金库,无需事先赋予中间授权。
参考文献与规范
EIP-3156: 闪电贷标准 (Flash Loans Standard)
EIP-3156 定义了以太坊上**闪电贷(Flash Loan)**的标准化接口与回调规范。
在 EIP-3156 出台前,不同借贷与做市协议(如 Aave、dYdX、Uniswap V2 Flash Swap)各自实现了一套私有的闪电贷方法签名与回调函数。借贷套利机器人、自动清算合约以及抵押品置换协议必须针对不同平台开发繁杂的适配器。EIP-3156 通过规范放贷方接口 IERC3156FlashLender 与借贷方接口 IERC3156FlashBorrower,确立了原子化无抵押借贷的行业标准。
核心接口定义
EIP-3156 严格解耦了放贷方(Lender)与借款方(Borrower)的职责:
sequenceDiagram
autonumber
actor User as 交易发起者 / 套利者
participant Borrower as 闪电借款合约 (Borrower)
participant Lender as 闪电放贷协议 (Lender)
participant DEX as 外部套利 / 清算市场
User->>Borrower: 触发套利策略
Borrower->>Lender: 调用 flashLoan(borrower, token, amount, data)
Note over Lender: 1. 记账前置:确认资金池有足够余额并锁定互斥状态
Lender->>Borrower: 转出指定代币 (无抵押物理放款)
Lender->>Borrower: 触发回调 onFlashLoan(initiator, token, amount, fee, data)
Note over Borrower: 2. 执行套利: 调动流动性在外部市场清算/兑换
Borrower->>DEX: 交换资产获取利润
Borrower->>Borrower: 确保自身持有 >= 本金 + 手续费
Borrower-->>Lender: 返回魔数 keccak256("ERC3156FlashBorrower.onFlashLoan")
Note over Lender: 3. 闭环验证:断言魔数匹配并通过 transferFrom 扣回本息
Lender->>Lender: 若扣回失败或金额不足,整笔交易 Revert
1. 放贷方接口:IERC3156FlashLender
interface IERC3156FlashLender {
/**
* @notice 查询指定代币当前最大允许借出的闪电贷额度
* @param token 拟借出的代币合约地址
* @return 若代币不支持则返回 0,否则返回池内可用最大流动性
*/
function maxFlashLoan(address token) external view returns (uint256);
/**
* @notice 查询借出指定额度所需支付的手续费
* @param token 拟借出的代币合约地址
* @param amount 拟借出的资产数量
* @return 应支付的手续费数量(若不支持则强制 Revert)
*/
function flashFee(address token, uint256 amount) external view returns (uint256);
/**
* @notice 发起闪电贷
* @param receiver 接收代币并执行回调的借款合约地址
* @param token 拟借出的代币地址
* @param amount 借贷本金
* @param data 传递给借款合约回调的任意业务参数
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool);
}
2. 借贷方接口:IERC3156FlashBorrower
interface IERC3156FlashBorrower {
/**
* @notice 借款方接收代币后的执行回调钩子
* @param initiator 触发该笔 flashLoan 的直接发起者地址
* @param token 借入的代币地址
* @param amount 借入的本金数额
* @param fee 需额外归还的手续费数额
* @param data 透传的自定义参数
* @return 必须返回固定的 32 字节魔数:keccak256("ERC3156FlashBorrower.onFlashLoan")
*/
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32);
}
生产级状态机逻辑与闭环校验
1. 魔数确认机制 (Magic Value Verification)
标准规定成功回调必须返回确定的哈希魔数:
$$ \text{CALLBACK_SUCCESS} = \text{keccak256}(\texttt{“ERC3156FlashBorrower.onFlashLoan”}) $$
设计初衷:防止放贷合约在向任意普通合约或仅拥有回退函数(fallback)的合约转账时,将意外成功的空调用误判为合法借款逻辑完成。
2. 标准放贷合约参考实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract StandardFlashLender is IERC3156FlashLender, ReentrancyGuard {
bytes32 public constant CALLBACK_SUCCESS =
keccak256("ERC3156FlashBorrower.onFlashLoan");
// 费率基点 (例如 9 bps = 0.09%)
uint256 public constant FEE_BPS = 9;
uint256 public constant BPS_DENOMINATOR = 10000;
function maxFlashLoan(address token) external view override returns (uint256) {
return IERC20(token).balanceOf(address(this));
}
function flashFee(address token, uint256 amount) public view override returns (uint256) {
return (amount * FEE_BPS) / BPS_DENOMINATOR;
}
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external override nonReentrant returns (bool) {
uint256 fee = flashFee(token, amount);
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
require(amount <= balanceBefore, "IERC3156: exceed max loan");
// 1. 无抵押借出本金
IERC20(token).transfer(address(receiver), amount);
// 2. 触发借款方回调逻辑
bytes32 callbackResult = receiver.onFlashLoan(
msg.sender,
token,
amount,
fee,
data
);
require(callbackResult == CALLBACK_SUCCESS, "IERC3156: invalid callback return");
// 3. 强制扣回本金与手续费
IERC20(token).transferFrom(address(receiver), address(this), amount + fee);
// 4. 严格断言资金闭环不变量 (Invariant Check)
require(
IERC20(token).balanceOf(address(this)) >= balanceBefore + fee,
"IERC3156: balance check failed"
);
return true;
}
}
生产安全与审计威胁模型
- 回调伪造风险(Borrower 视角):
- 借款合约的
onFlashLoan是公开接口(external)。攻击者可自行直接向借款合约调用onFlashLoan,试图触发借款合约内部的资金批准与代扣逻辑。 - 防御准则:Borrower 必须在回调函数首行严格校验:
require(msg.sender == TRUSTED_LENDER, "Untrusted lender"); require(initiator == address(this), "Untrusted initiator");
- 借款合约的
- 重入与操纵(Lender 视角):
- 放贷合约在向 Borrower 转移资产时发生了外部执行流转移,必须配备严密的防重入机制(
nonReentrant),防止借款人在回调中反向进入协议的其他质押、存取接口,利用瞬态失真账本套利。
- 放贷合约在向 Borrower 转移资产时发生了外部执行流转移,必须配备严密的防重入机制(
- 手续费向下截断套利:
- 手续费计算
(amount * fee) / 10000若借贷极微小数量,截断为 0,可能导致无手续费调用放大借款频次。协议应设定最小绝对手续费。
- 手续费计算
参考文献与规范
ERC-4337:通过合约实现账户抽象
账户抽象允许账户用可编程规则决定谁能操作资产、如何执行操作以及如何承担费用。ERC-4337 提供了一条通过合约和链下基础设施实现这些能力的路径,其基本机制不要求修改以太坊的共识规则。
用户提交 UserOperation,打包者(Bundler)把操作放入普通链上交易,EntryPoint 合约协调账户验证、执行和费用结算。用户使用的是合约钱包,但底层区块链仍然在处理普通交易。
经典合约账户流程以 EntryPoint v0.7.0 为基准。v0.6 的 UserOperation、v0.7 的链上打包结构,以及后续版本与 EIP-7702 的集成存在差异,接入时必须同时确认网络、EntryPoint 地址和版本。
为什么需要合约账户
传统 EOA 的交易授权主要由私钥签名决定。合约账户则可以在代码中实现多签、会话密钥、额度限制、恢复流程或批量调用,不必把所有权限交给同一把长期私钥。
ERC-4337 为这些账户提供了提交和结算操作的通用流程,并不自动提供上述钱包功能。恢复是否安全、会话密钥能做什么、批量调用是否原子执行,都由账户实现和相关服务决定。
一次操作如何上链
flowchart TD
User[用户与钱包客户端] -->|生成并授权 UserOperation| Bundler[Bundler:接收、模拟与打包]
Pool[可选:UserOperation P2P 内存池] <-->|传播操作| Bundler
Bundler -->|普通交易调用 handleOps| EntryPoint[选定版本的 EntryPoint]
EntryPoint -->|必要时通过工厂部署账户| Factory[账户工厂]
Factory --> Account[合约账户]
EntryPoint -->|验证授权与资金条件| Account
EntryPoint -->|可选:验证代付资格| Paymaster[Paymaster]
EntryPoint -->|调用账户的 callData| Account
Account -->|执行授权业务| Target[目标合约]
- 钱包准备操作,包括账户地址、nonce、调用数据、费用上限和授权数据。
- Bundler 在链下模拟验证,选择可接受的操作,并用普通交易调用 EntryPoint 的
handleOps()。 - EntryPoint 验证各账户及可选的 Paymaster,检查 nonce、有效时间和预付费用条件。
- 验证通过后,EntryPoint 依次调用账户;账户执行实际业务,随后结算费用。
UserOperation 不等于一笔以太坊协议层交易,也没有协议层必须接纳的独立交易类型。客户端通常通过 Bundler 的 RPC 提交它;开放 P2P 内存池是一种传播方式,并非每个服务都必须采用相同的内存池。
UserOperation 描述什么
v0.7 在链上使用以下打包结构。RPC 可以用拆分字段表示同一操作,不能把 RPC 对象直接当成 Solidity 的 ABI 结构传入。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
| 字段 | 含义 |
|---|---|
sender、nonce | 合约账户地址与防重放序列信息 |
initCode | 首次部署需要的工厂地址及调用数据;账户已部署时通常为空 |
callData | EntryPoint 将发送给账户的调用数据,由账户再执行业务 |
accountGasLimits | 打包后的验证和账户执行 Gas 上限 |
preVerificationGas | 补偿交易数据、打包及其他无法直接计入单个操作测量的链上开销 |
gasFees | 打包后的最高优先费与最高 Gas 单价 |
paymasterAndData | 可选的代付方地址、其 Gas 限额和附加数据 |
signature | 账户理解的授权数据,不限于单个 ECDSA 签名 |
preVerificationGas 不是“链下模拟实际消耗的 Gas”。模拟是 Bundler 的链下工作;该字段主要用于链上交易成本的分摊。Gas 上限、预付金额和实际结算费用也不能混为一谈。
账户、EntryPoint 与工厂各自负责什么
账户通过 validateUserOp() 验证授权,并按接口约定返回签名结果和有效时间等信息。账户应只允许受信任的 EntryPoint 调用这一入口,业务执行入口也需要独立的权限检查,避免绕过授权流程。
EntryPoint 管理验证流程、nonce 和费用记账。它是某条链上、某个版本的协调合约,不是整个生态永久唯一的全局合约。账户通常绑定特定 EntryPoint,签名也应绑定操作内容、链和 EntryPoint,避免跨环境重放。
账户尚未部署时,EntryPoint 通过工厂调用创建账户,再继续验证。很多工厂使用 CREATE2,使钱包能提前算出地址;部署方式由工厂实现决定,不能说 EntryPoint 自己固定用 CREATE2 创建所有账户。
v0.7 的 nonce 分为 192 位 key 和 64 位 sequence。不同 key 可以维护独立序列,减少不同会话之间的顺序依赖;这不表示 EVM 会并行执行这些操作,handleOps() 仍按流程顺序执行。
谁承担 Gas
Bundler 先承担普通交易的链上 Gas 费用,EntryPoint 再从账户或 Paymaster 的资金中结算操作费用,将收集的费用支付给指定的 beneficiary。用户看到的“免 Gas”,通常是其他主体代付,链上执行本身仍然有成本。
没有 Paymaster 时,由账户承担费用:使用其在 EntryPoint 中的 ETH 存款,必要时在验证过程中补足资金。有 Paymaster 时,由 Paymaster 的 ETH 存款承担费用,账户是否符合代付条件由其策略检查。
所谓“使用 ERC-20 支付手续费”,通常是 Paymaster 用 ETH 对 EntryPoint 结算,再按自己的规则向用户收取代币。ERC-4337 没有把以太坊底层 Gas 支付资产改成 ERC-20。
EntryPoint 中的两类资金需要分清:
- Deposit(存款):用于支付操作费用,需要有足够余额支持打包。
- Stake(质押):具有解锁延迟,配合内存池规则和信誉机制约束服务实体;不是每次操作扣费的账户,也不意味着协议会自动罚没。
验证失败与执行失败
链下模拟失败时,Bundler 可以拒绝或移除操作,避免提交已知无效的交易。但模拟成功不保证链上一定成功,因为状态可能在两者之间发生变化。
在 EntryPoint v0.7.0 参考实现中,handleOps() 先验证整批操作。某项验证失败可能使整笔打包交易回滚,并不是链上自动剔除坏操作后继续执行。Bundler 仍可能因此损失交易费用。
进入执行阶段后,账户业务调用的普通回滚通常会被记录为该操作失败,其他操作可以继续,已消耗的 Gas 仍会结算。外层 Gas 不足等异常仍可能影响整批交易,因此也不能保证所有执行错误都只影响单个操作。
安全边界
开放内存池需要防止昂贵或容易失效的验证。相关验证规则会约束操作码和存储访问,细则见 ERC-7562;这些是验证与打包规则,不能理解成整个 EVM 从此禁止读取时间或其他合约的状态。
账户抽象把授权策略交给代码,也把账户实现、升级权限、恢复机制和代付策略纳入了安全边界。评估钱包时,应同时查看这些规则,而不能仅凭“支持 ERC-4337”判断其安全性或功能完整度。
参考资料
- ERC-4337:账户抽象规范
- EntryPoint v0.7.0:验证、执行与结算实现
- PackedUserOperation v0.7.0:链上结构定义
- StakeManager v0.7.0:存款与质押
- ERC-7562:账户抽象验证规则
ERC-4626: 代币化金库会计标准 (Tokenized Vault Standard)
ERC-4626 是以太坊针对**产生收益的代币化金库(Yield-bearing Vaults)**制定的统一会计接口标准。
在 ERC-4626 之前,各大借贷与质押协议(如 Compound 的 cToken、Aave 的 aToken、Yearn 的 yVault)各自维护了一套私有的份额(Shares)与底层资产(Assets)计算公式。接口割裂导致聚合器、机枪池与借贷衍生品必须为每个协议编写高度耦合的适配器代码。ERC-4626 确立了统一的存款、铸币、提取、赎回状态机与精度换算范式。
核心数学模型:资产与份额的动态换算
金库通常持有某种底层代币(asset),并向存入资产的用户按比例铸造代表金库索赔权的凭证代币(shares)。
1. 基础换算比例公式
$$ \text{shares} = \text{assets} \times \frac{\text{totalSupply}}{\text{totalAssets}} $$
$$ \text{assets} = \text{shares} \times \frac{\text{totalAssets}}{\text{totalSupply}} $$
totalAssets():当前金库所管理并拥有控制权的底层资产物理总量。totalSupply():当前已发行的金库凭证份额总量。
2. 整数除法截断与舍入方向约束(Rounding Direction)
EVM 仅支持向零截断的整数算术。ERC-4626 明确规定了所有计算的舍入偏向,必须始终偏向保护金库账本完整性、惩罚提取套利:
| 操作接口 | 核心算式 | 规范舍入方向 (Rounding) | 经济学目的 |
|---|---|---|---|
previewDeposit(assets) / deposit | 计算获得的 shares | 向下取整(Round Down) | 避免用户存入资产时多得份额,防止凭空印钞 |
previewMint(shares) / mint | 计算需付出的 assets | 向上取整(Round Up) | 确保用户存足全额资产才可获得指定份额 |
previewWithdraw(assets) / withdraw | 计算需扣除的 shares | 向上取整(Round Up) | 提取资产时多销毁微量份额,保护剩余存款人 |
previewRedeem(shares) / redeem | 计算返还的 assets | 向下取整(Round Down) | 销毁份额时少给微量底层资产,收益沉淀于池中 |
核心状态机与标准接口
ERC-4626 扩展了 ERC-20 标准,包含四组对称的存款与提取方法:
interface IERC4626 is IERC20 {
// 底层资产地址
function asset() external view returns (address assetTokenAddress);
// 金库总管理资产数
function totalAssets() external view returns (uint256 totalManagedAssets);
// 1. 基于资产数存入 (Deposit: 指定资产,换取份额)
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
// 2. 基于份额数存入 (Mint: 指定目标份额,支付资产)
function mint(uint256 shares, address receiver) external returns (uint256 assets);
// 3. 基于资产数提取 (Withdraw: 指定提取资产数,销毁份额)
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
// 4. 基于份额数赎回 (Redeem: 指定销毁份额数,返还资产)
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
// 查询与模拟接口 (无状态开销)
function convertToShares(uint256 assets) external view returns (uint256 shares);
function convertToAssets(uint256 shares) external view returns (uint256 assets);
function maxDeposit(address receiver) external view returns (uint256 maxAssets);
function maxMint(address receiver) external view returns (uint256 maxShares);
function maxWithdraw(address owner) external view returns (uint256 maxAssets);
function maxRedeem(address owner) external view returns (uint256 maxShares);
function previewDeposit(uint256 assets) external view returns (uint256 shares);
function previewMint(uint256 shares) external view returns (uint256 assets);
function previewWithdraw(uint256 assets) external view returns (uint256 shares);
function previewRedeem(uint256 shares) external view returns (uint256 assets);
}
初始存款通胀攻击与虚拟偏移量防御 (Virtual Offset)
1. 攻击根因复盘
在初始空池(\(\text{totalSupply} = 0\))状态下,攻击者先存入 1 wei 资产获得 1 wei 份额,随后通过直接 transfer 向金库地址强制捐赠 \(10^6\) 个代币拉升每股净值。后续真实用户存入资金时,由于向下截断,计算出的 shares 为 0,资金被全额吞并。
2. 虚拟资产与虚拟份额偏移量解法 (OpenZeppelin 方案)
OpenZeppelin ERC4626 实现中引入了虚拟偏移量(如增加 \(10^{\text{decimals}}\)):
$$ \text{shares} = \text{assets} \times \frac{\text{totalSupply} + 10^a}{\text{totalAssets} + 1} $$
$$ \text{assets} = \text{shares} \times \frac{\text{totalAssets} + 1}{\text{totalSupply} + 10^a} $$
通过人为在分母分子中注入虚拟底数,即使在极端空池状态下,1 wei 的小额建仓也无法将单位净值拉升至发生严重截断的区间。
生产级安全检查清单
- 严格对齐舍入方向:所有换算函数必须严格遵循“存入少给份额,提取多扣份额”的原则,严禁使用原生除法默认向零截断造成方向颠倒。
- 防范初始通胀与捐赠攻击:必须配置虚拟偏移量机制,或在初始化阶段铸造并死锁最小基础份额(如向
address(0)锁定 1000 shares)。 - 防范零份额铸造与充值:在
deposit与mint逻辑中加入require(shares > 0)断言,防止用户资产被无偿吞没。 - 脱钩直接余额物理依赖:避免单纯依赖
asset.balanceOf(address(this))计算totalAssets(),采用内部记账抵御闪电贷或直接转账对汇率的单区块瞬态冲击。
参考文献与规范
ERC-1400: 证券型代币标准(Security Token Standard)
ERC-1400 是面向**证券型代币(Security Token)**的标准库:在 ERC-20 基础上增加转账限制、错误码、分区(partition)、文档管理与强制转账(controller)能力,用于合规地发行和管理受监管的数字证券。它不是单一接口,而是整合了 ERC-1410(部分同质化)、ERC-1594(核心转账校验)、ERC-1643(文档管理)、ERC-1644(controller 操作)四部分的组合标准。
该标准由 Security Token Standard 社区维护(Polymath 等推动),已从官方 EIPs 仓库移出,接口以 SecurityTokenStandard/EIP-Spec 为准。
设计动机
普通代币(ERC-20)的转账只需余额足够;证券代币的转账可能因为下列原因失败:
- 交易对手未通过 KYC/AML;
- 代币处于锁仓期(lock-up);
- 交易对方属于受限类别(如发行人关联方);
- 代币级别规则:最大持币人数、单投资者占比上限。
ERC-1400 提供“转账前可预检 + 失败原因码 + 链下数据注入“的机制,让链上和链下都能判断一笔转账是否合规。
组成模块
| 模块 | 职责 |
|---|---|
| ERC-1410 | 分区余额(partitions),余额按 bytes32 partition 拆分 |
| ERC-1594 | 转账校验(canTransfer)、链下数据注入(transferWithData)、发行/赎回语义 |
| ERC-1643 | 文档管理(getDocument / setDocument) |
| ERC-1644 | controller 强制转账(controllerTransfer / controllerRedeem) |
核心接口(IERC1400,继承 IERC20)
interface IERC1400 is IERC20 {
// 文档管理
function getDocument(bytes32 _name) external view returns (string, bytes32);
function setDocument(bytes32 _name, string _uri, bytes32 _documentHash) external;
// 分区信息
function balanceOfByPartition(bytes32 _partition, address _tokenHolder) external view returns (uint256);
function partitionsOf(address _tokenHolder) external view returns (bytes32[]);
// 带数据转账(全同质化)
function transferWithData(address _to, uint256 _value, bytes _data) external;
function transferFromWithData(address _from, address _to, uint256 _value, bytes _data) external;
// 分区转账(部分同质化)
function transferByPartition(bytes32 _partition, address _to, uint256 _value, bytes _data) external returns (bytes32);
function operatorTransferByPartition(bytes32 _partition, address _from, address _to, uint256 _value, bytes _data, bytes _operatorData) external returns (bytes32);
// Controller(监管方 / 发行方)
function isControllable() external view returns (bool);
function controllerTransfer(address _from, address _to, uint256 _value, bytes _data, bytes _operatorData) external;
function controllerRedeem(address _tokenHolder, uint256 _value, bytes _data, bytes _operatorData) external;
// Operator 管理
function authorizeOperator(address _operator) external;
function revokeOperator(address _operator) external;
function isOperator(address _operator, address _tokenHolder) external view returns (bool);
// 发行 / 赎回
function isIssuable() external view returns (bool);
function issue(address _tokenHolder, uint256 _value, bytes _data) external;
function redeem(uint256 _value, bytes _data) external;
function redeemFrom(address _tokenHolder, uint256 _value, bytes _data) external;
// 转账预检
function canTransfer(address _to, uint256 _value, bytes _data) external view returns (byte, bytes32);
function canTransferFrom(address _from, address _to, uint256 _value, bytes _data) external view returns (byte, bytes32);
}
关键概念
分区(Partition)
余额可以按 bytes32 分区拆分,例如“一级市场发行所得“与“二级市场交易所得“分开记账。balanceOfByPartition 查询某分区余额;所有分区余额之和等于总余额。分区元数据决定该部分代币的锁仓与转让限制。
带数据转账
transferWithData / transferByPartition 接受额外的 bytes _data 参数,用于注入链下签名数据(如券商对这笔交易的审批)。数据格式由实现定义,不在标准范围内。
转账预检与错误码
canTransfer 返回 (byte statusCode, bytes32 reason),遵循 EIP-1066 的应用状态码:
| 码 | 含义 |
|---|---|
0x50 | 转账失败(通用) |
0x51 | 转账成功 |
0x52 | 余额不足 |
0x53 | 授权额度不足 |
0x54 | 合约暂停 |
0x55 | 锁仓期 |
0x56 | 发送方无效 |
0x57 | 接收方无效 |
0x58 | operator 无效 |
0x5f | 代币元数据 / 信息 |
Controller 强制操作
当 isControllable() == true 时,controller(监管机构、过户代理或发行方)可以绕过持有人授权执行 controllerTransfer / controllerRedeem,用于司法冻结、找回丢失私钥、纠正欺诈交易。强制操作必须发出 ControllerTransfer / ControllerRedemption 事件,保证透明可审计。
事件
TransferByPartition(fromPartition, operator, from, to, value, data, operatorData)
ChangedPartition(fromPartition, toPartition, value)
AuthorizedOperator(operator, tokenHolder) / RevokedOperator(operator, tokenHolder)
ControllerTransfer(controller, from, to, value, data, operatorData)
ControllerRedemption(controller, tokenHolder, value, data, operatorData)
Document(name, uri, documentHash)
Issued(operator, to, value, data) / Redeemed(operator, from, value, data)
IssuedByPartition(...) / RedeemedByPartition(...)
对照
| ERC-20 | ERC-1400 | |
|---|---|---|
| 转账校验 | 余额 + allowance | 余额 + 身份 + 合规规则 + 链下数据 |
| 失败反馈 | 无(revert) | canTransfer 错误码 |
| 余额模型 | 单一 | 分区(partial fungibility) |
| 强制操作 | 无 | controllerTransfer / controllerRedeem |
| 文档 | 无 | getDocument / setDocument |
关联标准
- ERC-1410 / 1594 / 1643 / 1644:组成模块,见上文。
- ERC-3643(T-REX):另一条证券代币路线,用链上身份(claim)+ 合规合约替代 ERC-1400 的分区模型,实际生产应用更广。
- ERC-725:链上身份,可与 ERC-1400 的
canTransfer配合做身份校验。
ERC-3525: 半同质化代币规范 (Semi-Fungible Token - SFT)
ERC-3525 定义了**半同质化代币(Semi-Fungible Token, SFT)**标准。
在数字资产建模中,ERC-20 代币是完全同质化的(数值可随意拆分与合并,但无法承载独立的特征属性);ERC-721 NFT 是完全非同质化的(每个 TokenId 全局唯一,但无法直接拆分数量或进行局部转账)。在现实商业世界与高阶金融衍生品中,存在大量既具备独特独立属性、又包含可拆分数值属性的资产,例如:附带利息与到期日的债券、分批发放的归属凭证(Vesting Allocations)、碳信用额度以及存折票据。ERC-3525 确立了统一的“<ID, SLOT, VALUE>”三元组资产模型。
核心数据模型:三元组体系结构
ERC-3525 通过在 ERC-721 基础之上引入槽位(Slot)与面值(Value),实现了资产分类与数值流转的解耦:
$$ \text{Asset} = \langle \text{TokenId}, ; \text{Slot}, ; \text{Value} \rangle $$
graph TD
subgraph 资产结构划分
SlotA["Slot 1: 2026 年到期国债 (年化 5%)"]
SlotB["Slot 2: 2027 年到期企业债 (年化 8%)"]
end
SlotA --> Token1["TokenId #101<br/>持有面值: 5,000 USD"]
SlotA --> Token2["TokenId #102<br/>持有面值: 12,000 USD"]
SlotB --> Token3["TokenId #201<br/>持有面值: 80,000 USD"]
Token1 -. "允许拆分/转账 Value (同 Slot 互通)" .-> Token2
- TokenId (唯一凭证标识):遵循 ERC-721 规范,全局唯一,归属于特定的拥有者账户(Owner)。
- Slot (属性槽位 / 业务分类):定义资产的内在属性集合(如金融产品类型、计息规则、到期时间戳)。只有当两个 TokenId 的 Slot 完全相同时,它们持有的面值才可以相互转移或合并。
- Value (可拆分面值):当前 TokenId 所拥有的具体数值份额,支持数学上的加减合并与按需拆分。
核心状态机与流转接口
ERC-3525 包含三种不同层级的资产转移语义:
interface IERC3525 /* is IERC165, IERC721 */ {
event TransferValue(uint256 indexed fromTokenId, uint256 indexed toTokenId, uint256 value);
event ApprovalValue(uint256 indexed tokenId, address indexed operator, uint256 value);
event SlotChanged(uint256 indexed tokenId, uint256 indexed oldSlot, uint256 indexed newSlot);
// 查询该 TokenId 所属的属性槽位
function slotOf(uint256 tokenId) external view returns (uint256);
// 查询该 TokenId 持有的可拆分面值
function balanceOf(uint256 tokenId) external view returns (uint256);
// 1. 同 Slot 内:将面值从一个 Token 转移至另一个已有 Token
function transferFrom(
uint256 fromTokenId,
uint256 toTokenId,
uint256 value
) external payable;
// 2. 拆分铸造:将面值从源 Token 扣减,并在目标地址直接铸造一个全新的 TokenId 接收该面值
function transferFrom(
uint256 fromTokenId,
address to,
uint256 value
) external payable returns (uint256 toTokenId);
}
参考文献与规范
ERC-3643: 许可制现实世界资产代币化标准 (T-REX / RWA Token Standard)
ERC-3643(最初被称为 T-REX,Token for Regulated EXchanges)是以太坊针对**合规证券型代币(Security Tokens)与现实世界资产(Real-World Assets, RWA)**制定的机构级许可代币标准。
普通的 ERC-20 代币是无许可(Permissionless)的,任何地址均可自由接收和转账。然而,在股票、合规基金、不动产或债券代币化的现实金融监管中,发行方必须严格满足 KYC(了解你的客户)、AML(反洗钱)以及特定司法管辖区的投资者资质准入要求。ERC-3643 在保持 ERC-20 基础转账接口兼容的前提下,在底层注入了基于链上**去中心化身份(Decentralized Identity, ONCHAINID)**与合规引擎的强约束鉴权机制。
四大支柱架构体系
flowchart TD
subgraph 交易流转
Alice[持有者 Alice] -->|transfer| Token[ERC-3643 代币合约]
Token -->|转出资产| Bob[接收者 Bob]
end
subgraph 合规校验中枢
Token -->|1. 检查资格 isVerified| IDRegistry[身份注册表 Identity Registry]
IDRegistry -->|查询链上身份凭证| ClaimTopics[ONCHAINID / Claim Issuer]
Token -->|2. 检查合规规则 canTransfer| Compliance[合规规则引擎 Compliance Engine]
end
1. 核心代币合约 (Token Core)
- 兼容标准 ERC-20 接口与元数据展示,但在每次
transfer或transferFrom时,强制在前置状态机中调用合规引擎与身份注册表进行原子校验。若未通过,交易直接 Revert。
2. 链上身份注册表 (Identity Registry)
- 将用户的以太坊钱包地址映射至其链上身份合约(
ONCHAINID)。 - 管理受信任的权威凭证签发机构(Claim Issuers)列表与合规主题类别(Claim Topics,如是否属于美国合格投资者、是否完成实名认证)。
3. 合规规则引擎 (Compliance Engine)
- 维护动态的法律法规与发行方商业策略约束:
- 单一地址持有代币上限限制(防止单体垄断)。
- 最大投资者人数硬顶(满足特定豁免法案上限)。
- 国别黑名单/白名单及强制锁仓期(Lock-up Period,如 Reg S / Reg D 要求的 12 个月限售期)。
4. 资产保全与法律特权接口 (Legal Force Recovery)
由于现实资产在法律层面对发行人拥有追索权要求,ERC-3643 内建了受监管的应急救济原语:
- 强制销毁与追回(
forcedTransfer):在持有者私钥丢失或面临司法判决时,由特权监管角色(Agent)在出具链下证明后强制转移资产。 - 动态冻结(
freezePartialTokens):针对涉及洗钱嫌疑的特定涉案账户进行局部或全部资金冻结。
参考文献与规范
ERC-7201:命名空间存储布局
ERC-7201 为模块结构体指定独立存储根,使业务存储不完全依赖合约继承的线性布局。命名空间布局可以与普通 Solidity 布局并存;它不要求使用钻石代理,也不自动保证升级兼容。
根槽位派生
命名空间标识符 id 的根槽位使用以下 Solidity 表达式计算:
keccak256(abi.encode(uint256(keccak256(bytes(id))) - 1))
& ~bytes32(uint256(0xff))
id 是模块的命名空间字符串。第一次哈希减一得到与常规布局分离的位置;再次哈希避免较大结构扩展到普通布局的某些派生槽。隔离性质依赖哈希安全和合理的布局范围假设。
低 8 位清零使根按 256 槽对齐。规范将其描述为未来 Verkle 状态结构下可能获得的访问优化,当前 EVM 不会因此统一预热这 256 个槽。
模块内部布局
结构体成员仍遵循 Solidity 的打包规则。多个小字段可能共用一槽,uint256 通常独占一槽,mapping 和动态数组通过哈希派生数据位置。字段顺序与槽偏移并不是一一对应。
对齐不表示模块只能使用 256 槽,也不是顺序字段可直接偏移寻址的原因。固定成员偏移来自结构体布局,mapping 与动态数组仍有其独立寻址规则。
结构体定位示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library TokenStorage {
bytes32 internal constant ROOT = 0x792b309ed1024743406dc085f9859a5c6895fd893b6d7dafa1a05c10ff9c8f00;
/// @custom:storage-location erc7201:example.storage.ERC20
struct Layout {
mapping(address => uint256) balances;
uint256 totalSupply;
}
function layout() internal pure returns (Layout storage state) {
bytes32 slot = ROOT;
assembly {
state.slot := slot
}
}
}
contract NamespacedState {
function balanceOf(address account) external view returns (uint256) {
return TokenStorage.layout().balances[account];
}
function totalSupply() external view returns (uint256) {
return TokenStorage.layout().totalSupply;
}
}
该根由 example.storage.ERC20 按标准公式计算。NatSpec 注解供布局工具识别命名空间,不会自动改变代码的实际存储位置;汇编定位与声明需要保持一致。
升级兼容与迁移
独立根降低了不同模块之间因继承布局调整产生的耦合。相同命名空间内部仍不能随意重排、改变既有字段类型或改变其业务含义。
更换命名空间标识符会改变寻址根,不会自动读取旧根中的数据。从已有线性布局切换到命名空间,也需要明确迁移方案、初始化权限和迁移后的不变量验证。
不同模块应避免复用同一命名空间。任意存储写入、错误根常量或不兼容字段修改仍可破坏状态,命名空间不是对这些行为的自动安全检查。
参考资料
EIP-1967: 标准代理存储槽规范 (Standard Proxy Storage Slots)
EIP-1967 规定了以太坊可升级代理合约(Proxy Contract)用于存储逻辑实现地址、管理地址及信标地址的标准化存储槽(Storage Slots)。
在可升级代理模式中,代理合约通过 DELEGATECALL 操作码将所有执行委托给逻辑实现合约(Implementation)。如果代理合约自身的状态变量采用编译器默认的线性分配(从 Slot 0 开始),将与实现合约的业务变量发生灾难性的存储槽碰撞(Storage Collision)。EIP-1967 为代理元数据定义独立槽位,降低与常规布局重叠的风险,并提供统一的识别约定。业务布局兼容与升级授权仍需分别验证。
核心标准存储槽定义与数学推导
EIP-1967 使用哈希派生位置,使代理元数据与编译器的常规布局分离:
$$ \text{Slot} = \text{bytes32}\big(\text{uint256}(\text{keccak256}(\text{namespace})) - 1\big) $$
减一后的位置没有已知的哈希原像,进一步降低与派生存储位置重叠的风险。隔离依赖密码学假设,不是对任意代码写入或所有数学碰撞的排除。
| 槽位用途 (Role) | 命名空间字符串 (Namespace) | 确定性存储槽物理地址 (Slot Hex) | 对应事件 (Event) |
|---|---|---|---|
| 逻辑实现地址 (Implementation) | "eip1967.proxy.implementation" | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | Upgraded(address indexed implementation) |
| 管理员地址 (Admin) | "eip1967.proxy.admin" | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | AdminChanged(address previousAdmin, address newAdmin) |
| 信标地址 (Beacon) | "eip1967.proxy.beacon" | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | BeaconUpgraded(address indexed beacon) |
架构模型:三种主流代理形态
flowchart TD
subgraph TransparentProxy["Transparent Proxy (透明代理)"]
UserA[普通用户] -->|delegatecall| ImplA[Implementation 业务实现]
AdminA[ProxyAdmin 合约] -->|管理升级 upgradeTo| ProxyA[TransparentUpgradeableProxy]
end
subgraph UUPSProxy["UUPS Proxy (EIP-1822)"]
UserB[用户 / Admin] -->|统一入口| ProxyB[ERC1967Proxy]
ProxyB -->|delegatecall 包含升级逻辑| ImplB[UUPSUpgradeable 实现合约]
end
subgraph BeaconProxy["Beacon Proxy (信标代理)"]
P1[Proxy 1] & P2[Proxy 2] & P3[Proxy 3] -->|读取信标指向| Beacon[UpgradeableBeacon]
Beacon -->|统一指向新版本| ImplC[单一实现合约]
end
1. 逻辑实现槽 (Implementation Slot)
- 物理机制:保存当前被委托逻辑合约的 20 字节地址(存储在高位补零的 32 字节字中)。
- 更新约束:标准建议槽位变化时发出
Upgraded事件。OpenZeppelin 实现另外校验目标代码等条件,具体检查不等同于标准对所有代理的要求。
2. 管理员槽 (Admin Slot - 针对透明代理)
- 函数选择器冲突规避(Selector Clashing):若实现合约拥有
transfer(address,uint256),而代理自身拥有同签名或同 4 字节选择器的管理函数,调用将产生歧义。 - 透明路由逻辑:
- 若
msg.sender == Admin:调用仅被代理自身的管理函数响应(如升级、更改 Admin),禁止转发给逻辑实现。 - 若
msg.sender != Admin:调用无条件通过DELEGATECALL转发给逻辑实现。
- 若
- OpenZeppelin 5.4.0 的透明代理创建独立 ProxyAdmin,并使用 immutable 管理员参与路由;这是具体库实现,不是 EIP-1967 对所有代理的强制要求。
3. 信标槽 (Beacon Slot)
- 多实例集中升级:当工厂合约需要部署成千上万个同构代理(如用户金库、借贷仓位)时,若逐一调用
upgradeTo升级其实现槽,Gas 成本呈线性增长。 - 信标代理机制:代理从 Beacon 获取实现地址,更新该 Beacon 可改变依赖它的多个代理。OpenZeppelin 5.4.0 的 BeaconProxy 使用 immutable Beacon 地址参与路由,并在标准槽中记录地址,不能统一解释为每次都先读取 Beacon 存储槽。
标准槽读写示例 (Solidity & Yul)
以下代码抽取槽位访问与委托初始化。外部升级入口仍需鉴权,不能将该内部工具直接视为完整升级实现。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
library ERC1967Utils {
bytes32 internal constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 internal constant ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
event Upgraded(address indexed implementation);
event AdminChanged(address previousAdmin, address newAdmin);
function getImplementation() internal view returns (address impl) {
assembly {
impl := sload(IMPLEMENTATION_SLOT)
}
}
function upgradeToAndCall(address newImplementation, bytes memory data) internal {
require(newImplementation.code.length > 0, "ERC1967: new implementation is not a contract");
assembly {
sstore(IMPLEMENTATION_SLOT, newImplementation)
}
emit Upgraded(newImplementation);
if (data.length > 0) {
(bool success, ) = newImplementation.delegatecall(data);
require(success, "ERC1967: upgrade call failed");
}
}
}
参考文献与规范
EIP-1822 与 UUPS 可升级代理
UUPS(Universal Upgradeable Proxy Standard)的核心是把升级逻辑放在实现合约中。用户始终访问代理地址,代理通过 delegatecall 执行当前实现的代码;升级时,更换代理记录的实现地址,保留代理地址和已有状态。
示例采用 Solidity 0.8.24、OpenZeppelin Contracts 与 Contracts Upgradeable 5.4.0。升级入口、初始化函数与上下文检查按该版本定义。
代理保存状态,实现提供代码
一个 UUPS 实例至少涉及两个合约:
| 合约 | 主要职责 | 状态所在位置 |
|---|---|---|
| 代理(Proxy) | 保存实现地址,将调用委托给实现 | 业务数据、升级权限及实现地址等保存在代理的存储中 |
| 实现(Implementation) | 提供业务函数和升级函数的代码 | 直接调用实现地址时,访问的是实现自己的存储 |
通过代理执行 delegatecall 时,address(this) 是代理地址,读写的是代理存储,msg.sender 保留代理收到这次调用时的调用者。因而实现代码中的 owner()、value 和实现地址槽,都是在代理的上下文中读取或修改。
这也解释了升级的过程:管理员调用代理地址上的升级接口,代理委托给当前实现;当前实现检查权限,再把代理的实现地址槽更新为新地址。代理字节码本身不需要改变。
直接调用实现合约的 initialize(),即便能改变实现自己的 owner,也不等于取得代理的 owner。必须区分调用地址与执行上下文,才能判断一个操作究竟影响哪份状态。
EIP-1822 与 OpenZeppelin 的关系
EIP-1822 提出了 UUPS 架构及 proxiableUUID() 兼容接口。原提案使用 keccak256("PROXIABLE") 作为实现地址槽;OpenZeppelin 的现代 UUPS 实现则采用 EIP-1967 规定的实现地址槽。
因此,采用 UUPS 思路并不意味着任意实现都可以互相升级。双方还必须约定相同的存储槽与升级机制。upgradeToAndCall() 是OpenZeppelin 5.4.0的升级入口,不能把它当作 EIP-1822 对所有实现规定的唯一函数名。
OpenZeppelin 升级时会调用新实现的 proxiableUUID(),要求返回预期的实现地址槽。这是兼容性检查,不能证明新代码没有漏洞,也不能证明业务存储布局兼容。
与透明代理的区别
| 比较项 | 透明代理 | UUPS |
|---|---|---|
| 升级逻辑 | 位于代理侧,通常由 ProxyAdmin 管理 | 位于实现合约中,通过代理执行 |
| 管理调用与业务调用 | 代理按管理员身份区分调用路径 | 实现中的升级接口负责权限检查 |
| 部署开销 | 代理侧包含更多管理机制 | 代理侧通常更轻,具体成本取决于版本和部署方式 |
| 后续升级能力 | 由代理侧的管理机制提供 | 依赖当前实现仍然提供可用的升级路径 |
UUPS 减少了代理层的管理接口,但升级函数仍与业务函数一起存在于实现中,接口设计和权限控制仍然需要审查。透明代理的管理员存储方式也会随版本变化,不能统一断言每次调用都额外读取同一个管理员槽。
一个最小实现
以下示例使用 Solidity 0.8.24 和 OpenZeppelin 5.4.0。V1 保存一个数值,owner 可以修改数值或升级;V2 在原有状态布局上增加一个递增函数。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyLogicV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function setValue(uint256 newValue) external onlyOwner {
value = newValue;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
contract MyLogicV2 is MyLogicV1 {
function increment() external onlyOwner {
value += 1;
}
}
部署时先创建 V1,再创建 ERC1967Proxy。将 abi.encodeCall(MyLogicV1.initialize, (initialOwner)) 作为代理构造函数的初始化数据,使部署代理与设置 owner 在同一笔交易中完成。之后,业务调用和升级调用都发送到代理地址。
升级到 V2 时,owner 通过代理调用 upgradeToAndCall(address(v2), "")。这里传入空数据,因为 V2 没有需要初始化的新状态。升级后,代理的 value 和 owner 保留,代理也能执行新增的 increment()。
如果新版本增加了需要初始化的状态,可设计受控的 reinitializer,并通过升级调用附带初始化数据。普通 V1 initializer 不应在每次升级后重新执行。
初始化与调用上下文保护
初始化保护分为两件事:构造函数中的 _disableInitializers() 锁定实现合约本身;代理部署时执行的 initialize() 设置代理的状态。前者不会替代理初始化,后者也不会锁定实现自己的状态。
OpenZeppelin 5.4.0 提供两种上下文检查:
onlyProxy:升级入口必须通过代理委托执行,而且代理当前必须指向这个实现。notDelegated:proxiableUUID()必须直接在实现地址上调用,不能通过代理调用,避免把代理本身作为新实现而形成委托循环。
因此,不能把“直接初始化实现合约”推导为“直接修改代理的升级地址”。旧版本曾有与未初始化实现相关的攻击路径,但需要结合具体代码、调用路径和链的规则分析,不能套用到所有 UUPS 实现。
升级时真正需要保证的条件
升级权限决定谁可以替换代码。示例使用 owner,实际项目也可以使用多签或治理合约。获得升级权限通常意味着能够改变整个系统的行为,不能只把它理解为维护某个配置项。
新实现还必须兼容已有存储。对示例中的普通状态变量,不能随意改变 value 的类型、位置或含义。OpenZeppelin 的兼容检查不会自动发现所有存储冲突,部署工具的布局检查和升级后的行为验证仍然有必要。
当前实现会拒绝没有正确 proxiableUUID() 的普通合约。但一个返回正确 UUID 的实现仍可能删除升级入口、拒绝所有升级,或者引入错误。因此,兼容检查也不保证未来一定还能升级。
如果希望最终停止升级,应明确设计并验证终止机制。意外失去升级路径,可能让代理继续执行业务,却无法再修复代码;这与“代理一定完全无法使用”并不是同一件事。
参考资料
EIP-4844: 片段 Blob 交易规范 (Proto-Danksharding)
EIP-4844 是以太坊坎昆(Cancun-Deneb)硬分叉的核心升级,引入了专门面向 Layer 2 Rollup 的临时数据载体——携带 Blob 的交易(Blob-carrying Transactions,Type-3)。
在 EIP-4844 之前,Rollup 依赖执行层的 calldata 存储批量交易压缩包,成本昂贵且永久占用全节点磁盘。EIP-4844 将数据发布与 EVM 执行解耦,建立了独立的暂时性存储通道与动态费用市场。
Type-3 交易结构与执行层隔离
EIP-4844 引入了新的 EIP-2718 交易信封类型 0x03。
1. 交易物理负载解耦
Type-3 交易在网络传输层包含两套数据结构:
- 执行层可见负载(Execution Payload):包含交易基础字段(nonce、gas_limit、to、value、data)以及两个新字段:
max_fee_per_blob_gas:用户愿意为每单位 Blob Gas 支付的最大单价。blob_versioned_hashes:包含若干 32 字节哈希的数组,每个哈希对应一个挂载的 Blob。
- 共识层临时附件(Blob Sidecar):包含具体的 128 KB Blob 原始字节、KZG 多项式承诺(Commitment)以及对应的开值证明(Proof)。
flowchart TD
subgraph BlobTransaction["Type-3 交易负载 (P2P 广播)"]
direction TB
Tx[执行层交易体<br/>max_fee_per_blob_gas<br/>blob_versioned_hashes]
Sidecar[共识层 Sidecar<br/>128 KB Blob 原始数据<br/>KZG Commitment & Proof]
end
Tx -->|进入区块并在 EVM 结算| EL[以太坊执行层 Execution Layer]
Sidecar -->|在共识层 P2P 传播并暂存 18 天| CL[以太坊共识层 Consensus Layer]
EL -.->|仅暴露 BLOBHASH 操作码| SmartContract[L1 智能合约<br/>Rollup 核心逻辑]
2. 为什么 EVM 无法直接读取 Blob 明文?
EIP-4844 规定:智能合约在 EVM 执行环境中严禁直接访问 Blob 的原始数据字节。
- 吞吐保护原理:如果 EVM 能够在智能合约中逐字节读取 128 KB 甚至上兆字节的 Blob 数据,区块执行的计算开销将导致节点验证出现严重延迟,破坏网络去中心化。
- 版本化哈希操作码
BLOBHASH: EVM 仅新增了一个操作码BLOBHASH (0x49):// 从交易中读取第 index 个挂载的 blob 对应的 32 字节哈希 bytes32 versionedHash = blobhash(index);versionedHash的首字节固定为版本标识符(当前为0x01),后 31 字节为对应 KZG Commitment 的 SHA-256 哈希截断。Rollup 仲裁或验证合约通过比对该哈希与链下提交的点值证明,在零知识或交互式欺诈证明中证明某笔交易确实存在于该 Blob 中。
密码学基石:KZG 多项式承诺
每个 Blob 包含 4096 个有限域上的标量元素(Field Elements,标量属于 BLS12-381 曲线标量域),对应 128 KB 数据大小。
1. 从数据向量到多项式
Blob 数据被视作多项式在特定单位根(Roots of Unity)子群上的取值:
$$ p(\omega^i) = d_i \quad (i \in [0, 4095]) $$
协议通过拉格朗日插值法,将 128 KB 数据隐式映射为唯一的最高 4095 次多项式 \(p(x)\)。
2. KZG 承诺与验证机制
利用以太坊启动仪式(Trusted Setup)生成的 SRS(Structured Reference String)参数,KZG 机制允许定序器在无需暴露完整多项式的情况下生成固定 48 字节的椭圆曲线承诺点:
$$ C = [p(\tau)]_1 \in \mathbb{G}_1 $$
- 点值证明(Opening Proof):若需证明多项式在某点 \(z\) 处的取值为 \(y\)(即 \(p(z) = y\)),证明者可构造商多项式 \(q(x) = \frac{p(x) - y}{x - z}\) 的承诺 \(\pi = [q(\tau)]_1\)。
- 点式高效验证:L1 验证合约通过预编译合约
point_evaluation(地址0x0A)在一次椭圆曲线配对(Pairing)计算中完成判定: $$ e(C - [y]_1,; [1]_2) \stackrel{?}{=} e(\pi,; [\tau - z]_2) $$
独立的多维动态费用市场 (Blob Gas)
EIP-4844 在以太坊原有的常规执行 Gas 之外,引入了独立的 Blob Gas 计费体系,二者互不干扰,彻底阻断了 Rollup 批量上链导致常规用户转账 Gas 暴涨的问题。
1. 目标容量与动态调价算法
- 目标容量(Target):每个区块目标包含 3 个 Blob(384 KB)。
- 最大容量(Limit):每个区块最多容纳 6 个 Blob(768 KB)。
Blob 基础费用(blob_base_fee)完全独立于 EIP-1559 执行层基础费用,基于指数调整机制根据历史区块的 Blob 供需状态自适应浮动:
$$ \text{blob_base_fee}_{t+1} = \text{blob_base_fee}_t \times \exp\left( \frac{\text{actual_blobs} - \text{target_blobs}}{\gamma} \right) $$
其中 \(\gamma\) 为平滑常数。当近期区块的平均 Blob 数量持续高于目标值 3 时,Blob 单价呈指数级上升;当需求低迷时,单价回落至最低下限(1 wei)。
存储生命周期与剪枝规范
Blob 数据并非以太坊永久历史状态的一部分:
| 维度 | 常规 Calldata | EIP-4844 Blob |
|---|---|---|
| 存储层级 | 执行层(Execution Layer)永久持久化 | 共识层(Consensus Layer)暂存 |
| 全节点保留时长 | 永久保存(全历史节点) | 固定保留 4096 个 Epochs(约 18 天) |
| EVM 可读性 | 任意智能合约均可完全解析 | 仅能读取 32 字节 BLOBHASH |
| Gas 消耗类型 | 消耗常规执行 Gas(每非零字节 16 Gas) | 消耗专用 Blob Gas(独立动态定价) |
| 灾备与历史重构 | 随时可从主网全节点重放完整历史 | 18 天后全网自动剪枝,需依赖外部归档基础设施 |
参考文献与标准
- EIP-4844: Shard Blob Transactions
- KZG Polynomial Commitments (Kate et al.)
- Vitalik Buterin: Proto-Danksharding FAQ
热门项目源码分析
BNB 源码分析
- 币安链上的原生代币,实现方式效仿 WETH 对于原生以太坊代币封装,使其具有 ERC20 类似的代币功能
- 以太坊链上的 BNB,代码精简,适合初学者
BNB
以太坊地址BNB
pragma solidity ^0.4.8;
/**
* Math operations with safety checks
*/
contract SafeMath {
function safeMul(uint256 a, uint256 b) internal returns (uint256) {
uint256 c = a * b;
assert(a == 0 || c / a == b);
return c;
}
function safeDiv(uint256 a, uint256 b) internal returns (uint256) {
assert(b > 0);
uint256 c = a / b;
assert(a == b * c + a % b);
return c;
}
function safeSub(uint256 a, uint256 b) internal returns (uint256) {
assert(b <= a);
return a - b;
}
function safeAdd(uint256 a, uint256 b) internal returns (uint256) {
uint256 c = a + b;
assert(c>=a && c>=b);
return c;
}
function assert(bool assertion) internal {
if (!assertion) {
throw;
}
}
}
contract BNB is SafeMath{
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
address public owner;
/* This creates an array with all balances */
mapping (address => uint256) public balanceOf;
mapping (address => uint256) public freezeOf;
mapping (address => mapping (address => uint256)) public allowance;
/* This generates a public event on the blockchain that will notify clients */
event Transfer(address indexed from, address indexed to, uint256 value);
/* This notifies clients about the amount burnt */
event Burn(address indexed from, uint256 value);
/* This notifies clients about the amount frozen */
event Freeze(address indexed from, uint256 value);
/* This notifies clients about the amount unfrozen */
event Unfreeze(address indexed from, uint256 value);
/* Initializes contract with initial supply tokens to the creator of the contract */
function BNB(
uint256 initialSupply,
string tokenName,
uint8 decimalUnits,
string tokenSymbol
) {
balanceOf[msg.sender] = initialSupply; // Give the creator all initial tokens
totalSupply = initialSupply; // Update total supply
name = tokenName; // Set the name for display purposes
symbol = tokenSymbol; // Set the symbol for display purposes
decimals = decimalUnits; // Amount of decimals for display purposes
owner = msg.sender;
}
/* Send coins */
function transfer(address _to, uint256 _value) {
if (_to == 0x0) throw; // Prevent transfer to 0x0 address. Use burn() instead
if (_value <= 0) throw;
if (balanceOf[msg.sender] < _value) throw; // Check if the sender has enough
if (balanceOf[_to] + _value < balanceOf[_to]) throw; // Check for overflows
balanceOf[msg.sender] = SafeMath.safeSub(balanceOf[msg.sender], _value); // Subtract from the sender
balanceOf[_to] = SafeMath.safeAdd(balanceOf[_to], _value); // Add the same to the recipient
Transfer(msg.sender, _to, _value); // Notify anyone listening that this transfer took place
}
/* Allow another contract to spend some tokens in your behalf */
function approve(address _spender, uint256 _value)
returns (bool success) {
if (_value <= 0) throw;
allowance[msg.sender][_spender] = _value;
return true;
}
/* A contract attempts to get the coins */
function transferFrom(address _from, address _to, uint256 _value) returns (bool success) {
if (_to == 0x0) throw; // Prevent transfer to 0x0 address. Use burn() instead
if (_value <= 0) throw;
if (balanceOf[_from] < _value) throw; // Check if the sender has enough
if (balanceOf[_to] + _value < balanceOf[_to]) throw; // Check for overflows
if (_value > allowance[_from][msg.sender]) throw; // Check allowance
balanceOf[_from] = SafeMath.safeSub(balanceOf[_from], _value); // Subtract from the sender
balanceOf[_to] = SafeMath.safeAdd(balanceOf[_to], _value); // Add the same to the recipient
allowance[_from][msg.sender] = SafeMath.safeSub(allowance[_from][msg.sender], _value);
Transfer(_from, _to, _value);
return true;
}
function burn(uint256 _value) returns (bool success) {
if (balanceOf[msg.sender] < _value) throw; // Check if the sender has enough
if (_value <= 0) throw;
balanceOf[msg.sender] = SafeMath.safeSub(balanceOf[msg.sender], _value); // Subtract from the sender
totalSupply = SafeMath.safeSub(totalSupply,_value); // Updates totalSupply
Burn(msg.sender, _value);
return true;
}
function freeze(uint256 _value) returns (bool success) {
if (balanceOf[msg.sender] < _value) throw; // Check if the sender has enough
if (_value <= 0) throw;
balanceOf[msg.sender] = SafeMath.safeSub(balanceOf[msg.sender], _value); // Subtract from the sender
freezeOf[msg.sender] = SafeMath.safeAdd(freezeOf[msg.sender], _value); // Updates totalSupply
Freeze(msg.sender, _value);
return true;
}
function unfreeze(uint256 _value) returns (bool success) {
if (freezeOf[msg.sender] < _value) throw; // Check if the sender has enough
if (_value <= 0) throw;
freezeOf[msg.sender] = SafeMath.safeSub(freezeOf[msg.sender], _value); // Subtract from the sender
balanceOf[msg.sender] = SafeMath.safeAdd(balanceOf[msg.sender], _value);
Unfreeze(msg.sender, _value);
return true;
}
// transfer balance to owner
function withdrawEther(uint256 amount) {
if(msg.sender != owner)throw;
owner.transfer(amount);
}
// can accept ether
function() payable {
}
}
WBNB
币安链的原生代币为BNB,与以太坊的ETH类似。 币安链和以太坊链不是完全相同。币安智能链(BSC)上的BEP标准和以太坊的EIP标准是两个不同的体系,但有意设计得相互兼容:
- EIP (Ethereum Improvement Proposals):
- 以太坊的改进提案体系
- 包括ERC (Ethereum Request for Comments)标准,如ERC-20、ERC-721等
- 为以太坊网络定义协议规范和合约标准
- BEP (Binance Evolution Proposals):
- 币安生态系统的改进提案体系
- 包括BEP-2(币安链)、BEP-20(币安智能链)等代币标准
- 为币安的区块链网络定义规范和标准
币安智能链的BEP-20标准刻意设计得与以太坊的ERC-20非常相似,以实现兼容性和便于迁移。这种设计让开发者可以相对容易地将以太坊上的智能合约部署到币安智能链上,只需要进行很小的修改。
这种兼容性是战略性的决定,让币安智能链能够吸引以太坊生态系统的开发者和用户,同时提供更低的交易费用和更高的交易速度。
此合约在币安智能链上的地址为WBNB
/**
*Submitted for verification at Bscscan.com on 2020-09-03
*/
pragma solidity ^0.4.18;
contract WBNB {
string public name = "Wrapped BNB";
string public symbol = "WBNB";
uint8 public decimals = 18;
event Approval(address indexed src, address indexed guy, uint wad);
event Transfer(address indexed src, address indexed dst, uint wad);
event Deposit(address indexed dst, uint wad);
event Withdrawal(address indexed src, uint wad);
mapping (address => uint) public balanceOf;
mapping (address => mapping (address => uint)) public allowance;
function() public payable {
deposit();
}
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
Deposit(msg.sender, msg.value);
}
function withdraw(uint wad) public {
require(balanceOf[msg.sender] >= wad);
balanceOf[msg.sender] -= wad;
msg.sender.transfer(wad);
Withdrawal(msg.sender, wad);
}
function totalSupply() public view returns (uint) {
return this.balance;
}
function approve(address guy, uint wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
Approval(msg.sender, guy, wad);
return true;
}
function transfer(address dst, uint wad) public returns (bool) {
return transferFrom(msg.sender, dst, wad);
}
function transferFrom(address src, address dst, uint wad)
public
returns (bool)
{
require(balanceOf[src] >= wad);
if (src != msg.sender && allowance[src][msg.sender] != uint(-1)) {
require(allowance[src][msg.sender] >= wad);
allowance[src][msg.sender] -= wad;
}
balanceOf[src] -= wad;
balanceOf[dst] += wad;
Transfer(src, dst, wad);
return true;
}
}
/*
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
1. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
*/
USDC源码解析
文中代码所有来源均来自于etherscan verified contract ,代码中出现的相对路径可查阅以下链接 源代码快捷链接
USDC V1
USDC V1.1
USDC V2.0
FiatTokenProxy
pragma solidity ^0.4.24;
// File: zos-lib/contracts/upgradeability/Proxy.sol
/**
* @title Proxy
* @dev Implements delegation of calls to other contracts, with proper
* forwarding of return values and bubbling of failures.
* It defines a fallback function that delegates all calls to the address
* returned by the abstract _implementation() internal function.
*/
contract Proxy {
/**
* @dev Fallback function.
* Implemented entirely in `_fallback`.
*/
function () payable external {
_fallback();
}
/**
* @return The Address of the implementation.
*/
function _implementation() internal view returns (address);
/**
* @dev Delegates execution to an implementation contract.
* This is a low level function that doesn't return to its internal call site.
* It will return to the external caller whatever the implementation returns.
* @param implementation Address to delegate.
*/
function _delegate(address implementation) internal {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0, 0, calldatasize)
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas, implementation, 0, calldatasize, 0, 0)
// Copy the returned data.
returndatacopy(0, 0, returndatasize)
switch result
// delegatecall returns 0 on error.
case 0 { revert(0, returndatasize) }
default { return(0, returndatasize) }
}
}
/**
* @dev Function that is run as the first thing in the fallback function.
* Can be redefined in derived contracts to add functionality.
* Redefinitions must call super._willFallback().
*/
function _willFallback() internal {
}
/**
* @dev fallback implementation.
* Extracted to enable manual triggering.
*/
function _fallback() internal {
_willFallback();
_delegate(_implementation());
}
}
// File: openzeppelin-solidity/contracts/AddressUtils.sol
/**
* Utility library of inline functions on addresses
*/
library AddressUtils {
/**
* Returns whether the target address is a contract
* @dev This function will return false if invoked during the constructor of a contract,
* as the code is not actually created until after the constructor finishes.
* @param addr address to check
* @return whether the target address is a contract
*/
function isContract(address addr) internal view returns (bool) {
uint256 size;
// XXX Currently there is no better way to check if there is a contract in an address
// than to check the size of the code at that address.
// See https://ethereum.stackexchange.com/a/14016/36603
// for more details about how this works.
// TODO Check this again before the Serenity release, because all addresses will be
// contracts then.
// solium-disable-next-line security/no-inline-assembly
assembly { size := extcodesize(addr) }
return size > 0;
}
}
// File: zos-lib/contracts/upgradeability/UpgradeabilityProxy.sol
/**
* @title UpgradeabilityProxy
* @dev This contract implements a proxy that allows to change the
* implementation address to which it will delegate.
* Such a change is called an implementation upgrade.
*/
contract UpgradeabilityProxy is Proxy {
/**
* @dev Emitted when the implementation is upgraded.
* @param implementation Address of the new implementation.
*/
event Upgraded(address implementation);
/**
* @dev Storage slot with the address of the current implementation.
* This is the keccak-256 hash of "org.zeppelinos.proxy.implementation", and is
* validated in the constructor.
*/
bytes32 private constant IMPLEMENTATION_SLOT = 0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3;
/**
* @dev Contract constructor.
* @param _implementation Address of the initial implementation.
*/
constructor(address _implementation) public {
assert(IMPLEMENTATION_SLOT == keccak256("org.zeppelinos.proxy.implementation"));
_setImplementation(_implementation);
}
/**
* @dev Returns the current implementation.
* @return Address of the current implementation
*/
function _implementation() internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
/**
* @dev Upgrades the proxy to a new implementation.
* @param newImplementation Address of the new implementation.
*/
function _upgradeTo(address newImplementation) internal {
_setImplementation(newImplementation);
emit Upgraded(newImplementation);
}
/**
* @dev Sets the implementation address of the proxy.
* @param newImplementation Address of the new implementation.
*/
function _setImplementation(address newImplementation) private {
require(AddressUtils.isContract(newImplementation), "Cannot set a proxy implementation to a non-contract address");
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstore(slot, newImplementation)
}
}
}
// File: zos-lib/contracts/upgradeability/AdminUpgradeabilityProxy.sol
/**
* @title AdminUpgradeabilityProxy
* @dev This contract combines an upgradeability proxy with an authorization
* mechanism for administrative tasks.
* All external functions in this contract must be guarded by the
* `ifAdmin` modifier. See ethereum/solidity#3864 for a Solidity
* feature proposal that would enable this to be done automatically.
*/
contract AdminUpgradeabilityProxy is UpgradeabilityProxy {
/**
* @dev Emitted when the administration has been transferred.
* @param previousAdmin Address of the previous admin.
* @param newAdmin Address of the new admin.
*/
event AdminChanged(address previousAdmin, address newAdmin);
/**
* @dev Storage slot with the admin of the contract.
* This is the keccak-256 hash of "org.zeppelinos.proxy.admin", and is
* validated in the constructor.
*/
bytes32 private constant ADMIN_SLOT = 0x10d6a54a4754c8869d6886b5f5d7fbfa5b4522237ea5c60d11bc4e7a1ff9390b;
/**
* @dev Modifier to check whether the `msg.sender` is the admin.
* If it is, it will run the function. Otherwise, it will delegate the call
* to the implementation.
*/
modifier ifAdmin() {
if (msg.sender == _admin()) {
_;
} else {
_fallback();
}
}
/**
* Contract constructor.
* It sets the `msg.sender` as the proxy administrator.
* @param _implementation address of the initial implementation.
*/
constructor(address _implementation) UpgradeabilityProxy(_implementation) public {
assert(ADMIN_SLOT == keccak256("org.zeppelinos.proxy.admin"));
_setAdmin(msg.sender);
}
/**
* @return The address of the proxy admin.
*/
function admin() external view ifAdmin returns (address) {
return _admin();
}
/**
* @return The address of the implementation.
*/
function implementation() external view ifAdmin returns (address) {
return _implementation();
}
/**
* @dev Changes the admin of the proxy.
* Only the current admin can call this function.
* @param newAdmin Address to transfer proxy administration to.
*/
function changeAdmin(address newAdmin) external ifAdmin {
require(newAdmin != address(0), "Cannot change the admin of a proxy to the zero address");
emit AdminChanged(_admin(), newAdmin);
_setAdmin(newAdmin);
}
/**
* @dev Upgrade the backing implementation of the proxy.
* Only the admin can call this function.
* @param newImplementation Address of the new implementation.
*/
function upgradeTo(address newImplementation) external ifAdmin {
_upgradeTo(newImplementation);
}
/**
* @dev Upgrade the backing implementation of the proxy and call a function
* on the new implementation.
* This is useful to initialize the proxied contract.
* @param newImplementation Address of the new implementation.
* @param data Data to send as msg.data in the low level call.
* It should include the signature and the parameters of the function to be
* called, as described in
* https://solidity.readthedocs.io/en/develop/abi-spec.html#function-selector-and-argument-encoding.
*/
function upgradeToAndCall(address newImplementation, bytes data) payable external ifAdmin {
_upgradeTo(newImplementation);
require(address(this).call.value(msg.value)(data));
}
/**
* @return The admin slot.
*/
function _admin() internal view returns (address adm) {
bytes32 slot = ADMIN_SLOT;
assembly {
adm := sload(slot)
}
}
/**
* @dev Sets the address of the proxy admin.
* @param newAdmin Address of the new proxy admin.
*/
function _setAdmin(address newAdmin) internal {
bytes32 slot = ADMIN_SLOT;
assembly {
sstore(slot, newAdmin)
}
}
/**
* @dev Only fall back when the sender is not the admin.
*/
function _willFallback() internal {
require(msg.sender != _admin(), "Cannot call fallback function from the proxy admin");
super._willFallback();
}
}
// File: contracts/FiatTokenProxy.sol
/**
* Copyright CENTRE SECZ 2018
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is furnished to
* do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
pragma solidity ^0.4.24;
/**
* @title FiatTokenProxy
* @dev This contract proxies FiatToken calls and enables FiatToken upgrades
*/
contract FiatTokenProxy is AdminUpgradeabilityProxy {
constructor(address _implementation) public AdminUpgradeabilityProxy(_implementation) {
}
}
USDC源码解析
V1设计结构
逐模块分析
FiatTokenV1.sol (主要实现)
ownable.sol
pauseable.sol
AbstractFiatTokenV1.sol
Blacklistable.sol
Ownable
/**
* SPDX-License-Identifier: MIT
*
* Copyright (c) 2018 zOS Global Limited.
* Copyright (c) 2018-2020 CENTRE SECZ
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
pragma solidity 0.6.12;
/**
* @notice The Ownable contract has an owner address, and provides basic
* authorization control functions
* @dev Forked from https://github.com/OpenZeppelin/openzeppelin-labs/blob/3887ab77b8adafba4a26ace002f3a684c1a3388b/upgradeability_ownership/contracts/ownership/Ownable.sol
* Modifications:
* 1. Consolidate OwnableStorage into this contract (7/13/18)
* 2. Reformat, conform to Solidity 0.6 syntax, and add error messages (5/13/20)
* 3. Make public functions external (5/27/20)
*/
contract Ownable {
// Owner of the contract
address private _owner;
/**
* @dev Event to show ownership has been transferred
* @param previousOwner representing the address of the previous owner
* @param newOwner representing the address of the new owner
*/
event OwnershipTransferred(address previousOwner, address newOwner);
/**
* @dev The constructor sets the original owner of the contract to the sender account.
*/
constructor() public {
setOwner(msg.sender);
}
/**
* @dev Tells the address of the owner
* @return the address of the owner
*/
function owner() external view returns (address) {
return _owner;
}
/**
* @dev Sets a new owner address
*/
function setOwner(address newOwner) internal {
_owner = newOwner;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(msg.sender == _owner, "Ownable: caller is not the owner");
_;
}
/**
* @dev Allows the current owner to transfer control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function transferOwnership(address newOwner) external onlyOwner {
require(
newOwner != address(0),
"Ownable: new owner is the zero address"
);
emit OwnershipTransferred(_owner, newOwner);
setOwner(newOwner);
}
}
Pauseable
/**
* SPDX-License-Identifier: MIT
*
* Copyright (c) 2016 Smart Contract Solutions, Inc.
* Copyright (c) 2018-2020 CENTRE SECZ
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
pragma solidity 0.6.12;
import { Ownable } from "./Ownable.sol";
/**
* @notice Base contract which allows children to implement an emergency stop
* mechanism
* @dev Forked from https://github.com/OpenZeppelin/openzeppelin-contracts/blob/feb665136c0dae9912e08397c1a21c4af3651ef3/contracts/lifecycle/Pausable.sol
* Modifications:
* 1. Added pauser role, switched pause/unpause to be onlyPauser (6/14/2018)
* 2. Removed whenNotPause/whenPaused from pause/unpause (6/14/2018)
* 3. Removed whenPaused (6/14/2018)
* 4. Switches ownable library to use ZeppelinOS (7/12/18)
* 5. Remove constructor (7/13/18)
* 6. Reformat, conform to Solidity 0.6 syntax and add error messages (5/13/20)
* 7. Make public functions external (5/27/20)
*/
contract Pausable is Ownable {
event Pause();
event Unpause();
event PauserChanged(address indexed newAddress);
address public pauser;
bool public paused = false;
/**
* @dev Modifier to make a function callable only when the contract is not paused.
*/
modifier whenNotPaused() {
require(!paused, "Pausable: paused");
_;
}
/**
* @dev throws if called by any account other than the pauser
*/
modifier onlyPauser() {
require(msg.sender == pauser, "Pausable: caller is not the pauser");
_;
}
/**
* @dev called by the owner to pause, triggers stopped state
*/
function pause() external onlyPauser {
paused = true;
emit Pause();
}
/**
* @dev called by the owner to unpause, returns to normal state
*/
function unpause() external onlyPauser {
paused = false;
emit Unpause();
}
/**
* @notice Updates the pauser address.
* @param _newPauser The address of the new pauser.
*/
function updatePauser(address _newPauser) external onlyOwner {
require(
_newPauser != address(0),
"Pausable: new pauser is the zero address"
);
pauser = _newPauser;
emit PauserChanged(pauser);
}
}
AbstractFiatTokenV1
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
abstract contract AbstractFiatTokenV1 is IERC20 {
function _approve(
address owner,
address spender,
uint256 value
) internal virtual;
function _transfer(
address from,
address to,
uint256 value
) internal virtual;
}
Blacklistable
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { Ownable } from "./Ownable.sol";
/**
* @title Blacklistable Token
* @dev Allows accounts to be blacklisted by a "blacklister" role
*/
abstract contract Blacklistable is Ownable {
address public blacklister;
mapping(address => bool) internal _deprecatedBlacklisted;
event Blacklisted(address indexed _account);
event UnBlacklisted(address indexed _account);
event BlacklisterChanged(address indexed newBlacklister);
/**
* @dev Throws if called by any account other than the blacklister.
*/
modifier onlyBlacklister() {
require(
msg.sender == blacklister,
"Blacklistable: caller is not the blacklister"
);
_;
}
/**
* @dev Throws if argument account is blacklisted.
* @param _account The address to check.
*/
modifier notBlacklisted(address _account) {
require(
!_isBlacklisted(_account),
"Blacklistable: account is blacklisted"
);
_;
}
/**
* @notice Checks if account is blacklisted.
* @param _account The address to check.
* @return True if the account is blacklisted, false if the account is not blacklisted.
*/
function isBlacklisted(address _account) external view returns (bool) {
return _isBlacklisted(_account);
}
/**
* @notice Adds account to blacklist.
* @param _account The address to blacklist.
*/
function blacklist(address _account) external onlyBlacklister {
_blacklist(_account);
emit Blacklisted(_account);
}
/**
* @notice Removes account from blacklist.
* @param _account The address to remove from the blacklist.
*/
function unBlacklist(address _account) external onlyBlacklister {
_unBlacklist(_account);
emit UnBlacklisted(_account);
}
/**
* @notice Updates the blacklister address.
* @param _newBlacklister The address of the new blacklister.
*/
function updateBlacklister(address _newBlacklister) external onlyOwner {
require(
_newBlacklister != address(0),
"Blacklistable: new blacklister is the zero address"
);
blacklister = _newBlacklister;
emit BlacklisterChanged(blacklister);
}
/**
* @dev Checks if account is blacklisted.
* @param _account The address to check.
* @return true if the account is blacklisted, false otherwise.
*/
function _isBlacklisted(address _account)
internal
virtual
view
returns (bool);
/**
* @dev Helper method that blacklists an account.
* @param _account The address to blacklist.
*/
function _blacklist(address _account) internal virtual;
/**
* @dev Helper method that unblacklists an account.
* @param _account The address to unblacklist.
*/
function _unBlacklist(address _account) internal virtual;
}
FiatTokenV1
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol";
import { AbstractFiatTokenV1 } from "./AbstractFiatTokenV1.sol";
import { Ownable } from "./Ownable.sol";
import { Pausable } from "./Pausable.sol";
import { Blacklistable } from "./Blacklistable.sol";
/**
* @title FiatToken
* @dev ERC20 Token backed by fiat reserves
*/
contract FiatTokenV1 is AbstractFiatTokenV1, Ownable, Pausable, Blacklistable {
using SafeMath for uint256;
string public name;
string public symbol;
uint8 public decimals;
string public currency;
address public masterMinter;
bool internal initialized;
/// @dev A mapping that stores the balance and blacklist states for a given address.
/// The first bit defines whether the address is blacklisted (1 if blacklisted, 0 otherwise).
/// The last 255 bits define the balance for the address.
mapping(address => uint256) internal balanceAndBlacklistStates;
mapping(address => mapping(address => uint256)) internal allowed;
uint256 internal totalSupply_ = 0;
mapping(address => bool) internal minters;
mapping(address => uint256) internal minterAllowed;
event Mint(address indexed minter, address indexed to, uint256 amount);
event Burn(address indexed burner, uint256 amount);
event MinterConfigured(address indexed minter, uint256 minterAllowedAmount);
event MinterRemoved(address indexed oldMinter);
event MasterMinterChanged(address indexed newMasterMinter);
/**
* @notice Initializes the fiat token contract.
* @param tokenName The name of the fiat token.
* @param tokenSymbol The symbol of the fiat token.
* @param tokenCurrency The fiat currency that the token represents.
* @param tokenDecimals The number of decimals that the token uses.
* @param newMasterMinter The masterMinter address for the fiat token.
* @param newPauser The pauser address for the fiat token.
* @param newBlacklister The blacklister address for the fiat token.
* @param newOwner The owner of the fiat token.
*/
function initialize(
string memory tokenName,
string memory tokenSymbol,
string memory tokenCurrency,
uint8 tokenDecimals,
address newMasterMinter,
address newPauser,
address newBlacklister,
address newOwner
) public {
require(!initialized, "FiatToken: contract is already initialized");
require(
newMasterMinter != address(0),
"FiatToken: new masterMinter is the zero address"
);
require(
newPauser != address(0),
"FiatToken: new pauser is the zero address"
);
require(
newBlacklister != address(0),
"FiatToken: new blacklister is the zero address"
);
require(
newOwner != address(0),
"FiatToken: new owner is the zero address"
);
name = tokenName;
symbol = tokenSymbol;
currency = tokenCurrency;
decimals = tokenDecimals;
masterMinter = newMasterMinter;
pauser = newPauser;
blacklister = newBlacklister;
setOwner(newOwner);
initialized = true;
}
/**
* @dev Throws if called by any account other than a minter.
*/
modifier onlyMinters() {
require(minters[msg.sender], "FiatToken: caller is not a minter");
_;
}
/**
* @notice Mints fiat tokens to an address.
* @param _to The address that will receive the minted tokens.
* @param _amount The amount of tokens to mint. Must be less than or equal
* to the minterAllowance of the caller.
* @return True if the operation was successful.
*/
function mint(address _to, uint256 _amount)
external
whenNotPaused
onlyMinters
notBlacklisted(msg.sender)
notBlacklisted(_to)
returns (bool)
{
require(_to != address(0), "FiatToken: mint to the zero address");
require(_amount > 0, "FiatToken: mint amount not greater than 0");
uint256 mintingAllowedAmount = minterAllowed[msg.sender];
require(
_amount <= mintingAllowedAmount,
"FiatToken: mint amount exceeds minterAllowance"
);
totalSupply_ = totalSupply_.add(_amount);
_setBalance(_to, _balanceOf(_to).add(_amount));
minterAllowed[msg.sender] = mintingAllowedAmount.sub(_amount);
emit Mint(msg.sender, _to, _amount);
emit Transfer(address(0), _to, _amount);
return true;
}
/**
* @dev Throws if called by any account other than the masterMinter
*/
modifier onlyMasterMinter() {
require(
msg.sender == masterMinter,
"FiatToken: caller is not the masterMinter"
);
_;
}
/**
* @notice Gets the minter allowance for an account.
* @param minter The address to check.
* @return The remaining minter allowance for the account.
*/
function minterAllowance(address minter) external view returns (uint256) {
return minterAllowed[minter];
}
/**
* @notice Checks if an account is a minter.
* @param account The address to check.
* @return True if the account is a minter, false if the account is not a minter.
*/
function isMinter(address account) external view returns (bool) {
return minters[account];
}
/**
* @notice Gets the remaining amount of fiat tokens a spender is allowed to transfer on
* behalf of the token owner.
* @param owner The token owner's address.
* @param spender The spender's address.
* @return The remaining allowance.
*/
function allowance(address owner, address spender)
external
override
view
returns (uint256)
{
return allowed[owner][spender];
}
/**
* @notice Gets the totalSupply of the fiat token.
* @return The totalSupply of the fiat token.
*/
function totalSupply() external override view returns (uint256) {
return totalSupply_;
}
/**
* @notice Gets the fiat token balance of an account.
* @param account The address to check.
* @return balance The fiat token balance of the account.
*/
function balanceOf(address account)
external
override
view
returns (uint256)
{
return _balanceOf(account);
}
/**
* @notice Sets a fiat token allowance for a spender to spend on behalf of the caller.
* @param spender The spender's address.
* @param value The allowance amount.
* @return True if the operation was successful.
*/
function approve(address spender, uint256 value)
external
virtual
override
whenNotPaused
notBlacklisted(msg.sender)
notBlacklisted(spender)
returns (bool)
{
_approve(msg.sender, spender, value);
return true;
}
/**
* @dev Internal function to set allowance.
* @param owner Token owner's address.
* @param spender Spender's address.
* @param value Allowance amount.
*/
function _approve(
address owner,
address spender,
uint256 value
) internal override {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
allowed[owner][spender] = value;
emit Approval(owner, spender, value);
}
/**
* @notice Transfers tokens from an address to another by spending the caller's allowance.
* @dev The caller must have some fiat token allowance on the payer's tokens.
* @param from Payer's address.
* @param to Payee's address.
* @param value Transfer amount.
* @return True if the operation was successful.
*/
function transferFrom(
address from,
address to,
uint256 value
)
external
override
whenNotPaused
notBlacklisted(msg.sender)
notBlacklisted(from)
notBlacklisted(to)
returns (bool)
{
require(
value <= allowed[from][msg.sender],
"ERC20: transfer amount exceeds allowance"
);
_transfer(from, to, value);
allowed[from][msg.sender] = allowed[from][msg.sender].sub(value);
return true;
}
/**
* @notice Transfers tokens from the caller.
* @param to Payee's address.
* @param value Transfer amount.
* @return True if the operation was successful.
*/
function transfer(address to, uint256 value)
external
override
whenNotPaused
notBlacklisted(msg.sender)
notBlacklisted(to)
returns (bool)
{
_transfer(msg.sender, to, value);
return true;
}
/**
* @dev Internal function to process transfers.
* @param from Payer's address.
* @param to Payee's address.
* @param value Transfer amount.
*/
function _transfer(
address from,
address to,
uint256 value
) internal override {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
require(
value <= _balanceOf(from),
"ERC20: transfer amount exceeds balance"
);
_setBalance(from, _balanceOf(from).sub(value));
_setBalance(to, _balanceOf(to).add(value));
emit Transfer(from, to, value);
}
/**
* @notice Adds or updates a new minter with a mint allowance.
* @param minter The address of the minter.
* @param minterAllowedAmount The minting amount allowed for the minter.
* @return True if the operation was successful.
*/
function configureMinter(address minter, uint256 minterAllowedAmount)
external
whenNotPaused
onlyMasterMinter
returns (bool)
{
minters[minter] = true;
minterAllowed[minter] = minterAllowedAmount;
emit MinterConfigured(minter, minterAllowedAmount);
return true;
}
/**
* @notice Removes a minter.
* @param minter The address of the minter to remove.
* @return True if the operation was successful.
*/
function removeMinter(address minter)
external
onlyMasterMinter
returns (bool)
{
minters[minter] = false;
minterAllowed[minter] = 0;
emit MinterRemoved(minter);
return true;
}
/**
* @notice Allows a minter to burn some of its own tokens.
* @dev The caller must be a minter, must not be blacklisted, and the amount to burn
* should be less than or equal to the account's balance.
* @param _amount the amount of tokens to be burned.
*/
function burn(uint256 _amount)
external
whenNotPaused
onlyMinters
notBlacklisted(msg.sender)
{
uint256 balance = _balanceOf(msg.sender);
require(_amount > 0, "FiatToken: burn amount not greater than 0");
require(balance >= _amount, "FiatToken: burn amount exceeds balance");
totalSupply_ = totalSupply_.sub(_amount);
_setBalance(msg.sender, balance.sub(_amount));
emit Burn(msg.sender, _amount);
emit Transfer(msg.sender, address(0), _amount);
}
/**
* @notice Updates the master minter address.
* @param _newMasterMinter The address of the new master minter.
*/
function updateMasterMinter(address _newMasterMinter) external onlyOwner {
require(
_newMasterMinter != address(0),
"FiatToken: new masterMinter is the zero address"
);
masterMinter = _newMasterMinter;
emit MasterMinterChanged(masterMinter);
}
/**
* @inheritdoc Blacklistable
*/
function _blacklist(address _account) internal override {
_setBlacklistState(_account, true);
}
/**
* @inheritdoc Blacklistable
*/
function _unBlacklist(address _account) internal override {
_setBlacklistState(_account, false);
}
/**
* @dev Helper method that sets the blacklist state of an account.
* @param _account The address of the account.
* @param _shouldBlacklist True if the account should be blacklisted, false if the account should be unblacklisted.
*/
function _setBlacklistState(address _account, bool _shouldBlacklist)
internal
virtual
{
_deprecatedBlacklisted[_account] = _shouldBlacklist;
}
/**
* @dev Helper method that sets the balance of an account.
* @param _account The address of the account.
* @param _balance The new fiat token balance of the account.
*/
function _setBalance(address _account, uint256 _balance) internal virtual {
balanceAndBlacklistStates[_account] = _balance;
}
/**
* @inheritdoc Blacklistable
*/
function _isBlacklisted(address _account)
internal
virtual
override
view
returns (bool)
{
return _deprecatedBlacklisted[_account];
}
/**
* @dev Helper method to obtain the balance of an account.
* @param _account The address of the account.
* @return The fiat token balance of the account.
*/
function _balanceOf(address _account)
internal
virtual
view
returns (uint256)
{
return balanceAndBlacklistStates[_account];
}
}
USDC_V1_1 源码解析
V1_1设计结构
逐模块分析
FiatTokenV1_1.sol
Rescuable.sol
FiatTokenV1_1
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { FiatTokenV1 } from "../v1/FiatTokenV1.sol";
import { Rescuable } from "./Rescuable.sol";
/**
* @title FiatTokenV1_1
* @dev ERC20 Token backed by fiat reserves
*/
contract FiatTokenV1_1 is FiatTokenV1, Rescuable {
}
Rescuable
Rescuable
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { Ownable } from "../v1/Ownable.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol";
contract Rescuable is Ownable {
using SafeERC20 for IERC20;
address private _rescuer;
event RescuerChanged(address indexed newRescuer);
/**
* @notice Returns current rescuer
* @return Rescuer's address
*/
function rescuer() external view returns (address) {
return _rescuer;
}
/**
* @notice Revert if called by any account other than the rescuer.
*/
modifier onlyRescuer() {
require(msg.sender == _rescuer, "Rescuable: caller is not the rescuer");
_;
}
/**
* @notice Rescue ERC20 tokens locked up in this contract.
* @param tokenContract ERC20 token contract address
* @param to Recipient address
* @param amount Amount to withdraw
*/
function rescueERC20(
IERC20 tokenContract,
address to,
uint256 amount
) external onlyRescuer {
tokenContract.safeTransfer(to, amount);
}
/**
* @notice Updates the rescuer address.
* @param newRescuer The address of the new rescuer.
*/
function updateRescuer(address newRescuer) external onlyOwner {
require(
newRescuer != address(0),
"Rescuable: new rescuer is the zero address"
);
_rescuer = newRescuer;
emit RescuerChanged(newRescuer);
}
}
SafeERC20.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;
import "./IERC20.sol";
import "../../math/SafeMath.sol";
import "../../utils/Address.sol";
/**
* @title SafeERC20
* @dev Wrappers around ERC20 operations that throw on failure (when the token
* contract returns false). Tokens that return no value (and instead revert or
* throw on failure) are also supported, non-reverting calls are assumed to be
* successful.
* To use this library you can add a `using SafeERC20 for IERC20;` statement to your contract,
* which allows you to call the safe operations as `token.safeTransfer(...)`, etc.
*/
library SafeERC20 {
using SafeMath for uint256;
using Address for address;
function safeTransfer(IERC20 token, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
}
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
}
/**
* @dev Deprecated. This function has issues similar to the ones found in
* {IERC20-approve}, and its usage is discouraged.
*
* Whenever possible, use {safeIncreaseAllowance} and
* {safeDecreaseAllowance} instead.
*/
function safeApprove(IERC20 token, address spender, uint256 value) internal {
// safeApprove should only be called when setting an initial allowance,
// or when resetting it to zero. To increase and decrease it, use
// 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
// solhint-disable-next-line max-line-length
require((value == 0) || (token.allowance(address(this), spender) == 0),
"SafeERC20: approve from non-zero to non-zero allowance"
);
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
}
function safeIncreaseAllowance(IERC20 token, address spender, uint256 value) internal {
uint256 newAllowance = token.allowance(address(this), spender).add(value);
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance));
}
function safeDecreaseAllowance(IERC20 token, address spender, uint256 value) internal {
uint256 newAllowance = token.allowance(address(this), spender).sub(value, "SafeERC20: decreased allowance below zero");
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance));
}
/**
* @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
* on the return value: the return value is optional (but if data is returned, it must not be false).
* @param token The token targeted by the call.
* @param data The call data (encoded using abi.encode or one of its variants).
*/
function _callOptionalReturn(IERC20 token, bytes memory data) private {
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
// we're implementing it ourselves. We use {Address.functionCall} to perform this call, which verifies that
// the target address contains contract code and also asserts for success in the low-level call.
bytes memory returndata = address(token).functionCall(data, "SafeERC20: low-level call failed");
if (returndata.length > 0) { // Return data is optional
// solhint-disable-next-line max-line-length
require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
}
}
}
USDC_V2源码解析
V2设计结构
逐模块分析
AbstractFiatTokenV2.sol
EIP712Domain.sol
EIP2612.sol
EIP3009.sol
FiatTokenV2.sol (重要)
FiatTokenV2_1.sol (重要)
FiatTokenV2_2.sol (重要)
AbstractFiatTokenV2
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { AbstractFiatTokenV1 } from "../v1/AbstractFiatTokenV1.sol";
abstract contract AbstractFiatTokenV2 is AbstractFiatTokenV1 {
function _increaseAllowance(
address owner,
address spender,
uint256 increment
) internal virtual;
function _decreaseAllowance(
address owner,
address spender,
uint256 decrement
) internal virtual;
}
EIP712Domain
EIP712题案细节详见链接EIP712
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
// solhint-disable func-name-mixedcase
/**
* @title EIP712 Domain
*/
contract EIP712Domain {
// was originally DOMAIN_SEPARATOR
// but that has been moved to a method so we can override it in V2_2+
bytes32 internal _DEPRECATED_CACHED_DOMAIN_SEPARATOR;
/**
* @notice Get the EIP712 Domain Separator.
* @return The bytes32 EIP712 domain separator.
*/
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return _domainSeparator();
}
/**
* @dev Internal method to get the EIP712 Domain Separator.
* @return The bytes32 EIP712 domain separator.
*/
function _domainSeparator() internal virtual view returns (bytes32) {
return _DEPRECATED_CACHED_DOMAIN_SEPARATOR;
}
}
USDC 源码:permit 的 nonce、截止时间与合约签名
标准接口见 EIP-2612。这里分析 Circle 的具体实现,不把它的扩展直接当成标准要求。
1. 源码范围与版本
本文代码主体已对照 Circle 官方 stablecoin-evm 提交 574b947dfcfcb5fa05b46d8961ece29e06b14129,文件为 contracts/v2/EIP2612.sol,编译器声明是 Solidity 0.6.12。正文保留原先的许可注释;其许可头与该提交不同,pragma 之后的主体一致。固定源码
这固定的是仓库快照,不是任一网络的当前 USDC 部署认证。代理地址、实现地址、升级时点、库链接和链 ID 需要另查链上记录。本轮没有编译或部署完整 USDC 继承链。
2. 从消息到 allowance
Owner 签入自己、Spender、Value、当前 permit nonce 与 Deadline。_permit 验证后调用 _approve 覆盖额度;它不转账、不决定之后的收款人,也不让额度自动在 Deadline 到达时失效。
- 检查
deadline >= now,或使用最大 uint256 表示许可提交不设时间上限。 - 取
_permitNonces[owner]的旧值参与摘要,并递增存储值。 - 将结构体哈希与
_domainSeparator()组合为 EIP-712 摘要。 - 使用该快照的
SignatureChecker验证 Owner 的当前签名策略。 _approve(owner, spender, value)建立授权额度。
nonce 的自增虽在验签前发生,验签失败时整个调用回滚,nonce 不会因此被永久消费。域来自虚函数 _domainSeparator(),实际行为还要沿最终实现的覆盖关系分析,不能只看本文件就断定缓存永不变化。
3. deadline 不是授权的有效期
deadline 限制这份签名何时还能提交。如果它在有效期内已经成功建立 allowance,后来超过 deadline,剩余额度仍可代扣。最大值表示签名提交不设通常的过期边界,不表示一种额外的“永久额度”类型。
若业务将 permit 与消费放进同一交易,后续转账回滚且异常没有被吞掉,当前交易的许可与额度变化也回滚。单独已经确认的 permit 不会因后来另一笔业务交易失败而撤销。
4. 此实现的合约签名扩展
这里同时有 (v,r,s) 和变长 signature 两个内部重载;前者按 r,s,v 拼成字节,再走统一逻辑。变长签名支持钱包策略,不意味着标准 EIP-2612 的所有实现都有该重载。
同一快照的 contracts/util/SignatureChecker.sol 根据代码大小选择 ECDSA 恢复或 ERC-1271 静态查询。它是 Circle 的库实现,包含自己的恢复路径,并不是本博客教学工程使用的 OpenZeppelin 5.2.0 文件。固定库源码
外部用户能调用哪个重载,还应查看最终 FiatToken 实现暴露的函数。合约签名可能因钱包策略变更而失效;code.length 分支也要结合 EIP-7702 委托账户说明,不能理解成“有代码就没有私钥”。
5. 与教学测试的对应关系
独立示例工程 使用 Solidity 0.8.24 与 OpenZeppelin 5.2.0,验证 nonce、deadline、业务代扣与回滚边界。这些结果说明教学模型,不证明上述 0.6.12 USDC 源码或某个线上部署已经通过测试。
源码材料
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { AbstractFiatTokenV2 } from "./AbstractFiatTokenV2.sol";
import { EIP712Domain } from "./EIP712Domain.sol";
import { MessageHashUtils } from "../util/MessageHashUtils.sol";
import { SignatureChecker } from "../util/SignatureChecker.sol";
/**
* @title EIP-2612
* @notice Provide internal implementation for gas-abstracted approvals
*/
abstract contract EIP2612 is AbstractFiatTokenV2, EIP712Domain {
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
bytes32
public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
mapping(address => uint256) private _permitNonces;
/**
* @notice Nonces for permit
* @param owner Token owner's address (Authorizer)
* @return Next nonce
*/
function nonces(address owner) external view returns (uint256) {
return _permitNonces[owner];
}
/**
* @notice Verify a signed approval permit and execute if valid
* @param owner Token owner's address (Authorizer)
* @param spender Spender's address
* @param value Amount of allowance
* @param deadline The time at which the signature expires (unix time), or max uint256 value to signal no expiration
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function _permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) internal {
_permit(owner, spender, value, deadline, abi.encodePacked(r, s, v));
}
/**
* @notice Verify a signed approval permit and execute if valid
* @dev EOA wallet signatures should be packed in the order of r, s, v.
* @param owner Token owner's address (Authorizer)
* @param spender Spender's address
* @param value Amount of allowance
* @param deadline The time at which the signature expires (unix time), or max uint256 value to signal no expiration
* @param signature Signature byte array signed by an EOA wallet or a contract wallet
*/
function _permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
bytes memory signature
) internal {
require(
deadline == type(uint256).max || deadline >= now,
"FiatTokenV2: permit is expired"
);
bytes32 typedDataHash = MessageHashUtils.toTypedDataHash(
_domainSeparator(),
keccak256(
abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
_permitNonces[owner]++,
deadline
)
)
);
require(
SignatureChecker.isValidSignatureNow(
owner,
typedDataHash,
signature
),
"EIP2612: invalid signature"
);
_approve(owner, spender, value);
}
}
EIP3009
这段代码实现了EIP-3009标准,提供了一种气体抽象转账(gas-abstracted transfers)的内部实现。
主要功能
EIP-3009允许用户通过离线签名消息授权他人代表自己执行代币转账,而无需亲自支付气体费用。这种模式类似于EIP-2612的授权机制,但专注于直接转账操作。
核心组件
-
三种类型的交易:
transferWithAuthorization: 任何人都可以提交已签名的转账授权receiveWithAuthorization: 只有指定的接收者可以提交已签名的转账授权cancelAuthorization: 允许签名者取消之前未使用的授权
-
授权状态跟踪:
- 使用
_authorizationStates双重映射跟踪每个授权者的每个nonce的使用状态 - 每个授权使用随机生成的32字节nonce来确保唯一性
- 使用
-
事件通知:
AuthorizationUsed: 当授权被使用时发出AuthorizationCanceled: 当授权被取消时发出
工作流程
-
创建授权:
- 用户离线签署包含转账详情的消息(发送方、接收方、金额、有效期、nonce)
- 签名使用EIP-712标准格式化数据以提高可读性和安全性
-
提交授权:
- 对于
transferWithAuthorization:任何人都可以提交授权 - 对于
receiveWithAuthorization:只有指定的接收方可以提交(防止抢跑攻击)
- 对于
-
验证机制:
- 确保授权在有效时间范围内
- 验证nonce未被使用
- 验证签名的有效性
- 标记授权为已使用
- 执行实际转账
技术亮点
-
灵活的安全性:
- 支持时间窗口限制(validAfter/validBefore)
- 支持取消未使用的授权
- 支持智能合约钱包的签名(通过SignatureChecker)
-
改进的用户体验:
- 用户可以离线签署多个转账
- 接收方或第三方可代付气体费用
- 允许创建未来生效的授权
-
防止常见攻击:
- 使用nonce防止重放攻击
receiveWithAuthorization特别增加了调用者验证,防止抢跑
实际应用场景
- 代付费用交易:第三方可以为用户支付气体费用
- 批量处理:离线收集多个签名后一次性处理
- 延迟执行:设置未来时间窗口的授权
- 接收方发起:允许接收方通过
receiveWithAuthorization主动提取授权的资金
这种模式极大地提高了代币的使用灵活性,特别适用于稳定币和高频使用的代币,可以明显降低与DeFi协议交互的摩擦成本。它与EIP-2612(授权机制)相辅相成,共同构成现代代币标准的重要组成部分。
离线签署的基本原理
用户不需要直接与区块链交互,而是在自己的设备上创建并签署一条消息,这条消息包含了转账的所有必要信息。直接的理解就是写支票给别人去银行兑现,自己在这过程中无需与银行沟通。
签署过程详解
-
构建结构化数据:
- 根据交易类型(转账、接收或取消),构建一个遵循EIP-712标准的结构化数据
- 包含发送方地址、接收方地址、金额、有效时间、nonce等信息
-
创建消息哈希:
- 使用typeHash(如
TRANSFER_WITH_AUTHORIZATION_TYPEHASH) - 将所有参数编码:
abi.encode(TYPEHASH, from, to, value, validAfter, validBefore, nonce) - 对编码后的数据进行keccak256哈希
- 使用typeHash(如
-
应用EIP-712域分隔符:
- 将消息哈希与域分隔符(
_domainSeparator())结合 - 使用
MessageHashUtils.toTypedDataHash()方法生成最终待签名哈希
- 将消息哈希与域分隔符(
-
使用私钥签名:
- 用户使用自己的私钥对最终哈希进行签名
- 生成签名组件(v, r, s)或打包的签名字节数组
代码中的实现
在代码中,这个过程对应于:
// 创建消息哈希
bytes32 dataHash = keccak256(
abi.encode(
TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
from,
to,
value,
validAfter,
validBefore,
nonce
)
);
// 应用域分隔符并验证签名
SignatureChecker.isValidSignatureNow(
signer,
MessageHashUtils.toTypedDataHash(_domainSeparator(), dataHash),
signature
)
实际工作流程
-
用户端:
- 用户使用钱包应用或库(如web3.js、ethers.js)
- 创建转账意图(包含接收方、金额等)
- 生成随机nonce和设定有效时间窗口
- 构建结构化数据并使用私钥签名
- 将签名和原始参数共享给执行者
-
执行者端:
- 接收签名和参数
- 调用合约的
transferWithAuthorization或receiveWithAuthorization - 提供签名和所有必要参数
- 支付交易的gas费用
安全性保障
- 确定性哈希:EIP-712确保不同应用为相同数据生成相同哈希
- 域分隔符:防止跨合约重放攻击
- nonce机制:防止同一授权被多次使用
- 时间窗口:授权只在特定时间段有效
- 取消机制:授权者可以取消未使用的授权
这种离线签署机制的最大优势是将签名创建(需要私钥)与交易提交(需要ETH支付gas)完全分离。
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { AbstractFiatTokenV2 } from "./AbstractFiatTokenV2.sol";
import { EIP712Domain } from "./EIP712Domain.sol";
import { SignatureChecker } from "../util/SignatureChecker.sol";
import { MessageHashUtils } from "../util/MessageHashUtils.sol";
/**
* @title EIP-3009
* @notice Provide internal implementation for gas-abstracted transfers
* @dev Contracts that inherit from this must wrap these with publicly
* accessible functions, optionally adding modifiers where necessary
*/
abstract contract EIP3009 is AbstractFiatTokenV2, EIP712Domain {
// keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
bytes32
public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;
// keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
bytes32
public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;
// keccak256("CancelAuthorization(address authorizer,bytes32 nonce)")
bytes32
public constant CANCEL_AUTHORIZATION_TYPEHASH = 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429;
/**
* @dev authorizer address => nonce => bool (true if nonce is used)
*/
mapping(address => mapping(bytes32 => bool)) private _authorizationStates;
event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);
event AuthorizationCanceled(
address indexed authorizer,
bytes32 indexed nonce
);
/**
* @notice Returns the state of an authorization
* @dev Nonces are randomly generated 32-byte data unique to the
* authorizer's address
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
* @return True if the nonce is used
*/
function authorizationState(address authorizer, bytes32 nonce)
external
view
returns (bool)
{
return _authorizationStates[authorizer][nonce];
}
/**
* @notice Execute a transfer with a signed authorization
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function _transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) internal {
_transferWithAuthorization(
from,
to,
value,
validAfter,
validBefore,
nonce,
abi.encodePacked(r, s, v)
);
}
/**
* @notice Execute a transfer with a signed authorization
* @dev EOA wallet signatures should be packed in the order of r, s, v.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param signature Signature byte array produced by an EOA wallet or a contract wallet
*/
function _transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes memory signature
) internal {
_requireValidAuthorization(from, nonce, validAfter, validBefore);
_requireValidSignature(
from,
keccak256(
abi.encode(
TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
from,
to,
value,
validAfter,
validBefore,
nonce
)
),
signature
);
_markAuthorizationAsUsed(from, nonce);
_transfer(from, to, value);
}
/**
* @notice Receive a transfer with a signed authorization from the payer
* @dev This has an additional check to ensure that the payee's address
* matches the caller of this function to prevent front-running attacks.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function _receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) internal {
_receiveWithAuthorization(
from,
to,
value,
validAfter,
validBefore,
nonce,
abi.encodePacked(r, s, v)
);
}
/**
* @notice Receive a transfer with a signed authorization from the payer
* @dev This has an additional check to ensure that the payee's address
* matches the caller of this function to prevent front-running attacks.
* EOA wallet signatures should be packed in the order of r, s, v.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param signature Signature byte array produced by an EOA wallet or a contract wallet
*/
function _receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes memory signature
) internal {
require(to == msg.sender, "FiatTokenV2: caller must be the payee");
_requireValidAuthorization(from, nonce, validAfter, validBefore);
_requireValidSignature(
from,
keccak256(
abi.encode(
RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
from,
to,
value,
validAfter,
validBefore,
nonce
)
),
signature
);
_markAuthorizationAsUsed(from, nonce);
_transfer(from, to, value);
}
/**
* @notice Attempt to cancel an authorization
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function _cancelAuthorization(
address authorizer,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) internal {
_cancelAuthorization(authorizer, nonce, abi.encodePacked(r, s, v));
}
/**
* @notice Attempt to cancel an authorization
* @dev EOA wallet signatures should be packed in the order of r, s, v.
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
* @param signature Signature byte array produced by an EOA wallet or a contract wallet
*/
function _cancelAuthorization(
address authorizer,
bytes32 nonce,
bytes memory signature
) internal {
_requireUnusedAuthorization(authorizer, nonce);
_requireValidSignature(
authorizer,
keccak256(
abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)
),
signature
);
_authorizationStates[authorizer][nonce] = true;
emit AuthorizationCanceled(authorizer, nonce);
}
/**
* @notice Validates that signature against input data struct
* @param signer Signer's address
* @param dataHash Hash of encoded data struct
* @param signature Signature byte array produced by an EOA wallet or a contract wallet
*/
function _requireValidSignature(
address signer,
bytes32 dataHash,
bytes memory signature
) private view {
require(
SignatureChecker.isValidSignatureNow(
signer,
MessageHashUtils.toTypedDataHash(_domainSeparator(), dataHash),
signature
),
"FiatTokenV2: invalid signature"
);
}
/**
* @notice Check that an authorization is unused
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
*/
function _requireUnusedAuthorization(address authorizer, bytes32 nonce)
private
view
{
require(
!_authorizationStates[authorizer][nonce],
"FiatTokenV2: authorization is used or canceled"
);
}
/**
* @notice Check that authorization is valid
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
*/
function _requireValidAuthorization(
address authorizer,
bytes32 nonce,
uint256 validAfter,
uint256 validBefore
) private view {
require(
now > validAfter,
"FiatTokenV2: authorization is not yet valid"
);
require(now < validBefore, "FiatTokenV2: authorization is expired");
_requireUnusedAuthorization(authorizer, nonce);
}
/**
* @notice Mark an authorization as used
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
*/
function _markAuthorizationAsUsed(address authorizer, bytes32 nonce)
private
{
_authorizationStates[authorizer][nonce] = true;
emit AuthorizationUsed(authorizer, nonce);
}
}
FiatTokenV2
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { FiatTokenV1_1 } from "../v1.1/FiatTokenV1_1.sol";
import { EIP712 } from "../util/EIP712.sol";
import { EIP3009 } from "./EIP3009.sol";
import { EIP2612 } from "./EIP2612.sol";
/**
* @title FiatToken V2
* @notice ERC20 Token backed by fiat reserves, version 2
*/
contract FiatTokenV2 is FiatTokenV1_1, EIP3009, EIP2612 {
uint8 internal _initializedVersion;
/**
* @notice Initialize v2
* @param newName New token name
*/
function initializeV2(string calldata newName) external {
// solhint-disable-next-line reason-string
require(initialized && _initializedVersion == 0);
name = newName;
_DEPRECATED_CACHED_DOMAIN_SEPARATOR = EIP712.makeDomainSeparator(
newName,
"2"
);
_initializedVersion = 1;
}
/**
* @notice Increase the allowance by a given increment
* @param spender Spender's address
* @param increment Amount of increase in allowance
* @return True if successful
*/
function increaseAllowance(address spender, uint256 increment)
external
virtual
whenNotPaused
notBlacklisted(msg.sender)
notBlacklisted(spender)
returns (bool)
{
_increaseAllowance(msg.sender, spender, increment);
return true;
}
/**
* @notice Decrease the allowance by a given decrement
* @param spender Spender's address
* @param decrement Amount of decrease in allowance
* @return True if successful
*/
function decreaseAllowance(address spender, uint256 decrement)
external
virtual
whenNotPaused
notBlacklisted(msg.sender)
notBlacklisted(spender)
returns (bool)
{
_decreaseAllowance(msg.sender, spender, decrement);
return true;
}
/**
* @notice Execute a transfer with a signed authorization
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external whenNotPaused notBlacklisted(from) notBlacklisted(to) {
_transferWithAuthorization(
from,
to,
value,
validAfter,
validBefore,
nonce,
v,
r,
s
);
}
/**
* @notice Receive a transfer with a signed authorization from the payer
* @dev This has an additional check to ensure that the payee's address
* matches the caller of this function to prevent front-running attacks.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external whenNotPaused notBlacklisted(from) notBlacklisted(to) {
_receiveWithAuthorization(
from,
to,
value,
validAfter,
validBefore,
nonce,
v,
r,
s
);
}
/**
* @notice Attempt to cancel an authorization
* @dev Works only if the authorization is not yet used.
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function cancelAuthorization(
address authorizer,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external whenNotPaused {
_cancelAuthorization(authorizer, nonce, v, r, s);
}
/**
* @notice Update allowance with a signed permit
* @param owner Token owner's address (Authorizer)
* @param spender Spender's address
* @param value Amount of allowance
* @param deadline The time at which the signature expires (unix time), or max uint256 value to signal no expiration
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
)
external
virtual
whenNotPaused
notBlacklisted(owner)
notBlacklisted(spender)
{
_permit(owner, spender, value, deadline, v, r, s);
}
/**
* @dev Internal function to increase the allowance by a given increment
* @param owner Token owner's address
* @param spender Spender's address
* @param increment Amount of increase
*/
function _increaseAllowance(
address owner,
address spender,
uint256 increment
) internal override {
_approve(owner, spender, allowed[owner][spender].add(increment));
}
/**
* @dev Internal function to decrease the allowance by a given decrement
* @param owner Token owner's address
* @param spender Spender's address
* @param decrement Amount of decrease
*/
function _decreaseAllowance(
address owner,
address spender,
uint256 decrement
) internal override {
_approve(
owner,
spender,
allowed[owner][spender].sub(
decrement,
"ERC20: decreased allowance below zero"
)
);
}
}
FiatTokenV2_1
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { FiatTokenV2 } from "./FiatTokenV2.sol";
// solhint-disable func-name-mixedcase
/**
* @title FiatToken V2.1
* @notice ERC20 Token backed by fiat reserves, version 2.1
*/
contract FiatTokenV2_1 is FiatTokenV2 {
/**
* @notice Initialize v2.1
* @param lostAndFound The address to which the locked funds are sent
*/
function initializeV2_1(address lostAndFound) external {
// solhint-disable-next-line reason-string
require(_initializedVersion == 1);
uint256 lockedAmount = _balanceOf(address(this));
if (lockedAmount > 0) {
_transfer(address(this), lostAndFound, lockedAmount);
}
_blacklist(address(this));
_initializedVersion = 2;
}
/**
* @notice Version string for the EIP712 domain separator
* @return Version string
*/
function version() external pure returns (string memory) {
return "2";
}
}
FiatTokenV2_2
/**
* SPDX-License-Identifier: Apache-2.0
*
* Copyright (c) 2023, Circle Internet Financial, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity 0.6.12;
import { EIP712Domain } from "./EIP712Domain.sol"; // solhint-disable-line no-unused-import
import { Blacklistable } from "../v1/Blacklistable.sol"; // solhint-disable-line no-unused-import
import { FiatTokenV1 } from "../v1/FiatTokenV1.sol"; // solhint-disable-line no-unused-import
import { FiatTokenV2 } from "./FiatTokenV2.sol"; // solhint-disable-line no-unused-import
import { FiatTokenV2_1 } from "./FiatTokenV2_1.sol";
import { EIP712 } from "../util/EIP712.sol";
// solhint-disable func-name-mixedcase
/**
* @title FiatToken V2.2
* @notice ERC20 Token backed by fiat reserves, version 2.2
*/
contract FiatTokenV2_2 is FiatTokenV2_1 {
/**
* @notice Initialize v2.2
* @param accountsToBlacklist A list of accounts to migrate from the old blacklist
* @param newSymbol New token symbol
* data structure to the new blacklist data structure.
*/
function initializeV2_2(
address[] calldata accountsToBlacklist,
string calldata newSymbol
) external {
// solhint-disable-next-line reason-string
require(_initializedVersion == 2);
// Update fiat token symbol
symbol = newSymbol;
// Add previously blacklisted accounts to the new blacklist data structure
// and remove them from the old blacklist data structure.
for (uint256 i = 0; i < accountsToBlacklist.length; i++) {
require(
_deprecatedBlacklisted[accountsToBlacklist[i]],
"FiatTokenV2_2: Blacklisting previously unblacklisted account!"
);
_blacklist(accountsToBlacklist[i]);
delete _deprecatedBlacklisted[accountsToBlacklist[i]];
}
_blacklist(address(this));
delete _deprecatedBlacklisted[address(this)];
_initializedVersion = 3;
}
/**
* @dev Internal function to get the current chain id.
* @return The current chain id.
*/
function _chainId() internal virtual view returns (uint256) {
uint256 chainId;
assembly {
chainId := chainid()
}
return chainId;
}
/**
* @inheritdoc EIP712Domain
*/
function _domainSeparator() internal override view returns (bytes32) {
return EIP712.makeDomainSeparator(name, "2", _chainId());
}
/**
* @notice Update allowance with a signed permit
* @dev EOA wallet signatures should be packed in the order of r, s, v.
* @param owner Token owner's address (Authorizer)
* @param spender Spender's address
* @param value Amount of allowance
* @param deadline The time at which the signature expires (unix time), or max uint256 value to signal no expiration
* @param signature Signature bytes signed by an EOA wallet or a contract wallet
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
bytes memory signature
) external whenNotPaused {
_permit(owner, spender, value, deadline, signature);
}
/**
* @notice Execute a transfer with a signed authorization
* @dev EOA wallet signatures should be packed in the order of r, s, v.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param signature Signature bytes signed by an EOA wallet or a contract wallet
*/
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes memory signature
) external whenNotPaused notBlacklisted(from) notBlacklisted(to) {
_transferWithAuthorization(
from,
to,
value,
validAfter,
validBefore,
nonce,
signature
);
}
/**
* @notice Receive a transfer with a signed authorization from the payer
* @dev This has an additional check to ensure that the payee's address
* matches the caller of this function to prevent front-running attacks.
* EOA wallet signatures should be packed in the order of r, s, v.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param signature Signature bytes signed by an EOA wallet or a contract wallet
*/
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
bytes memory signature
) external whenNotPaused notBlacklisted(from) notBlacklisted(to) {
_receiveWithAuthorization(
from,
to,
value,
validAfter,
validBefore,
nonce,
signature
);
}
/**
* @notice Attempt to cancel an authorization
* @dev Works only if the authorization is not yet used.
* EOA wallet signatures should be packed in the order of r, s, v.
* @param authorizer Authorizer's address
* @param nonce Nonce of the authorization
* @param signature Signature bytes signed by an EOA wallet or a contract wallet
*/
function cancelAuthorization(
address authorizer,
bytes32 nonce,
bytes memory signature
) external whenNotPaused {
_cancelAuthorization(authorizer, nonce, signature);
}
/**
* @dev Helper method that sets the blacklist state of an account on balanceAndBlacklistStates.
* If _shouldBlacklist is true, we apply a (1 << 255) bitmask with an OR operation on the
* account's balanceAndBlacklistState. This flips the high bit for the account to 1,
* indicating that the account is blacklisted.
*
* If _shouldBlacklist if false, we reset the account's balanceAndBlacklistStates to their
* balances. This clears the high bit for the account, indicating that the account is unblacklisted.
* @param _account The address of the account.
* @param _shouldBlacklist True if the account should be blacklisted, false if the account should be unblacklisted.
*/
function _setBlacklistState(address _account, bool _shouldBlacklist)
internal
override
{
balanceAndBlacklistStates[_account] = _shouldBlacklist
? balanceAndBlacklistStates[_account] | (1 << 255)
: _balanceOf(_account);
}
/**
* @dev Helper method that sets the balance of an account on balanceAndBlacklistStates.
* Since balances are stored in the last 255 bits of the balanceAndBlacklistStates value,
* we need to ensure that the updated balance does not exceed (2^255 - 1).
* Since blacklisted accounts' balances cannot be updated, the method will also
* revert if the account is blacklisted
* @param _account The address of the account.
* @param _balance The new fiat token balance of the account (max: (2^255 - 1)).
*/
function _setBalance(address _account, uint256 _balance) internal override {
require(
_balance <= ((1 << 255) - 1),
"FiatTokenV2_2: Balance exceeds (2^255 - 1)"
);
require(
!_isBlacklisted(_account),
"FiatTokenV2_2: Account is blacklisted"
);
balanceAndBlacklistStates[_account] = _balance;
}
/**
* @inheritdoc Blacklistable
*/
function _isBlacklisted(address _account)
internal
override
view
returns (bool)
{
return balanceAndBlacklistStates[_account] >> 255 == 1;
}
/**
* @dev Helper method to obtain the balance of an account. Since balances
* are stored in the last 255 bits of the balanceAndBlacklistStates value,
* we apply a ((1 << 255) - 1) bit bitmask with an AND operation on the
* balanceAndBlacklistState to obtain the balance.
* @param _account The address of the account.
* @return The fiat token balance of the account.
*/
function _balanceOf(address _account)
internal
override
view
returns (uint256)
{
return balanceAndBlacklistStates[_account] & ((1 << 255) - 1);
}
/**
* @inheritdoc FiatTokenV1
*/
function approve(address spender, uint256 value)
external
override
whenNotPaused
returns (bool)
{
_approve(msg.sender, spender, value);
return true;
}
/**
* @inheritdoc FiatTokenV2
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external override whenNotPaused {
_permit(owner, spender, value, deadline, v, r, s);
}
/**
* @inheritdoc FiatTokenV2
*/
function increaseAllowance(address spender, uint256 increment)
external
override
whenNotPaused
returns (bool)
{
_increaseAllowance(msg.sender, spender, increment);
return true;
}
/**
* @inheritdoc FiatTokenV2
*/
function decreaseAllowance(address spender, uint256 decrement)
external
override
whenNotPaused
returns (bool)
{
_decreaseAllowance(msg.sender, spender, decrement);
return true;
}
}
DeFi
Web3 交易所核心:订单撮合机制深度解析
摘要
订单撮合将买卖意图按价格、数量和执行条件配对。链上交易系统需要分别确定订单管理、撮合与资产结算的位置,其设计影响成本、可用性、审查能力和交易排序风险。
1. 基础:什么是订单簿 (Order Book)?
订单簿维护某市场尚未成交的买单(Bids)和卖单(Asks),记录价格、数量及相关执行条件。
- 买单 (Bids):代表买家愿意以特定价格购买的数量。
- 卖单 (Asks):代表卖家愿意以特定价格出售的数量。
- 价差 (Spread):最高买单价和最低卖单价之间的差距。
当一个新的买单价格高于或等于最低卖单价,或者一个新的卖单价格低于或等于最高买单价时,撮合引擎 (Matching Engine) 就会执行交易。这个过程就是订单撮合。
2. 核心挑战:在区块链上实现订单簿
将传统的订单簿模型直接搬到 Layer-1 区块链(如以太坊主网)上会遇到几个严重的问题,这源于区块链的“不可能三角”——去中心化、安全、可扩展性:
- 成本高昂:在以太坊上,每一个操作(下单、取消订单、修改订单)都需要作为一笔交易上链,并支付 Gas 费。在高频交易场景下,这笔费用会变得无法承受。
- 速度缓慢:交易需要等待下一个区块才能被确认,延迟通常在十几秒到几分钟不等,这对于需要快速响应市场变化的交易者是致命的。
- 抢跑交易 (Front-running):由于交易在被打包进区块前会进入公开的内存池 (Mempool),恶意的矿工或机器人可以观察到即将到来的大额订单,并抢先执行自己的交易来获利,损害了原始用户的利益。
为了克服这些挑战,Web3 社区探索出了几种不同的订单撮合架构。
3. 主流订单撮合机制的设计与对比
模式一:完全链上订单簿 (Fully On-Chain Order Book)
这是最符合去中心化理想的模型,所有订单的生命周期——提交、存储、撮合、取消——都在链上通过智能合约完成。
-
工作流程:
- 用户 A 调用智能合约的
placeOrder函数,提交一个买单。这笔交易被打包上链,订单被存储在合约中。 - 用户 B 调用
placeOrder函数,提交一个价格匹配的卖单。 - 智能合约的撮合逻辑被触发,识别到这两个订单可以匹配。
- 合约自动执行资产交换,将资产分别发送给 A 和 B。
- 用户 A 调用智能合约的
-
Mermaid 流程图:
sequenceDiagram
participant UA as "用户 A"
participant UB as "用户 B"
participant OBC as "订单簿合约"
participant BC as "区块链"
UA->>OBC: 下单(买入 ETH)
OBC->>BC: 存储买单
BC-->>UA: 订单已提交(确认)
UB->>OBC: 下单(卖出 ETH)
OBC->>OBC: 匹配成功(A 买单 vs B 卖单)
OBC->>BC: 执行交换并结算
BC-->>UB: 订单已匹配并结算
BC-->>UA: 订单已匹配并结算
- 优点:
- 完全去中心化和抗审查:没有单点故障或控制方。
- 透明可信:所有操作都在链上公开可验证。
- 缺点:
- 极度昂贵和缓慢:如前所述,每个操作都需要 Gas 和区块确认时间。
- 抢跑风险高:所有意图都暴露在内存池中。
- 适用场景:
- 几乎不适用于高频交易。仅在 Layer-2 或高性能专用链上才变得有可行性。
模式二:链下撮合,链上结算 (Off-Chain Matching, On-Chain Settlement)
这是目前最主流和实用的订单簿 DEX 模型,也被称为混合模型 (Hybrid Model)。它将计算密集型且需要高速响应的订单管理和撮合过程移至链下,仅将最终的交易结果上链进行结算。
-
工作流程:
- 用户 A 在前端界面上创建一个买单,然后用自己的私钥对这个订单数据进行签名,这不消耗 Gas。
- 这个经过签名的订单被发送到一个中心化的中继方 (Relayer) 的服务器上。
- 用户 B 同样签名一个卖单,并发送给同一个 Relayer。
- Relayer 的撮合引擎在自己的链下数据库中匹配这两个订单。
- 一旦匹配成功,Relayer 会将 A 和 B 的两个签名订单捆绑在一起,打包成一笔交易,并调用链上的结算合约 (Settlement Contract)。
- 结算合约在链上验证 A 和 B 的签名是否有效,并检查他们是否有足够的资产。验证通过后,合约执行原子化的资产交换。
-
Mermaid 流程图:
sequenceDiagram
participant UA as "用户 A"
participant UB as "用户 B"
participant R as "中继者(链下)"
participant SC as "结算合约(链上)"
UA->>UA: 创建并签署买单
UA->>R: 提交已签名订单
UB->>UB: 创建并签署卖单
UB->>R: 提交已签名订单
R->>R: 在链下数据库中撮合订单
R->>SC: 提交匹配订单及签名
SC->>SC: 1. 验证签名 A
SC->>SC: 2. 验证签名 B
SC->>SC: 3. 执行原子交换
SC-->>R: 结算已确认
- 优点:
- 用户体验好:下单和取消订单免费且即时。
- 成本低:只有在交易成功撮合后才需要支付一次性的链上结算 Gas 费(通常由 Taker 或 Relayer 支付)。
- 高性能:撮合速度接近中心化交易所。
- 抗抢跑:由于撮合过程在链下,抢跑者无法在内存池中提前发现订单。
- 缺点:
- 中心化风险:Relayer 成为一个中心化组件。它可能会审查订单、选择性撮合,甚至宕机导致服务中断。(可以通过去中心化的 Relayer 网络缓解,如 0x Protocol)。
- 非托管性:尽管 Relayer 无法偷走用户的资金(因为没有私钥),但它在撮合环节引入了中心化信任。
- 代表项目:
- 0x Protocol, dYdX (早期版本), Loopring。
模式三:自动做市商 (Automated Market Maker, AMM)
虽然 AMM 不使用传统的订单簿和撮合引擎,但它作为一种去中心化的交易机制,是理解 DEX 生态的关键。它通过算法和流动性池来自动定价和执行交易。
- 工作流程:
- 流动性提供者 (LP) 将两种代币存入一个资金池。
- 当用户想要交易时,他们与资金池进行交互,而不是与另一个交易者。
- 价格由池中两种代币的比例决定,通常遵循一个确定性算法,如 Uniswap 的
x * y = k。
- 优点:
- 始终有流动性:只要池中有资金,交易就可以随时进行,无需等待对手方。
- 完全去中心化和无需许可。
- 缺点:
- 滑点 (Slippage):大额交易会显著影响价格。
- 无常损失 (Impermanent Loss):对流动性提供者有风险。
- 无法挂限价单:只能以当前市场价(或滑点范围内)进行交易。
- 代表项目:
- Uniswap, Curve, Balancer。
4. 总结与展望
| 特性 | 完全链上订单簿 | 链下撮合,链上结算 | 自动做市商 (AMM) |
|---|---|---|---|
| 去中心化程度 | 最高 | 中等(依赖 Relayer) | 高 |
| 交易速度 | 慢 (区块时间) | 快 (链下撮合) | 中等 (区块时间) |
| 交易成本 | 非常高 | 低(仅结算) | 中等(单次 Swap) |
| 用户体验 | 差 | 好 | 好 |
| 支持订单类型 | 市价单、限价单 | 市价单、限价单 | 仅市价单 |
| 抗抢跑性 | 差 | 好 | 中等(有 MEV 风险) |
结论:
在当前的区块链技术(特别是 Layer-1)限制下,“链下撮合,链上结算”的混合模型 是实现高性能、低成本订单簿 DEX 的最佳方案,它在用户体验和去中心化之间取得了巧妙的平衡。
与此同时,AMM 以其简洁和完全去中心化的特性,开辟了另一条成功的道路,并成为目前 DeFi 领域交易量最大的模式。
展望未来,随着 Layer-2 扩容方案(如 ZK-Rollups 和 Optimistic Rollups)的成熟,交易成本将大幅降低,执行速度显著提升。这可能会让完全链上订单簿模型重新焕发生机,在高性能的 L2 环境中实现接近中心化交易所的体验,同时保留区块链的去中心化和安全优势。
5. 深入:链下撮合、链上结算的安全与验证
链下撮合、链上结算需要校验签名域、价格数量、余额与授权,并定义部分成交、取消和交易排序约束。
5.1 参与方与职责
- Maker(发单方):签名授权愿意成交的代币、价格/数量、有效期、是否允许部分成交等约束。
- Taker(吃单/执行):选择订单并发起链上结算,或委托撮合引擎代为执行。
- Relayer/撮合引擎:聚合订单与撮合配对,可代付 Gas 并收取撮合费(可选)。
- 结算合约:验证签名与约束、检查余额与授权、执行代币交换、记录成交与取消状态。
5.2 订单结构与签名(EIP‑712)
- 常见字段:
maker、taker(可选)、sellToken、buyToken、sellAmount、minBuyAmount/price、deadline、nonce/salt、receiver、feeRecipient、feeBps/feeAmount、chainId、verifyingContract、partialFill等。 - 签名:使用 EIP‑712 Typed Data。Domain 至少包含
name、version、chainId、verifyingContract,避免跨链/跨合约重放。 - 支持合约署名:如机构账户用 ERC‑1271 校验。
5.3 结算合约验证流程(关键步骤)
- 域隔离:校验
chainId与verifyingContract是否匹配。 - 签名恢复:
ecrecover/OpenZeppelin ECDSA 或 ERC‑1271 验证签名者为maker。 - 期限/重放保护:检查
deadline、used[orderHash]与filled[orderHash](允许部分成交时按累计量校验)。 - 价格与数量约束:校验
sell/buy方向相反,成交量满足双方最小接收/价格区间;计算填单量的取整方向需固定,避免被四舍五入套利。 - 授权与余额:
- 非托管:检查
balanceOf(maker)与allowance(maker, settlement)是否足够。 - 可选即刻授权:支持 EIP‑2612/Permit2,先
permit再结算,减少“授权被用户修改导致失败”。
- 非托管:检查
- 代币转账:使用
SafeERC20做transferFrom,考虑非标准返回值与税/通缩代币;费用按约定结算给feeRecipient。 - 状态更新与事件:更新
filled/used,emit成交事件,便于索引与审计。
5.4 防止用户在执行前转走资产
- 无法从协议层“禁止”用户转资;常见策略是:
- 即刻授权(Permit/Permit2)减少失败概率;
- 上链前仿真(eth_call/Tenderly)检查余额与可行性;
- 指定
taker+ 短deadline,缩短状态变化窗口; - 托管/预存模式(在合约/二层账户中持有余额),以可用余额为准保障执行确定性。
5.5 取消与部分成交
- 取消:提供
cancel(orderHash)或cancelUpTo(nonceFloor),也可签名“取消指令”由第三方代上链。 - 部分成交:
filled[orderHash]记录累计成交量,完全成交或取消后标记used = true,禁止再次使用。
5.6 防前置/抢跑(MEV)
- 绑定
taker/执行者限定可执行主体; - 使用私有内存池/保护中继(如 Flashbots);
- 短有效期与撮合域隔离;必要时加密订单、仅对特定执行者可见。
5.7 费用与结算
- 在订单中明确
feeRecipient与feeBps/feeAmount; - 费用币种可为买/卖任一侧或独立
feeToken; - 对税/通缩代币,按“实际到帐量”设计约束,或直接在白名单外禁用。
5.8 时序图(含签名校验、授权检查、部分成交与取消)
sequenceDiagram
participant MK as "Maker(发单)"
participant TK as "Taker(执行)"
participant R as "撮合引擎(可选)"
participant SC as "结算合约"
MK->>R: 提交签名订单(EIP‑712)
R-->>TK: 返回可撮合订单
TK->>SC: settle(order, sig, fill, params)
SC->>SC: 1) 域隔离校验(chainId、verifyingContract)
SC->>SC: 2) 恢复签名人 / ERC‑1271 校验
alt 已取消/过期/重复使用
SC-->>TK: revert("invalid order")
end
SC->>SC: 3) 价格/最小接收等约束校验
SC->>SC: 4) 计算成交量(支持部分成交)
opt 附带 Permit/Permit2
TK->>SC: 提交 permit 数据
SC->>SC: 调用 permit 设置授权
end
SC->>SC: 5) 检查 maker 余额与授权
alt 余额/授权不足
SC-->>TK: revert("insufficient balance/allowance")
end
SC->>SC: 6) 代币交换(SafeERC20.transferFrom)
SC->>SC: 7) 更新 filled/used 并记录费用
SC-->>TK: emit Fill(orderHash, maker, taker, amounts)
Uniswap
概述
Uniswap 是以太坊上最成功的去中心化交易所(DEX)之一,它通过创新的自动做市商(AMM)机制彻底改变了加密货币交易的方式。Uniswap 由 Hayden Adams 于 2018 年创建,最初是一个简单的概念验证项目,基于以太坊创始人 Vitalik Buterin 提出的自动做市商概念。
版本演进
Uniswap 从 v1 到 v4 经历了多次重大升级,每个版本都带来了重要的创新和改进:
Uniswap v1 (2018 年 11 月)
Uniswap v1 是第一个实现自动做市商(AMM)机制的 DEX,它引入了以下关键特性:
- 恒定乘积公式:使用\(x \cdot y = k\)公式,其中\(x\)和\(y\)是交易对中两种资产的储备量,\(k\)是一个常数。
- ETH 作为基础资产:每个交易对都必须包含 ETH 作为基础资产。
- 简单的流动性提供机制:流动性提供者(LP)需要提供等值的两种资产。
- 0.3%的交易费用:所有交易费用都分配给流动性提供者。
v1 的主要局限性是只能与 ETH 创建交易对,这限制了其灵活性和流动性。
Uniswap v2 (2020 年 5 月)
Uniswap v2 解决了 v1 的许多局限性,引入了以下创新:
- 任意 ERC20 代币对:支持创建任意 ERC20 代币之间的交易对,不再需要 ETH 作为基础资产。
- 价格预言机:引入了强化的价格预言机,通过在每个区块开始时累积价格数据,使其他合约能够估算任意时间间隔内的时间加权平均价格(TWAP)。
- 闪电兑换:允许用户在同一笔交易中先接收资产,使用它们,然后再支付或归还,为套利和复杂交易策略提供便利。
- 协议费用:引入了可选的 0.05%协议费用,可以发送到指定的地址。
v2 的智能合约架构包括:
- 工厂合约:负责创建和管理交易对合约。
- 交易对合约:存储流动性提供者的资金,执行交易逻辑。
- 路由器合约:提供用户友好的接口,计算交易或存款金额,并将资金转移到交易对合约。
Uniswap v3 (2021 年 5 月)
Uniswap v3 带来了革命性的改进,大幅提高了资本效率:
- 集中流动性:允许流动性提供者将资金集中在特定价格范围内,而不是均匀分布在整个价格曲线上。
- 多费率等级:引入了多个费率等级(0.05%、0.30%和 1%),允许 LP 根据资产对的波动性选择适当的费率。
- 改进的价格预言机:进一步改进了价格预言机,使其更加精确和高效。
- 非 ERC20 代币支持:增加了对非 ERC20 代币的支持,如 ETH 和 WETH。
v3 的核心创新是集中流动性,它通过以下方式实现:
- LP 可以选择提供流动性的价格范围。
- 流动性只在选定的价格范围内有效。
- 当价格超出范围时,流动性会转换为单一资产,直到价格回到范围内。
这种机制大大提高了资本效率,使 LP 能够获得更高的费用收入,但也增加了无常损失的风险。
Uniswap v4 (2023 年 6 月)
Uniswap v4 是最新版本,引入了多项突破性创新:
- 钩子(Hooks):允许开发者在流动性池的生命周期中的特定点执行自定义代码,为 AMM 提供了前所未有的可扩展性。
- 单例池(Singleton Pool):所有池都存储在一个合约中,而不是每个交易对一个合约,这大大降低了部署和交互成本。
- 动态费用:支持基于市场条件的动态费用调整。
- 改进的闪电贷:提供了更灵活和高效的闪电贷机制。
- 自定义 AMM 曲线:允许创建自定义的 AMM 曲线,而不仅限于恒定乘积公式。
v4 的钩子系统是其最显著的创新,它允许在以下关键时刻执行自定义逻辑:
- 初始化池
- 添加/移除流动性
- 交易前/后
- 闪电贷前/后
这种灵活性使开发者能够创建各种创新的 AMM 变体,如:
- 基于时间的费用调整
- 基于交易量的费用调整
- 自动再平衡池
- 与外部预言机集成的池
技术实现
自动做市商(AMM)模型
Uniswap 的核心是自动做市商模型,它通过数学公式自动确定交易价格,而不是依赖订单簿匹配。不同版本使用了不同的公式:
- v1 和 v2:使用恒定乘积公式 \(x \cdot y = k\)
- v3:使用改进的恒定乘积公式,支持集中流动性
- v4:支持自定义 AMM 曲线,包括恒定乘积、恒定和、恒定平均值等
流动性提供机制
不同版本的流动性提供机制有所不同:
- v1 和 v2:LP 提供等值的两种资产,获得代表池中份额的 LP 代币
- v3:LP 提供不等值的资产,并指定价格范围,获得代表特定价格范围内份额的 NFT
- v4:支持更灵活的流动性提供机制,可以通过钩子实现各种创新
无常损失
无常损失是指当 LP 提供流动性后,由于资产价格变化导致的潜在损失。不同版本的无常损失风险不同:
- v1 和 v2:LP 在整个价格范围内面临无常损失风险
- v3:LP 可以通过集中流动性减少无常损失风险,但也可能面临更高的风险
- v4:通过钩子系统,可以创建减少无常损失的创新机制
经济模型
交易费用
不同版本的交易费用模型有所不同:
- v1:固定 0.3%的交易费用,全部归 LP 所有
- v2:固定 0.3%的交易费用,其中 0.25%归 LP 所有,0.05%可能归协议所有
- v3:多个费率等级(0.05%、0.30%和 1%),LP 可以根据资产对的波动性选择适当的费率
- v4:支持动态费用,可以通过钩子实现基于市场条件的费用调整
治理
Uniswap 的治理由 UNI 代币持有者控制,他们可以投票决定协议的未来发展方向,包括:
- 协议费用开关
- 费用接收地址
- 协议升级
- 资金分配
生态系统
Uniswap 已经发展成为一个庞大的生态系统,包括:
- Uniswap 界面:用户友好的 Web 界面,用于与 Uniswap 协议交互
- Uniswap SDK:开发者工具包,用于构建与 Uniswap 交互的应用程序
- Uniswap 治理:UNI 代币持有者可以参与协议治理
- Uniswap Labs:开发 Uniswap 协议的公司
- Uniswap 基金会:支持 Uniswap 协议发展的非营利组织
参考资料
- Uniswap v2 白皮书
- Uniswap v3 白皮书
- Uniswap v4 白皮书
- Uniswap 官方文档
- Uniswap GitHub 仓库
Uniswap V1
概述
Uniswap V1 是第一个完全去中心化的自动做市商(AMM)协议,由 Hayden Adams 于 2018 年 11 月推出。它通过智能合约实现了代币的自动交易,无需传统的订单簿。
核心机制
AMM(Automated Market Maker)
Uniswap V1 使用恒定乘积做市商(Constant Product Market Maker)模型,其核心公式为:
x * y = k
其中:
- x 是代币 X 的储备量
- y 是代币 Y 的储备量
- k 是常数
价格计算
代币 X 相对于代币 Y 的价格计算公式为:
price = y / x
滑点
滑点(Slippage)是指实际成交价格与预期价格之间的差异。在 Uniswap V1 中,滑点主要由以下因素决定:
-
交易规模:
- 交易规模越大,滑点越大
- 这是因为 x * y = k 的曲线特性
-
流动性深度:
- 流动性池越大,滑点越小
- 流动性提供者(LP)的数量和质量影响滑点
交易费用
-
费率:
- 每笔交易收取 0.3% 的手续费
- 手续费直接添加到流动性池中
-
费用分配:
- 手续费按比例分配给所有流动性提供者
- 分配基于 LP 在池中的份额
智能合约架构
核心合约
-
Exchange.sol:
- 处理代币交换
- 管理流动性池
- 实现价格计算
-
Factory.sol:
- 创建新的交易对
- 管理所有交易对
主要函数
// 添加流动性
function addLiquidity(uint256 min_liquidity, uint256 max_tokens, uint256 deadline) external returns (uint256);
// 移除流动性
function removeLiquidity(uint256 amount, uint256 min_eth, uint256 min_tokens, uint256 deadline) external returns (uint256, uint256);
// 代币交换
function tokenToTokenSwap(uint256 tokens_sold, uint256 min_tokens_bought, uint256 min_eth_bought, uint256 deadline, address token_addr) external returns (uint256);
// 获取价格
function getTokenToEthInputPrice(uint256 tokens_sold) external view returns (uint256);
特点
-
完全去中心化:
- 无需中心化交易所
- 无需订单簿
- 无需做市商
-
自动定价:
- 价格由算法自动确定
- 基于供需关系动态调整
-
流动性提供:
- 任何人都可以提供流动性
- 获得交易手续费分成
-
无需许可:
- 任何人都可以创建交易对
- 任何人都可以进行交易
局限性
-
高滑点:
- 大额交易滑点较高
- 不适合大额交易
-
价格偏差:
- 可能偏离市场价格
- 需要套利者维持价格稳定
-
无常损失:
- 流动性提供者面临价格波动风险
- 可能导致比单纯持有代币更低的收益
安全考虑
-
重入攻击防护:
- 使用检查-效果-交互模式
- 实现重入锁
-
精度控制:
- 使用足够大的精度
- 防止舍入误差
-
权限控制:
- 严格控制管理员权限
- 实现时间锁机制
Uniswap V2:储备、兑换与流动性份额
Uniswap V2 是采用恒定乘积模型的自动做市商(AMM)。交易者与资金池兑换资产,流动性提供者向池中存入两种代币,获得代表池中份额的 LP 代币。
与 V1 的 ETH/代币池不同,V2 可以直接建立两种 ERC-20 代币的交易对。涉及原生 ETH 时,路由器通常先将其包装为 WETH。原始 V2 的费用、储备及路由规则与后续版本或分叉协议可能不同。
合约如何分工
| 合约 | 职责 |
|---|---|
| Factory | 创建交易对,并记录两种代币对应的 Pair 地址 |
| Pair | 持有两种代币,记录储备,执行兑换、流动性铸造和赎回;自身也是 LP 代币合约 |
| Router | 计算输入输出,沿调用者指定的兑换路径组织转账和 Pair 调用,并检查用户设定的限制 |
Factory 与 Pair 属于核心合约,Router 属于外围合约。用户通常通过 Router 交互,但 Pair 也可以被直接调用;直接调用时,需要自己处理转账顺序、最小输出和其他保护条件。
Pair 中的 reserve0、reserve1 是记录的储备,代币的 balanceOf(pair) 是实际余额。两者在正常结算后相符,但直接转账、代币增减余额等情况可以造成差异。兑换和流动性操作会根据实际余额识别收到的资产,并更新储备。
恒定乘积决定兑换数量
设池中 A、B 的储备分别为 x、y。忽略手续费时,兑换前后满足:
\[x \cdot y = k\]
这里的“恒定”描述一次不含手续费、也不增减流动性的兑换约束,不是池子生命周期中永远固定的数值。收取手续费或增减流动性都会改变储备乘积。
当 x、y 以完整代币数量表示时,y / x 是池内 A 的瞬时边际价格,单位为 B/A。若直接用合约中的原始整数储备计算,还需要调整两种代币的 decimals。这个价格也不等于一笔较大兑换的平均成交价格。
V2 对输入收取 0.3% 手续费。输入 Δx 个 A 时,用于报价的有效输入为 0.997 × Δx,输出 B 的数量为:
\[\Delta y = \frac{y \cdot 0.997\Delta x}{x + 0.997\Delta x}\]
例如,池中有 1000 A 和 1000 B,输入 100 A,输出约为 90.6611 B。成交后池中约有 1100 A 和 909.3389 B:全部输入都进入池子,手续费留在其中,因此实际储备乘积会增长。
Pair 最终检查的是扣除输入手续费后的余额乘积不低于原储备乘积。合约使用整数计算,输出需要向下取整,所以数学公式与实际结果可能有最小单位的差异。
输入越大,兑换过程中储备比例变化越大,平均成交价越偏离兑换前的边际价格,这称为价格影响。报价到成交之间还可能发生价格变化,用户通常用最小输出或最大输入限制可接受的滑点。
LP 代币代表池中份额
提供流动性时,需要区分首次建池和后续追加。以下公式中的数量都使用合约的原始整数单位,忽略取整之外的特殊代币行为。
首次存入 a 个 A 和 b 个 B 时,初始 LP 总量约为 sqrt(a × b)。原始 V2 将其中 MINIMUM_LIQUIDITY = 1000 个最小单位永久锁定在零地址,首次提供者实际获得:
\[L_{first} = \lfloor\sqrt{a b}\rfloor - 1000\]
这不是锁定 1000 个完整 LP 代币。首次存款必须足够大,使扣除锁定数量后的铸造量为正;首次储备比例也建立了池内初始价格,若明显偏离市场,会产生套利机会。
后续追加时,设现有储备为 x、y,铸造前 LP 总量为 S,实际存入 a、b,则新铸造数量为:
\[L_{added} = \min\left(\left\lfloor\frac{aS}{x}\right\rfloor,\left\lfloor\frac{bS}{y}\right\rfloor\right)\]
两种资产中较少的相对投入决定新增份额。Router 通常按池中比例选择转入金额;若直接向 Pair 不成比例地转入再铸造,多出的部分不会换来额外 LP 份额。
赎回时,持有者将 LP 代币交给 Pair 销毁,按其占总量的比例领取两种资产。赎回得到的是当时池中的资产组合,不保证与最初存入的两种数量相同。
手续费怎样进入 LP 收益
交易手续费留在池中,使每份 LP 对应的资产价值受益于交易活动。LP 通常在赎回份额时实现这部分收益,而不是每次兑换都收到一笔单独分红。
原始 V2 还支持可选的协议费用。启用时,协议通过在流动性事件中向 feeTo 铸造 LP 份额,分享约六分之一的手续费收益,相当于成交额的约 0.05%。这从原有 0.3% 中分出,不是再额外收取 0.05%。
是否开启协议费用,要检查对应 Factory 的 feeTo 配置。计算追加和赎回份额时,也要考虑协议费用铸造可能先改变 LP 总量,不能把所有部署都视为同一种费用状态。
价格累计值与 TWAP
V2 提供两种方向的价格累计值,供外部合约计算时间加权平均价格(TWAP)。每段时间的贡献是该段价格乘以持续时间,而不是简单地把价格相加:
\[C(t_2)-C(t_1)=\sum_i p_i\Delta t_i\]
\[TWAP=\frac{C(t_2)-C(t_1)}{t_2-t_1}\]
累计值在储备更新时按已过去的时间更新,使用更新前的储备比例。它不是每到一个新区块就自动执行一次;若当前时间还没有储备更新,读取方可以根据已有储备和时间补算当前累计值。
应用需要记录两个时间点的观测,选择窗口并处理精度,Pair 不会直接返回任意窗口的 TWAP。两种方向应分别计算:A/B 的平均价格通常不等于 B/A 平均价格的倒数。
TWAP 让操纵通常需要影响一段时间内的价格,较难依靠单个瞬时价格误导应用。但它没有消除操纵风险,窗口、流动性、观测方式和市场条件都会影响安全性,不能直接把池内价格视为可信的外部报价。
闪电兑换如何结算
闪电兑换允许接收方先拿到输出资产,在同一笔交易中使用,再支付输入资产。它与普通兑换使用同一个 swap(amount0Out, amount1Out, to, data) 入口;参数指定输出数量,输入由最终余额推算。
当 data 非空时,Pair 在转出资产后调用接收合约的 uniswapV2Call()。接收合约可以进行套利或其他操作,然后在回调结束前将应付代币转入 Pair。Pair 最后检查余额和手续费约束,不满足则整笔交易回滚。
归还同一种代币时,若取出 q,所需总归还量至少约为 q / 0.997,还需向上取整;支付另一种代币时,按池中储备和手续费计算输入。不存在“只归还原数量就完成借款”的通用规则。
不需要再次调用同一个 Pair 的 swap() 来还款,这还会受到 Pair 重入锁的阻止。回调合约应验证调用者确实是预期 Factory 创建的 Pair,避免被伪造回调诱导转账。
无常损失是相对持有的差额
当外部相对价格变化时,套利会推动池内储备比例调整。LP 赎回时的资产组合与单纯持有最初两种资产不同,因此需要比较这两种策略在同一价格下的价值。
设 A 相对 B 的价格从 p₀ 变为 p₁,r = p₁ / p₀。对初始等值投入的恒定乘积池,忽略手续费,假设套利使池内价格跟随外部价格,LP 相对持有的收益差额为:
\[\frac{V_{LP}}{V_{hold}}-1=\frac{2\sqrt{r}}{1+r}-1\]
当价格变为原来的 4 倍时,结果为 -20%,表示 LP 价值比持有策略低 20%。它不表示 LP 的资产绝对价值一定减少了 20%;若把“损失幅度”写成正数,对应的是 1 - 2sqrt(r)/(1+r)。
手续费收入可以抵消部分差额,但不能保证覆盖。价格回到初始比例时,上述理想模型中的差额会消失;如果已经退出或价格没有回来,不能凭“无常”这个名称认为损失一定会恢复。
使用时的边界
Pair 的重入锁保护其关键入口,不是所有外围合约和代币的整体安全保证。对 transfer() 不返回布尔值的兼容,也不意味着任意带转账税、重基准或恶意逻辑的代币都能正常兑换。
sync() 将记录的储备更新为实际余额,skim(to) 转出超过记录储备的余额。这是公开的余额协调函数,不是管理员专用的救援入口;直接向 Pair 转入资产,并不会自动建立属于发送者的可赎回存款。
V2 可以用于兑换、提供流动性、套利和价格观测,但这些用途各有条件。理解储备与余额、成交价与边际价、LP 份额与原始存款之间的区别,比把“恒定乘积”当作一个固定不变的等式更重要。
参考资料
- Uniswap V2:资金池与流动性份额
- Uniswap V2:兑换、价格影响与滑点
- Uniswap V2 Pair 源码:兑换与流动性规则
- Uniswap V2:流动性收益与相对持有差额
- Uniswap V2:价格预言机
- Uniswap V2:闪电兑换与还款计算
Uniswap V2 白皮书
引用块标记补充注释,与英文原文和中文译文分开。
whitepaper 原 pdf 经过 minerU 转换
Uniswap v2 Core
| Hayden Adams | Noah Zinsmeister | Dan Robinson |
| hayden@uniswap.org | noah@uniswap.org | dan@paradigm.xyz |
Abstract
This technical whitepaper explains some of the design decisions behind the Uniswap v2 core contracts. It covers the contracts’ new features—including arbitrary pairs between ERC20s, a hardened price oracle that allows other contracts to estimate the time-weighted average price over a given interval, “flash swaps” that allow traders to receive assets and use them elsewhere before paying for them later in the transaction, and a protocol fee that can be turned on in the future. It also re-architects the contracts to reduce their attack surface. This whitepaper describes the mechanics of Uniswap v2’s “core” contracts including the pair contract that stores liquidity providers’ funds—and the factory contract used to instantiate pair contracts.
摘要
本技术白皮书解释了 Uniswap v2 核心合约背后的一些设计决策。它涵盖了合约的新特性——包括任意 ERC20 代币对、一个强化的价格预言机(允许其他合约估计给定时间间隔内的时间加权平均价格)、“闪电兑换”(允许交易者在同一笔交易中先接收资产并在其他地方使用,然后再支付)、以及未来可以开启的协议费用。它还对合约进行了重新架构,以减少合约受攻击面。本白皮书描述了 Uniswap v2“核心“合约的机制,包括存储流动性提供者资金的交易对合约,以及用于实例化交易对合约的工厂合约。
1 Introduction (介绍)
Uniswap v1 is an on-chain system of smart contracts on the Ethereum blockchain, implementing an automated liquidity protocol based on a “constant product formula” 1. Each Uniswap v1 pair stores pooled reserves of two assets, and provides liquidity for those two assets, maintaining the invariant that the product of the reserves cannot decrease. Traders pay a 30-basis-point fee on trades, which goes to liquidity providers. The contracts are non-upgradeable.
Uniswap v1 是以太坊区块链上的一套链上智能合约系统,它基于“恒定乘积公式“1 实现了一种自动化的流动性协议。每个 Uniswap v1 交易对都存储着两种资产的汇集储备,并为这两种资产提供流动性,维持储备金乘积不能减少的不变性。交易者在交易时支付 30 个基点的费用,这笔费用会分配给流动性提供者。这些合约是不可升级的。
基点:金融学术语,万分之一为 1 基点。30 基点即千分之三
Uniswap v2 is a new implementation based on the same formula, with several new highlydesirable features. Most significantly, it enables the creation of arbitrary ERC20/ERC20 pairs, rather than supporting only pairs between ERC20 and ETH. It also provides a hardened price oracle that accumulates the relative price of the two assets at the beginning of each block. This allows other contracts on Ethereum to estimate the time-weighted average price for the two assets over arbitrary intervals. Finally, it enables “flash swaps” where users can receive assets freely and use them elsewhere on the chain, only paying for (or returning) those assets at the end of the transaction.
Uniswap v2 是基于相同公式的新实现,具有几个非常理想的新特性。最重要的是,它能够创建任意 ERC20/ERC20 交易对,而不仅仅是支持 ERC20 和 ETH 之间的交易对。它还提供了一个强化的价格预言机,该预言机在每个区块的开始累积两种资产的相对价格。这使得以太坊上的其他合约能够估算任意时间间隔内这两种资产的时间加权平均价格。最后,它启用了“闪电兑换”,用户可以免费接收资产并在链上的其他地方使用它们,只需在交易结束时支付(或归还)这些资产。
While the contract is not generally upgradeable, there is a private key that has the ability to update a variable on the factory contract to turn on an on-chain 5-basis-point fee on trades. This fee will initially be turned off, but could be turned on in the future, after which liquidity providers would earn 25 basis points on every trade, rather than 30 basis points.
虽然该合约通常是不可升级的,但存在一个私钥,该私钥能够更新工厂合约上的一个变量以开启收取链上 5 个基点的交易费用。此费用最初将处于关闭状态,但未来可能会开启,之后流动性提供者将在每笔交易中赚取 25 个基点,而不是 30 个基点。
As discussed in section 3, Uniswap v2 also fixes some minor issues with Uniswap v1, as well as rearchitecting the implementation, reducing Uniswap’s attack surface and making the system more easily upgradeable by minimizing the logic in the “core” contract that holds liquidity providers’ funds.
正如第 3 节所讨论的,Uniswap v2 还修复了 Uniswap v1 的一些小问题,并重新设计了架构实现,从而减少了 Uniswap 的受攻击面,并通过最小化存储流动性提供者资金的“核心”合约中的逻辑,使系统更容易升级。
This paper describes the mechanics of that core contract, as well as the factory contract used to instantiate those contracts. Actually using Uniswap v2 will require calling the pair contract through a “router” contract that computes the trade or deposit amount and transfers funds to the pair contract.
本文描述了该核心合约以及用于实例化这些合约的工厂合约的机制。实际上使用 Uniswap v2 需要通过一个“路由器”合约来调用交易对合约,该路由器合约计算交易或存款金额并将资金转移到交易对合约。
2 New features (新特性)
2.1 ERC-20 pairs (ERC-20 币对)
Uniswap v1 used ETH as a bridge currency. Every pair included ETH as one of its assets. This makes routing simpler—every trade between ABC and XYZ goes through the ETH/ABC pair and the ETH/XYZ pair—and reduces fragmentation of liquidity.
Uniswap v1 使用 ETH 作为桥接货币。每个交易对都包含 ETH 作为其资产之一。这使得路由更简单——ABC 和 XYZ 之间的每笔交易都通过 ETH/ABC 交易对和 ETH/XYZ 交易对进行——并减少了流动性的分散。
However, this rule imposes significant costs on liquidity providers. All liquidity providers have exposure to ETH, and suffer impermanent loss based on changes in the prices of other assets relative to ETH. When two assets ABC and XYZ are correlated—for example, if they are both USD stablecoins—liquidity providers on a Uniswap pair ABC/XYZ would generally be subject to less impermanent loss than the ABC/ETH or XYZ/ETH pairs.
然而,这条规则给流动性提供者带来了显著的成本。所有流动性提供者都暴露于 ETH 的风险之下,并会因其他资产相对于 ETH 的价格变动而遭受无常损失。当两种资产 ABC 和 XYZ 相关联时——例如,如果它们都是美元稳定币——与 ABC/ETH 或 XYZ/ETH 交易对相比,Uniswap 上 ABC/XYZ 交易对的流动性提供者通常会遭受更少的无常损失。
Using ETH as a mandatory bridge currency also imposes costs on traders. Traders have to pay twice as much in fees as they would on a direct ABC/XYZ pair, and they suffer slippage twice.
使用 ETH 作为强制性的桥接货币也给交易者带来了成本。与直接的 ABC/XYZ 交易对相比,交易者需要支付双倍的费用,并且遭受双倍的滑点。
滑点:指在进行交易时,客户下达的指定交易价格与实际成交价格存在偏差的一种现象。相关链接
双倍费用:进行一次 ABC->ETH,收取一次交易费用,发生一次滑点。进行一次 ETH->XYZ,收取一次交易费用,发生一次滑点。
Uniswap v2 allows liquidity providers to create pair contracts for any two ERC-20s.
Uniswap v2 允许流动性提供者为任意两种 ERC-20 代币创建交易对合约。
A proliferation of pairs between arbitrary ERC-20s could make it somewhat more difficult to find the best path to trade a particular pair, but routing can be handled at a higher layer (either off-chain or through an on-chain router or aggregator).
任意 ERC-20 代币之间大量交易对的出现可能会使得寻找特定交易对的最佳交易路径变得更加困难,但路由可以在更高的层级处理(无论是链下还是通过链上路由器或聚合器)。
2.2 Price oracle (价格预言机)
The marginal price offered by Uniswap (not including fees) at time \(t\) can be computed by dividing the reserves of asset \(a\) by the reserves of asset \(b\) .
在时间 \(t\),Uniswap 提供的边际价格(不包括费用)可以通过将资产 \(a\) 的储备量除以资产 \(b\) 的储备量来计算。
$$ p_t = \frac{r_t^a}{r_t^b} $$
Since arbitrageurs will trade with Uniswap if this price is incorrect (by a sufficient amount to make up for the fee), the price offered by Uniswap tends to track the relative market price of the assets, as shown by Angeris et al 2. This means it can be used as an approximate price oracle.
由于套利者会在 Uniswap 提供的价格不正确(达到足以弥补费用的程度)时与 Uniswap 进行交易,因此 Uniswap 提供的价格往往会跟踪资产的相对市场价格,正如 Angeris et al 2 所展示的那样。这意味着它可以被用作一个近似的价格预言机。
当 uniswap 的价格与市场真实价格差距较大时,如 1 ETH swap 1 USDT,套利者利用价格差距买入 ETH,将 ETH 提出赚取市场差价。这同时改变了 uniswap 中的价格比值,如更新后变为 1 ETH swap 100 USDT。随着时间变化,此类交易次数增多,相对套利空间减少,此比率越来越接近市场价值。
However, Uniswap v1 is not safe to use as an on-chain price oracle, because it is very easy to manipulate. Suppose some other contract uses the current ETH-DAI price to settle a derivative. An attacker who wishes to manipulate the measured price can buy ETH from the ETH-DAI pair, trigger settlement on the derivative contract (causing it to settle based on the inflated price), and then sell ETH back to the pair to trade it back to the true price. 1 This might even be done as an atomic transaction, or by a miner who controls the ordering of transactions within a block.
然而,Uniswap v1 作为链上价格预言机并不安全,因为它非常容易被操纵。假设某个其他合约使用当前的 ETH-DAI 价格来结算衍生品。一个希望操纵衡量价格的攻击者可以从 ETH-DAI 交易对中购买 ETH,触发衍生品合约的结算(导致其基于膨胀的价格结算),然后将 ETH 卖回该交易对以将其价格恢复到真实水平。 1这甚至可以作为原子交易完成,或者由控制区块内交易顺序的矿工完成。
假设场景: 有一个名为 "衍生品合约" 的 DeFi 合约,它使用 Uniswap v1 的 ETH-DAI 交易对的价格作为结算依据。 当前 Uniswap v1 的 ETH-DAI 交易对中,1 ETH 的价格为 1000 DAI。 攻击者拥有大量 DAI 和 ETH。 攻击步骤: 价格操纵:攻击者使用大量 DAI 在 Uniswap v1 的 ETH-DAI 交易对中购买 ETH。 由于 Uniswap v1 的价格机制,这会导致交易对中的 ETH 数量减少,DAI 数量增加,从而推高 ETH 的价格。 假设攻击者通过大量购买,将 ETH 的价格暂时推高到 1200 DAI。 触发结算:此时,衍生品合约检测到 Uniswap v1 提供的 ETH 价格为 1200 DAI,并基于这个膨胀的价格进行结算。 假设这个衍生品合约是多单合约,那么价格越高,多单合约的获利也就越高。 恢复价格:结算完成后,攻击者立即将之前购买的 ETH 卖回 Uniswap v1 的 ETH-DAI 交易对。 这会导致交易对中的 ETH 数量增加,DAI 数量减少,从而将 ETH 的价格恢复到接近 1000 DAI 的真实水平。 获利:攻击者通过在价格被操纵的高点触发衍生品合约的结算,从而获得了超额利润。 并且因为,攻击者最后恢复了价格,所以攻击者所持有的ETH或者DAI的数量并没有明显的减少。 关键点: 整个攻击过程可以在一次原子交易中完成,使得攻击难以被追踪和阻止。 攻击者利用了 Uniswap v1 价格机制的弱点,即价格容易受到交易量影响。 这种攻击对依赖 Uniswap v1 价格的 DeFi 应用造成了严重威胁。
Uniswap v2 improves this oracle functionality by measuring and recording the price before the first trade of each block (or equivalently, after the last trade of the previous block). This price is more difficult to manipulate than prices during a block. If the attacker submits a transaction that attempts to manipulate the price at the end of a block, some other arbitrageur may be able to submit another transaction to trade back immediately afterward in the same block. A miner (or an attacker who uses enough gas to fill an entire block) could manipulate the price at the end of a block, but unless they mine the next block as well, they may not have a particular advantage in arbitraging the trade back.
Uniswap v2 通过在每个区块的第一笔交易之前(或等效地,在上一个区块的最后一笔交易之后)测量和记录价格来改进此预言机功能。与区块内的价格相比,此价格更难被操纵。如果攻击者提交一笔试图在区块末尾操纵价格的交易,一些其他的套利者可能会立即在同一区块内提交另一笔交易以恢复价格。矿工(或使用足够 Gas 来填满整个区块的攻击者)可以在区块末尾操纵价格,但除非他们也挖掘下一个区块,否则他们在套利交易以恢复价格方面可能没有特别的优势。
“区块最后一笔交易”指的是什么?
一个区块包含多笔交易(可能是转账、合约调用等),矿工/验证者会按一定顺序打包这些交易。最后一笔交易是指这个区块中最后被执行的那一笔(无论是否是 Uniswap 交易)。
例如:
交易 1: Alice 转账给 Bob
交易 2: Uniswap 交易(攻击者操纵价格)
交易 3: (无)→ 交易 2 就是最后一笔。
为什么可以操纵最后一笔交易?
关键在于矿工/验证者对交易顺序的控制权:
矿工决定交易顺序:矿工可以自由选择将哪笔交易放在区块的末尾(比如通过 Gas 费竞价或恶意排序)。
如果攻击者是矿工: 攻击者将自己的操纵交易(例如在 Uniswap 上虚假拉高价格)放在区块末尾。 由于这是最后一笔交易,同一区块内没有后续交易可以立即对冲或套利(比如其他套利者无法在同一个区块内提交反向交易)。 此时,被操纵的价格会被暂时记录(直到下一个区块)。
如果攻击者不是矿工: 攻击者可以尝试用高 Gas 费贿赂矿工,让自己的交易成为最后一笔。 但普通用户很难保证交易一定在末尾,因为矿工可能优先打包其他高 Gas 交易。
为什么操纵“非末尾”交易更难? 如果攻击者的交易在区块中间(非末尾),其他套利者可以在同一区块内紧跟一笔反向交易,迅速抵消操纵效果。 例如:
交易 1: 攻击者拉高价格
交易 2: 套利者发现机会,卖出代币恢复价格
结果:价格未被成功操纵。 但在区块末尾,套利者没有机会在同一区块内反应,必须等到下一个区块,此时攻击可能已生效(例如触发其他合约的清算或预言机喂价)。
为什么矿工或攻击者在不挖掘下一区块的时候没有特别的优势?
当尝试操纵价格后且下一区块不属于上一区块矿工或攻击者挖掘时。所有用户均同时看见此验证过的区块,并且能够作出决策的起跑线一致(区块被验证有效的瞬间)
若无区块挖掘权利,此矿工或攻击者将与其他人竞争交易优先权(通过支付高 gas 费),且尝试提高价格的支出成本存在损失风险。
Specifically, Uniswap v2 accumulates this price, by keeping track of the cumulative sum of prices at the beginning of each block in which someone interacts with the contract. Each price is weighted by the amount of time that has passed since the last block in which it was updated, according to the block timestamp.2 This means that the accumulator value at any given time (after being updated) should be the sum of the spot price at each second in the history of the contract.
具体来说,Uniswap v2 通过追踪每个有人与合约交互的区块开始时的累积价格总和来累积这个价格。每个价格都根据自上次更新的区块以来经过的时间(根据区块时间戳)进行加权2。这意味着在任何给定时间(更新后),累积器的值应该是合约历史上每秒钟现货价格的总和。
$$ a_t = \sum_{i=1}^{t} p_i $$
To estimate the time-weighted average price from time \(t_1\) to \(t_2\), an external caller can checkpoint the accumulator’s value at \(t_1\) and then again at \(t_2\), subtract the first value from the second, and divide by the number of seconds elapsed. (Note that the contract itself does not store historical values for this accumulator—the caller has to call the contract at the beginning of the period to read and store this value.)
要估计从时间 \(t_1\) to \(t_2\) 的时间加权平均价格,外部调用者可以在\(t_1\) 时刻检查累加器的值,然后在\(t_2\)时刻再次检查,将第一个值从第二个值中减去,然后除以经过的秒数。(请注意,合约本身不存储此累加器的历史值——调用者必须在期间开始时调用合约以读取和存储此值。)
$$ p_{t_1,t_2} = \frac{\sum_{i=t_1}^{t_2} p_i}{t_2 - t_1} = \frac{\sum_{i=1}^{t_2} p_i - \sum_{i=1}^{t_1} p_i}{t_2 - t_1} = \frac{a_{t_2} - a_{t_1}}{t_2 - t_1} $$
Users of the oracle can choose when to start and end this period. Choosing a longer period makes it more expensive for an attacker to manipulate the TWAP, although it results in a less up-to-date price.
预言机的用户可以选择时间范围的起始位置和结束位置。选择较长的时间区间会增加攻击者操纵 TWAP 的成本,尽管这会导致用户获取到不太实时的价格。
时间加权平均价格 (TWAP) 的时间间隔过长可能会导致几个问题,尤其是在波动性较大的市场中。以下是一些潜在的结果:
- 价格滞后:
- 时间间隔越长,TWAP 对当前市场价格的反应就越慢。
- 如果市场价格快速变化,TWAP 可能无法及时反映这些变化,从而导致交易者做出基于过时信息的决策。
- 滑点增加:
- 在价格快速变化的市场中,使用滞后的 TWAP 进行交易可能会导致滑点增加。
- 滑点是指预期交易价格与实际执行价格之间的差异。
- 如果 TWAP 没有及时反映当前市场价格,交易者可能会以比预期更差的价格成交。
- 套利机会:
- 滞后的 TWAP 可能会产生套利机会。
- 如果 TWAP 没有及时反映当前市场价格,套利者可能会利用价格差异在其他交易所或市场中进行获利。
- 预言机操纵:
- 在极端情况下,如果时间间隔过长,攻击者可能会更容易操纵 TWAP。
- 攻击者可以通过在较长时间内进行少量交易来缓慢地影响 TWAP,而不会引起注意。
- 对瞬时价格波动的敏感度降低:
- TWAP 的设计初衷是为了降低单笔交易对价格的影响,但是时间间隔过长,会降低价格对于短时剧烈波动的敏感度。
- 在一些希望对价格波动快速做出反应的协议中,这样的特性并不好。 时间间隔过长会降低 TWAP 的响应能力,使其对当前市场价格的反应滞后。这可能会导致滑点增加、套利机会和预言机操纵等问题。因此,在选择 TWAP 的时间间隔时,需要权衡准确性和响应能力。
One complication: should we measure the price of asset A in terms of asset B, or the price of asset B in terms of asset A? While the spot price of A in terms of B is always the reciprocal of the spot price of B in terms of A, the mean price of asset A in terms of asset B over a particular period of time is not equal to the reciprocal of the mean price of asset B in terms of asset A.3 For example, if the USD/ETH price is 100 in block 1 and 300 in block 2, the average USD/ETH price will be 200 USD/ETH, but the average ETH/USD price will be 1/150 ETH/USD. Since the contract cannot know which of the two assets users would want to use as the unit of account, Uniswap v2 tracks both prices.
一个复杂的问题是:我们应该衡量资产 A 相对于资产 B 的价格,还是资产 B 相对于资产 A 的价格?虽然 A 相对于 B 的即时价格始终是 B 相对于 A 的即时价格的倒数,但在特定时间段内,资产 A 相对于资产 B 的平均价格不等于资产 B 相对于资产 A 的平均价格的倒数3。例如,如果 USD/ETH 的价格在区块 1 中是 100,在区块 2 中是 300,那么平均 USD/ETH 价格将是 200 USD/ETH,但平均 ETH/USD 价格将是 1/150 ETH/USD。由于合约无法知道用户希望将两种资产中的哪一种用作记账单位,因此 Uniswap v2 会跟踪两种价格。
1. 即时价格的倒数关系 这句话首先明确了,在任何一个瞬间,资产 A 相对于资产 B 的价格,和资产 B 相对于资产 A 的价格,是互为倒数的关系。 例如,如果 1 ETH = 200 USD,那么 1 USD = 1/200 ETH。 2. 平均价格的差异 但是,当计算一段时间内的平均价格时,这个倒数关系就不成立了。 原因在于,平均价格的计算是基于价格本身的平均,而不是基于价格的倒数。 3. 示例分析:USD/ETH 和 ETH/USD USD/ETH 的平均价格: 区块 1:1 ETH = 100 USD 区块 2:1 ETH = 300 USD 平均 USD/ETH:(100 + 300) / 2 = 200 USD/ETH ETH/USD 的平均价格: 区块 1:1 USD = 1/100 ETH 区块 2:1 USD = 1/300 ETH 平均 ETH/USD:((1/100) + (1/300)) / 2 为了计算这个平均值,我们需要先计算括号内的算式,然后再除以 2。 (1/100 + 1/300) = (3/300 + 1/300) = 4/300 = 1/75 (1/75)/2 = 1/150 所以平均 ETH/USD 价格是 1/150 ETH/USD 4. 为什么会出现 1/150? 1/150 是通过计算 ETH/USD 价格在两个区块中的平均值得到的。 由于我们是对分数进行平均,而不是直接对倒数进行平均,所以结果与 1/200 不同。 5. Uniswap v2 的解决方案 因为用户可能需要不同方向的平均价格,Uniswap v2 同时跟踪两种价格,以确保提供全面的信息。
Another complication is that it is possible for someone to send assets to the pair contract—and thus change its balances and marginal price—without interacting with it, and thus without triggering an oracle update. If the contract simply checked its own balances and updated the oracle based on the current price, an attacker could manipulate the oracle by sending an asset to the contract immediately before calling it for the first time in a block. If the last trade was in a block whose timestamp was X seconds ago, the contract would incorrectly multiply the new price by X before accumulating it, even though nobody has had an opportunity to trade at that price. To prevent this, the core contract caches its reserves after each interaction, and updates the oracle using the price derived from the cached reserves rather than the current reserves. In addition to protecting the oracle from manipulation, this change enables the contract re-architecture described below in section 3.2.
另一个复杂的问题是,有人可能在不与交易对合约交互的情况下向此交易对合约发送资产,从而改变其余额和边际价格(因此也不会触发预言机更新)。如果合约只是检查自身的余额并根据当前价格更新预言机,攻击者可以通过在一个区块中首次调用合约之前立即向合约发送资产来操纵预言机。如果上次交易发生在 X 秒前的区块中,即使没有人有机会以该价格进行交易,合约也会错误地将新价格乘以 X 然后累积它。为了防止这种情况,核心合约在每次交互后缓存其资产储备值,并使用从缓存的储备而不是当前储备中得出的价格更新预言机。除了保护预言机免受操纵外,此更改还实现了下文 3.2 节中描述的合约重新架构。
此处简单的翻译即为:uniswap 不基于当前合约中的资产量计算价格,而是每次通过所有触发交易的事件记录并更新储备量。屏蔽了私下转入的资产对预言机的影响
2.2.1 Precision (精度)
Because Solidity does not have first-class support for non-integer numeric data types, the Uniswap v2 uses a simple binary fixed point format to encode and manipulate prices. Specifically, prices at a given moment are stored as UQ112.112 numbers, meaning that 112 fractional bits of precision are specified on either side of the decimal point, with no sign. These numbers have a range of \([0,2^{112}-1]\)4 and a precision of \(\frac{1}{2^{112}}\).
由于 Solidity 本身不支持非整数数值数据类型,Uniswap v2 使用一种简单的二进制定点格式来编码和操作价格。具体来说,某一时刻的价格存储为 UQ112.112 格式的数字,这意味着在小数点两侧都指定了 112 位的小数精度,并且没有符号。这些数字的范围是 \([0,2^{112}-1]\)4,精度为 \(\frac{1}{2^{112}}\).
The UQ112.112 format was chosen for a pragmatic reason — because these numbers can be stored in a uint224, this leaves 32 bits of a 256 bit storage slot free. It also happens that the reserves, each stored in a uint112, also leave 32 bits free in a (packed) 256 bit storage slot. These free spaces are used for the accumulation process described above. Specifically, the reserves are stored alongside the timestamp of the most recent block with at least one trade, modded with \(2^{32}\) so that it fits into 32 bits. Additionally, although the price at any given moment (stored as a UQ112.112 number) is guaranteed to fit in 224 bits, the accumulation of this price over an interval is not. The extra 32 bits on the end of the storage slots for the accumulated price of A/B and B/A are used to store overflow bits resulting from repeated summations of prices. This design means that the price oracle only adds an additional three SSTORE operations (a current cost of about 15,000 gas) to the first trade in each block.
选择 UQ112.112 格式是出于务实的考虑——因为这些数字可以存储在 uint224 中,这使得一个 256 位的存储槽空闲了 32 位。碰巧的是,储备金(每个都存储在 uint112 中)也在一个(紧凑的)256 位存储槽中留出了 32 位空闲空间。这些空闲空间用于上述的累积过程。具体来说,储备金与最近一次至少发生一笔交易的区块的时间戳一起存储,该时间戳会进行模 \(2^{32}\)运算,以截取此时间戳的末尾 32 位。此外,虽然任何给定时刻的价格(存储为 UQ112.112 数字)保证可以容纳在 224 位中,但该价格在一段时间内的累积却不能。存储槽末尾用于存储 A/B 和 B/A 累积价格的额外 32 位用于存储重复的价格求和产生的溢出位。这种设计意味着价格预言机在每个区块的第一笔交易中仅额外增加三个 SSTORE 操作(目前成本约为 15,000 gas)。
1.
UQ112.112格式的用途
- 定义:
UQ112.112是一种 224 位无符号定点数格式,其中:
- 前 112 位 表示整数部分。
- 后 112 位 表示小数部分。
- 作用:用于精确存储价格(如
reserve1/reserve0),避免浮点数精度丢失。
2. 存储布局优化
Uniswap v2 的存储槽(256 位)被 紧凑排列 以节省 Gas 成本:
存储槽 1:储备量 + 时间戳
字段 位数 说明 reserve0112 位 代币 0 的储备量(如 USDC)。 reserve1112 位 代币 1 的储备量(如 ETH)。 blockTimestampLast32 位 最后一次交易的时间戳(取模 2^32,仅保留后 32 位)。
- 空闲位:
reserve0 + reserve1 + blockTimestampLast = 112 + 112 + 32 = 256位,刚好填满一个存储槽。存储槽 2:累积价格
字段 位数 说明 price0Cumulative224 位 代币 0 的累积价格( UQ112.112格式)。price1Cumulative224 位 代币 1 的累积价格( UQ112.112格式)。溢出位 32 位 用于处理累积价格的溢出(见下文)。
- 空闲位:
price0Cumulative + price1Cumulative = 224 + 224 = 448位,但一个存储槽仅 256 位,因此实际分为两个存储槽,剩余 32 位用于溢出处理。
3. 为什么选择
UQ112.112?
- Gas 效率:
- 每次交易更新价格时,只需修改 一个存储槽(储备量+时间戳)和 部分累积价格槽。
- 若使用更大的数据类型(如
uint256),会占用更多存储槽,增加 Gas 成本。- 精度足够:
- 112 位小数部分可满足大多数代币价格的精度需求(如 ETH/USDC 价格通常小于
2^112)。- 溢出处理:
- 累积价格可能随时间溢出
UQ112.112的 224 位范围,因此额外 32 位用于记录溢出次数(类似“进位”)。
4. 时间戳的截断(模
2^32)
- 原因:
- 区块时间戳本身是
uint256,但仅需记录相对时间差(ΔT)。- 截断为 32 位后,可覆盖 约 136 年 的时间范围(
2^32秒 ≈ 136年),完全够用。- 节省空间:
- 避免占用额外存储槽,与储备量共享同一槽位。
5. Gas 成本优化
- 每次更新的操作:
- 更新
reserve0和reserve1(同一存储槽)。- 更新
price0Cumulative和price1Cumulative(需两个存储槽)。- 更新
blockTimestampLast(与储备量共享存储槽)。- 总成本:约 15,000 Gas(主要来自 3 次
SSTORE操作)。
6. 示例说明
假设在区块时间戳
t=1000时:
- 储备量:
reserve0 = 1000 USDC,reserve1 = 1 ETH→ 价格price0 = 1/1000 = 0.001。- 累积价格更新:
- 若
blockTimestampLast = 500,则ΔT = 1000 - 500 = 500秒。price0Cumulative += 0.001 * 500 = 0.5(以UQ112.112格式存储)。
7. 设计优势总结
- 紧凑存储:充分利用每个存储槽的 256 位,减少 Gas 消耗。
- 抗溢出:通过额外 32 位处理累积价格的溢出。
- 低延迟更新:仅在区块首笔交易时更新累积价格,避免频繁计算。
- 兼容性:与 EVM 的存储模型完美匹配,无需复杂升级。
The primary downside is that 32 bits isn’t quite enough to store timestamp values that will reasonably never overflow. In fact, the date when the Unix timestamp overflows a uint32 is 02/07/2106. To ensure that this system continues to function properly after this date, and every multiple of \(2^{32}-1\) seconds thereafter, oracles are simply required to check prices at least once per interval (approximately 136 years). This is because the core method of accumulation (and modding of timestamp), is actually overflow-safe, meaning that trades across overflow intervals can be appropriately accounted for given that oracles are using the proper (simple) overflow arithmetic to compute deltas.
主要的缺点是 32 位不足以存储合理范围内永远不会溢出的时间戳值。事实上,Unix 时间戳溢出 uint32 的日期是 2106 年 2 月 7 日。为了确保该系统在此日期之后以及此后每隔 \(2^{32}-1\)秒的倍数都能正常运行,预言机只需要至少每隔一段时间(大约 136 年)检查一次价格即可。这是因为累积(以及时间戳的模运算)的核心方法实际上是溢出安全的,这意味着只要预言机使用正确的(简单的)溢出算术来计算增量,就可以适当地计算跨越溢出间隔的交易。
_update() function . 时间计算式 : \( 2^{32}_{s} / ( {60}_{s/m} * {60}_{m/h} * {24}_{h/d} * {365}_{d/y} ) \)≈ 136.192519
2.3 Flash Swaps (闪电互换)
In Uniswap v1, a user purchasing ABC with XYZ needs to send the XYZ to the contract before they could receive the ABC. This is inconvenient if that user needs the ABC they are buying in order to obtain the XYZ they are paying with. For example, the user might be using that ABC to purchase XYZ in some other contract in order to arbitrage a price difference from Uniswap, or they could be unwinding a position on Maker or Compound by selling the collateral to repay Uniswap.
在 Uniswap v1 中,用户用 XYZ 购买 ABC 需要先将 XYZ 发送到合约,然后才能收到 ABC。如果用户需要他们购买的 ABC 来获得他们用于支付的 XYZ,这会很不方便。例如,用户可能正在使用该 ABC 在某个其他合约中购买 XYZ,以赚取此合约与 Uniswap 的差价,或者他们可以通过出售抵押品来偿还 Uniswap 来平仓 Maker 或 Compound 的头寸。
头寸: 投资者拥有或借用的资金数量
Uniswap v2 adds a new feature that allows a user to receive and use an asset before paying for it, as long as they make the payment within the same atomic transaction. The swap function makes a call to an optional user-specified callback contract in between transferring out the tokens requested by the user and enforcing the invariant. Once the callback is complete, the contract checks the new balances and confirms that the invariant is satisfied (after adjusting for fees on the amounts paid in). If the contract does not have sufficient funds, it reverts the entire transaction.
Uniswap v2 增加了一项新功能,允许用户在付款之前接收和使用资产,只要他们在同一个原子交易中完成付款即可。swap 函数在转出用户请求的代币和强制执行不变量之间,调用一个可选的用户指定的回调合约。回调完成后,合约检查新的余额,并确认不变量得到满足(在根据支付金额调整费用后)。如果合约没有足够的资金,它将回滚整个交易。
A user can also repay the Uniswap pool using the same token, rather than completing the swap. This is effectively the same as letting anyone flash-borrow any of assets stored in a Uniswap pool (for the same \(0.30\%\) fee as Uniswap charges for trading).5
用户也可以使用相同的代币偿还 Uniswap 池,而不是完成兑换。这实际上等同于允许任何人闪电借用存储在 Uniswap 池中的任何资产(费用与 Uniswap 收取的交易费用相同,为 0.30%)5。
此借贷是一个临时资金周转的工具,起到的最大作用即加速资金的流转。
有意思的点是,与传统金融中过桥协议类似
1.两者中传统过桥协议需要抵押物,存在一定无法偿还的风险。而闪电互换无需抵押,依赖于区块链的回滚机制,除了协议漏洞外无偿还风险。
2.两者中传统过桥协议资金量大,面向企业。而闪电互换面向所有用户,常为小资金用户。
2.4 Protocol fee
Uniswap v2 includes a \(0.05\%\) protocol fee that can be turned on and off. If turned on, this fee would be sent to a feeTo address specified in the factory contract.
Uniswap v2 包含一个可以开启和关闭的 \(0.05\%\) 协议费用。如果开启,此费用将发送到工厂合约中指定的 feeTo 地址。
Initially, feeTo is not set, and no fee is collected. A pre-specified address—feeToSetter—can call the setFeeTo function on the Uniswap v2 factory contract, setting feeTo to a different value. feeToSetter can also call the setFeeToSetter to change the feeToSetter address itself.
初始化时,feeTo 未设置,不收取任何费用。一个预先指定的地址——feeToSetter——可以调用 Uniswap v2 工厂合约上的 setFeeTo 函数,将 feeTo 设置为不同的值。feeToSetter 还可以调用 setFeeToSetter 来更改 feeToSetter 地址本身。
If the feeTo address is set, the protocol will begin charging a 5-basis-point fee, which is taken as a \(\frac{1}{6}\) cut of the 30-basis-point fees earned by liquidity providers. That is, traders will continue to pay a \(0.30\%\) fee on all trades; \(83.\overline{3}\%\) of that fee ( \(0.25\%\) of the amount traded) will go to liquidity providers, and \(16.\overline{6}\%\) of that fee ( \(0.05\%\) of the amount traded) will go to the feeTo address.
如果 feeTo 地址已设置,协议将开始收取 5 个基点的费用,这相当于流动性提供者赚取的 30 个基点费用的 \(\frac{1}{6}\)。也就是说,交易者将继续为所有交易支付 \(0.30\%\)的费用;该费用的 \(83.\overline{3}\%\)(交易金额的 0.25%)将归流动性提供者所有,而该费用的 \(16.\overline{6}\%\)(交易金额的 0.05%)将归 feeTo 地址所有。
Collecting this \(0.05\%\) fee at the time of the trade would impose an additional gas cost on every trade. To avoid this, accumulated fees are collected only when liquidity is deposited or withdrawn. The contract computes the accumulated fees, and mints new liquidity tokens to the fee beneficiary, immediately before any tokens are minted or burned.
在交易时收取这 \(0.05\%\) 的费用会在每笔交易中增加额外的 Gas 成本。为了避免这种情况,累积的费用仅在存入或提取流动性时收取。合约在铸造或销毁任何代币之前,计算累积的费用,并向费用受益人铸造新的流动性代币。
The total collected fees can be computed by measuring the growth in \(\sqrt{k}\) (that is, \(\sqrt{x \cdot y}\)) since the last time fees were collected.6 This formula gives you the accumulated fees between \(t_1\) and \(t_2\) as a percentage of the liquidity in the pool at \(t_2\):
可以通过测量自上次收取费用以来\(\sqrt{k}\) (即 \(\sqrt{x \cdot y}\))的增长来计算收取的总费用6。此公式给出了在 \(t_1\)和 \(t_2\)之间累积的费用占 \(t_2\)时刻池中流动性的百分比:
$$ f_{1,2} = 1 - \frac{\sqrt{k_1}}{\sqrt{k_2}} $$
If the fee was activated before \(t_1\), the feeTo address should capture \(\frac{1}{6}\) of fees that were accumulated between \(t_1\) and \(t_2\). Therefore, we want to mint new liquidity tokens to the feeTo address that represent \( \phi \cdot f_{1,2} \) of the pool, where \(\phi\) is \(\frac{1}{6}\).
如果费用在 \(t_1\)之前被激活,则 feeTo 地址应获得\(\frac{1}{6}\)的,在\(t_1\)和\(t_2\)之间产生的费率。 因此,我们希望向 feeTo 地址铸造代表池中 \( \phi \cdot f_{1,2} \) 份额的新流动性代币,其中\(\phi\) 是 \(\frac{1}{6}\)。
That is, we want to choose \(s_m\) to satisfy the following relationship, where \(s_1\) is the total quantity of outstanding shares at time \(t_1\):
也就是说,我们希望选择 \(s_m\)来满足以下关系,其中\(s_1\)是在 \(t_1\)时刻流通的份额总数:
$$ \frac{s_m}{s_m + s_1} = \phi \cdot f_{1,2} $$
After some manipulation, including substituting \(1 - \frac{\sqrt{k_1}}{\sqrt{k_2}}\) for \(f_{1,2}\) and solving for \(s_m\), we can rewrite this as:
在进行一些操作后,包括代入\(1 - \frac{\sqrt{k_1}}{\sqrt{k_2}}\) 作为\(f_{1,2}\) ,并且求解\(s_m\),我们可以重写此公式为:
$$ s_m = \frac{\sqrt{k_2} - \sqrt{k_1}}{(\frac{1}{\phi} - 1) \cdot \sqrt{k_2} + \sqrt{k_1}} \cdot s_1 $$
Setting \(\phi\) to \(\frac{1}{6}\) gives us the following formula:
将\(\phi\) 设定为\(\frac{1}{6}\) 给我们带来如下的公式:
$$ s_m = \frac{\sqrt{k_2} - \sqrt{k_1}}{5 \cdot \sqrt{k_2} + \sqrt{k_1}} \cdot s_1 $$
Suppose the initial depositor puts 100 DAI and 1 ETH into a pair, receiving 10 shares. Some time later (without any other depositor having participated in that pair), they attempt to withdraw it, at a time when the pair has 96 DAI and 1.5 ETH. Plugging those values into the above formula gives us the following:
假设初始存款人向一个交易对投入 100 DAI 和 1 ETH,并获得 10 份份额。一段时间后(没有其他存款人参与该交易对),他们在交易对拥有 96 DAI 和 1.5 ETH 时尝试提取。将这些值代入上述公式,我们得到以下结果:
$$ s_m = \frac{\sqrt{1.5 \cdot 96} - \sqrt{1 \cdot 100}}{5 \cdot \sqrt{1.5 \cdot 96} + \sqrt{1 \cdot 100}} \cdot 10 \approx 0.0286 $$
2.5 Meta transactions for pool shares (流动池份额中的元交易)
Pool shares minted by Uniswap v2 pairs natively support meta transactions. This means users can authorize a transfer of their pool shares with a signature7, rather than an on-chain transaction from their address. Anyone can submit this signature on the user’s behalf by calling the permit function, paying gas fees and possibly performing other actions in the same transaction.
Uniswap v2 交易对铸造的池份额原生支持元交易。这意味着用户可以通过签名7授权转移他们的池份额,而无需从他们的地址发起链上交易。任何人都可以通过调用 permit 函数代表用户提交此签名,支付 Gas 费用,并可能在同一笔交易中执行其他操作。
3 Other changes (其他变化)
3.1 Solidity
Uniswap v1 is implemented in Vyper, a Python-like smart contract language. Uniswap v2 is implemented in the more widely-used Solidity, since it requires some capabilities that were not yet available in Vyper (such as the ability to interpret the return values of non-standard ERC-20 tokens, as well as access to new opcodes such as chainid via inline assembly) at the time it was being developed.
Uniswap v1 使用 Vyper(一种类似 Python 的智能合约语言)实现。Uniswap v2 则使用更广泛使用的 Solidity 实现,因为它在开发时需要一些 Vyper 尚未提供的功能(例如解释非标准 ERC-20 代币返回值的能力,以及通过内联汇编访问诸如 chainid 等新操作码的能力)。
3.2 Contract re-architecture (合约重构)
One design priority for Uniswap v2 is to minimize the surface area and complexity of the core pair contract—the contract that stores liquidity providers’ assets. Any bugs in this contract could be disastrous, since millions of dollars of liquidity might be stolen or frozen.
Uniswap v2 的一个设计重点是最小化核心交易对合约(存储流动性提供者的资产的合约)的表面积(即合约暴露给调用者的部分)和复杂性。此合约中的任何错误都可能是灾难性的,因为数百万美元的流动性可能会被盗或冻结。
When evaluating the security of this core contract, the most important question is whether it protects liquidity providers from having their assets stolen or locked. Any feature that is meant to support or protect traders—other than the basic functionality of allowing one asset in the pool to be swapped for another—can be handled in a “router” contract.
在评估此核心合约的安全性时,最重要的问题是它是否保护流动性提供者免于资产被盗或锁定。任何旨在支持或保护交易者的功能——除了允许池中的一种资产兑换为另一种资产的基本功能之外——都可以在“路由器”合约中处理。
In fact, even part of the swap functionality can be pulled out into the router contract. As mentioned above, Uniswap v2 stores the last recorded balance of each asset (in order to prevent a particular manipulative exploit of the oracle mechanism). The new architecture takes advantage of this to further simplify the Uniswap v1 contract.
事实上,甚至是部分兑换功能也可以提取到路由器合约中。如上所述,Uniswap v2 存储了每种资产的最后记录余额(以防止特定的预言机机制的操纵性利用)。新的架构利用这一点进一步简化了 Uniswap v1 合约。
In Uniswap v2, the seller sends the asset to the core contract before calling the swap function. Then, the contract measures how much of the asset it has received, by comparing the last recorded balance to its current balance. This means the core contract is agnostic to the way in which the trader transfers the asset. Instead of transferFrom, it could be a meta transaction, or any other future mechanism for authorizing the transfer of ERC-20s.
在 Uniswap v2 中,卖方在调用 swap 函数之前将资产发送到核心合约。然后,合约通过比较最后记录的余额和当前余额来衡量它收到了多少资产。这意味着核心合约与交易者转移资产的方式无关。它可以是元交易,也可以是任何其他未来授权 ERC-20 转移的机制,而不是 transferFrom。
3.2.1 Adjustment for fee (费率调整)
Uniswap v1’s trading fee is applied by reducing the amount paid into the contract by \(0.3\%\) before enforcing the constant-product invariant. The contract implicitly enforces the following formula:
Uniswap v1 的交易费用是通过在执行 恒定乘积不变量计算之前划扣掉用户支付到合约的总资产量的\(0.3\%\) 实现的。合约隐式地强制执行以下公式:
$$ (x_1 - 0.003 \cdot x_{in}) \cdot y_1 \gt = x_0 \cdot y_0 $$
With flash swaps, Uniswap v2 introduces the possibility that \(x_{in}\) and \(y_{in}\) might both be non-zero (when a user wants to pay the pair back using the same asset, rather than swapping). To handle such cases while properly applying fees, the contract is written to enforce the following invariant8:
借助闪电互换,Uniswap v2 引入了 \( x_{in}\)和 \(y_{in}\)可能都非零的情况(当用户想要使用相同的资产偿还交易对,而不是进行兑换时)。为了在正确应用费用的同时处理这种情况,合约被编写为强制执行以下不变量8:
$$ (x_1 - 0.003 \cdot x_{in}) \cdot (y_1 - 0.003 \cdot y_{in}) \gt = x_0 \cdot y_0 $$
To simplify this calculation on-chain, we can multiply each side of the inequality by 1,000,000:
为简化这个在链上的计算,我们可以在不等式两侧同乘 1,000,000:
$$ (1000 \cdot x_1 - 3 \cdot x_{in}) \cdot (1000 \cdot y_1 - 3 \cdot y_{in}) \gt = 1000000 \cdot x_0 \cdot y_0 $$
3.2.2 sync() and skim()
To protect against bespoke token implementations that can update the pair contract’s balance, and to more gracefully handle tokens whose total supply can be greater than \(2^{112}\), Uniswap v2 has two bail-out functions: sync() and skim().
为了防范可能更新交易对合约余额的定制代币实现,并更优雅地处理总供应量可能大于 \(2^{112}\)的代币,Uniswap v2 提供了两个应急函数:sync() 和 skim()。
sync() functions as a recovery mechanism in the case that a token asynchronously deflates the balance of a pair. In this case, trades will receive sub-optimal rates, and if no liquidity provider is willing to rectify the situation, the pair is stuck. sync() exists to set the reserves of the contract to the current balances, providing a somewhat graceful recovery from this situation.
sync() 函数作为一种恢复机制,用于处理代币异步通缩交易对余额的情况。在这种情况下,交易将获得次优的价格,如果没有任何流动性提供者愿意纠正这种情况,交易对就会陷入困境。sync() 的存在是为了将合约的储备设置为当前的余额,从而为此情况提供某种程度的优雅恢复。
此机制触发将一定程度上打破币对供给平衡(前提是有用户直接向合约转入代币而不调用合约),通过尝试增加套利空间而使交易继续进行
skim() functions as a recovery mechanism in case enough tokens are sent to an pair to overflow the two uint112 storage slots for reserves, which could otherwise cause trades to fail. skim() allows a user to withdraw the difference between the current balance of the pair and \(2^{112}-1\) to the caller, if that difference is greater than 0.
skim() 函数作为一种恢复机制,用于处理发送到交易对的代币数量过多,导致超出用于存储储备的两个 uint112 存储槽的情况,否则可能导致交易失败。如果当前交易对余额与 \(2^{112}-1\) 之间的差值大于 0,skim() 允许用户将该差值提取给调用者。
一般正常的合约不会提供超过一千万亿额度的代币,此处主要处理潜在的非正常情况
3.3 Handling non-standard and unusual tokens (处理非标准及不常见代币)
The ERC-20 standard requires that transfer() and transferFrom() return a boolean indicating the success or failure of the call 4. The implementations of one or both of these functions on some tokens—including popular ones like Tether (USDT) and Binance Coin (BNB)—instead have no return value. Uniswap v1 interprets the missing return value of these improperly defined functions as false—that is, as an indication that the transfer was not successful—and reverts the transaction, causing the attempted transfer to fail.
ERC-20 标准要求 transfer() 和 transferFrom() 返回一个布尔值,指示调用成功或失败 4。然而,一些代币(包括流行的 Tether (USDT) 和 Binance Coin (BNB))上这两个函数中的一个或两个的实现都没有返回值。Uniswap v1 将这些不正确定义的函数缺失的返回值解释为 false——即表示转账不成功——并回滚交易,导致尝试的转账失败。
Uniswap v2 handles non-standard implementations differently. Specifically, if a transfer() call9 has no return value, Uniswap v2 interprets it as a success rather than as a failure. This change should not affect any ERC-20 tokens that conform to the standard (because in those tokens, transfer() always has a return value).
Uniswap v2 以不同的方式处理非标准实现。具体来说,如果 transfer() 调用9 没有返回值,Uniswap v2 会将其解释为成功而不是失败。此更改不应影响任何符合标准的 ERC-20 代币(因为在这些代币中,transfer() 始终具有返回值)。
Uniswap v1 also makes the assumption that calls to transfer() and transferFrom() cannot trigger a reentrant call to the Uniswap pair contract. This assumption is violated by certain ERC-20 tokens, including ones that support ERC-777’s “hooks” 5. To fully support such tokens, Uniswap v2 includes a “lock” that directly prevents reentrancy to all public state-changing functions. This also protects against reentrancy from the user-specified callback in a flash swap, as described in section 2.3.
Uniswap v1 还假设对 transfer() 和 transferFrom() 的调用不会触发对 Uniswap 交易对合约的重入调用。某些 ERC-20 代币违反了此假设,包括支持 ERC-777“钩子”的代币5。为了完全支持此类代币,Uniswap v2 包含一个“锁”,该锁直接阻止对所有公共状态更改函数的重入。正如 2.3 节所述,这还可以防止闪电互换中用户指定的回调函数的重入。
3.4 Initialization of liquidity token supply (流动性代币的初始化补充)
When a new liquidity provider deposits tokens into an existing Uniswap pair, the number of liquidity tokens minted is computed based on the existing quantity of tokens:
当一个新的流动性提供者向现有的 Uniswap 交易对存入代币时,所铸造的流动性代币数量是基于现有的代币数量计算的:
$$ s_{minted} = \frac{x_{deposited}}{x_{starting}} \cdot s_{starting} $$
But what if they are the first depositor? In that case, \(x_{starting}\) is \(0\), so this formula will not work.
但如果他们是第一个存款人呢?在这种情况下,\(x_{starting}\) 为 \(0\),因此该公式将不起作用。
Uniswap v1 sets the initial share supply to be equal to the amount of ETH deposited (in wei). This was a somewhat reasonable value, because if the initial liquidity was deposited at the correct price, then 1 liquidity pool share (which, like ETH, is an 18-decimal token) would be worth approximately 2 ETH.
Uniswap v1 将初始份额供应量设置为等于存入的 ETH 数量(以 wei 为单位)。这是一个相对合理的值,因为如果初始流动性以正确的价格存入,那么 1 份流动性池份额(与 ETH 一样,是 18 位小数的代币)的价值约为 2 ETH。
例如:初始化创建者向 ETH-DAI 币对存入 1ETH+100DAI(至少以创建者的价值设定以及接受程度,100DAI 值 1ETH)。那么按照合约规则创建者获取到 1 LP-Token。这 1 个 Token 值 1ETH+100DAI,按照价值等比转换的算法,值 2ETH。至于按常规市场视角来说,这个比例不合理,那么就会有潜在的交易者从中套利,开始使得这个价值比例趋于市场价格。初始创建者将承担潜在的损失 ETH 或 DAI 的风险。
一个潜在的有意思的设计在于:想在创建之初吸引到足够多的交易者进入或参与到此币对的互换中来时,创建者可以投入大量的资金创造这种代币兑换的不平衡。尽管这将在前期带来一定量的亏损,不过在后期,尤其是创建者是其中某个代币发行方时,将会获得大量用户并使得货币价格得到一定的保障(毕竟在不爆雷或者出现重大漏洞的情况下,每个人的交易时间,获取到的信息和对代币当下的心理价位不同)。
However, this meant that the value of a liquidity pool share was dependent on the ratio at which liquidity was initially deposited, which was fairly arbitrary, especially since there was no guarantee that that ratio reflected the true price. Additionally, Uniswap v2 supports arbitrary pairs, so many pairs will not include ETH at all.
然而,这意味着流动性池份额的价值取决于初始存入流动性的比例,而这个比例相当随意,特别是因为不能保证该比例反映真实价格。此外,Uniswap v2 支持任意交易对,因此许多交易对根本不会包含 ETH。
Instead, Uniswap v2 initially mints shares equal to the geometric mean of the amounts deposited:
相反,Uniswap v2 最初铸造的份额等于存入金额的几何平均值:
$$ s_{minted} = \sqrt{x_{deposited} \cdot y_{deposited}} $$
This formula ensures that the value of a liquidity pool share at any time is essentially independent of the ratio at which liquidity was initially deposited. For example, suppose that the price of 1 ABC is currently 100 XYZ. If the initial deposit had been 2 ABC and 200 XYZ (a ratio of 1:100), the depositor would have received \(\sqrt{2 \cdot 200} = 20\) shares. Those shares should now still be worth 2 ABC and 200 XYZ, plus accumulated fees.
此公式确保任何时候流动性池份额的价值基本上独立于初始存入流动性的比例。例如,假设 1 ABC 的价格当前为 100 XYZ。如果初始存款为 2 ABC 和 200 XYZ(比例为 1:100),存款人将收到 \(\sqrt{2 \cdot 200} = 20\) 份份额。这些份额现在仍然值 2 ABC 和 200 XYZ,加上累积的费用。
If the initial deposit had been 2 ABC and 800 XYZ (a ratio of 1:400), the depositor would have received \(\sqrt{2 \cdot 800} = 40\) pool shares.10
如果初始存款为 2 ABC 和 800 XYZ(比例为 1:400),存款人将收到 \(\sqrt{2 \cdot 800} = 40\) 份池份额10。
The above formula ensures that a liquidity pool share will never be worth less than the geometric mean of the reserves in that pool. However, it is possible for the value of a liquidity pool share to grow over time, either by accumulating trading fees or through “donations” to the liquidity pool. In theory, this could result in a situation where the value of the minimum quantity of liquidity pool shares (1e-18 pool shares) is worth so much that it becomes infeasible for small liquidity providers to provide any liquidity.
上述公式确保流动性池份额的价值永远不会低于该池中储备的几何平均值。然而,流动性池份额的价值可能会随着时间的推移而增长,无论是通过累积交易费用还是通过向流动性池“捐赠”。理论上,这可能会导致最小数量的流动性池份额(1e-18 份池份额)的价值变得非常高,以至于小额流动性提供者无法提供任何流动性。
To mitigate this, Uniswap v2 burns the first 1e-15 (0.000000000000001) pool shares that are minted (1000 times the minimum quantity of pool shares), sending them to the zero address instead of to the minter. This should be a negligible cost for almost any token pair.11 But it dramatically increases the cost of the above attack. In order to raise the value of a liquidity pool share to \($100\), the attacker would need to donate $100,000 to the pool, which would be permanently locked up as liquidity.
为了缓解这种情况,Uniswap v2 销毁了最初铸造的 1e-15 (0.000000000000001) 份池份额(是最小池份额数量的 1000 倍),将其发送到零地址而不是铸造者。对于几乎任何代币对来说,这都应该是一个可以忽略不计的成本11。但它大大增加了上述攻击的成本。为了将流动性池份额的价值提高到 \($100\),攻击者需要向池中捐赠 $100,000,这将作为流动性永久锁定。
背景知识
- 流动性池份额(LP Shares)
- 当用户向 Uniswap 的流动性池存入代币时,会获得对应的流动性池份额(LP Token),代表他们在池中的所有权。
- 池份额的价值由池中代币储备的几何平均值决定(即公式 \( \sqrt{reserveA \cdot reserveB} \)),确保其价值不会低于该几何平均值。
- 最小流动性份额(1e-18)
- Uniswap v2 中,最小的流动性份额单位是 \( 10^{-18} \)(即 1 wei 的 LP Token)。这是技术限制下的最小可分割单位。
小额流动性提供者可能被挤出
- 如果流动性池份额的价值因交易费用累积或人为“捐赠”而变得非常高,那么:
- 例如,假设 1 份 LP Token 的价值涨到$100,那么最小的 \( 10^{-18} \) 份额价值就是 \( 10^{-16} \) 美元(几乎可以忽略不计)。
- 但问题在于:用户无法存入比最小份额更小的流动性。如果池份额价值极高,用户需要存入大量代币才能获得哪怕 \( 10^{-18} \) 的份额,这会让小额流动性提供者无法参与。
Uniswap v2 的解决方案:销毁初始流动性
为了缓解这一问题,Uniswap v2 在创建池子时:
- 初始铸造时销毁一部分份额
- 在池子创建时,会先铸造 \( 10^{-15} \)(即 1000 倍于最小份额)的流动性,但不分配给任何人,而是直接发送到零地址销毁。
- 这样,池中始终有至少 \( 10^{-15} \) 的流动性被永久锁定,无法被赎回。
- 攻击成本大幅提高
- 假设攻击者想通过“捐赠”代币(比如向池中无偿转入代币)来抬高 LP 份额的价值:
- 如果目标是让 1 份 LP Token 的价值达到$100,那么攻击者需要让池的几何平均值达到 \( $100 \)。
- 由于初始有 \( 10^{-15} \) 的流动性被销毁,攻击者需要让这部分被销毁的份额也值$100,因此需要让整个池子的价值大幅提高。
- 攻击者需要锁定的资金量,会根据池子中总的流动性变化,池子流动性越大,需要的资金越多。
- 实际上,Uniswap 文档中的例子是$100,000,可能是基于更合理的假设,但核心逻辑一致:攻击者需要锁定巨额资金。
- 实际效果
- 销毁初始流动性后,攻击者需要付出极高的成本才能操纵 LP 份额的价值,而小额流动性提供者仍能参与(因为最小份额的价值不会轻易被抬高到无法接受的程度)。
为什么初始销毁 \( 10^{-15} \)?
- 这是一个权衡后的值:
- 足够小:对普通用户来说,销毁 \( 10^{-15} \) 的份额成本可以忽略不计(几乎不值钱)。
- 足够大:能显著提高攻击者的成本(需要锁定更多资金)。
- 1e-15 的选择,也是为了在技术限制和安全性之间取得平衡。
Uniswap v2 通过销毁初始流动性(“最小化初始流动性”),实现了以下目标:
- 防止攻击者通过少量“捐赠”抬高 LP 份额价值,挤兑小额流动性提供者。
- 确保攻击者需付出极高成本(如永久锁定大量资金)才能操纵池子。
- 对正常用户几乎无成本影响,同时维护了池的长期可用性。
这是一种巧妙的经济安全设计,通过牺牲极小的初始流动性,换取了系统的健壮性和公平性。
3.5 Wrapping ETH (封装 ETH)
The interface for transacting with Ethereum’s native asset, ETH, is different from the standard interface for interacting with ERC-20 tokens. As a result, many other protocols on Ethereum do not support ETH, instead using a canonical “wrapped ETH” token, WETH 6.
与以太坊原生资产 ETH 进行交易的接口不同于与 ERC-20 代币交互的标准接口。因此,以太坊上的许多其他协议不支持 ETH,而是使用规范的“封装 ETH”代币,WETH 6。
Uniswap v1 is an exception. Since every Uniswap v1 pair included ETH as one asset, it made sense to handle ETH directly, which was slightly more gas-efficient.
Uniswap v1 是一个例外。由于每个 Uniswap v1 交易对都包含 ETH 作为一种资产,因此直接处理 ETH 是有意义的,这样 Gas 效率会略高。
Since Uniswap v2 supports arbitrary ERC-20 pairs, it now no longer makes sense to support unwrapped ETH. Adding such support would double the size of the core codebase, and risks fragmentation of liquidity between ETH and WETH pairs12. Native ETH needs to be wrapped into WETH before it can be traded on Uniswap v2.
由于 Uniswap v2 支持任意 ERC-20 交易对,因此现在不再支持未封装的 ETH。添加此类支持将使核心代码库的大小翻倍,并存在 ETH 和 WETH 交易对之间流动性分散的风险12。原生 ETH 需要封装成 WETH 才能在 Uniswap v2 上进行交易。
3.6 Deterministic pair addresses ( 确定性的交易对地址)
As in Uniswap v1, all Uniswap v2 pair contracts are instantiated by a single factory contract. In Uniswap v1, these pair contracts were created using the CREATE opcode, which meant that the address of such a contract depended on the order in which that pair was created. Uniswap v2 uses Ethereum’s new CREATE2 opcode 8 to generate a pair contract with a deterministic address. This means that it is possible to calculate a pair’s address (if it exists) off-chain, without having to look at the chain state.
与 Uniswap v1 一样,所有 Uniswap v2 交易对合约都由单个工厂合约实例化。在 Uniswap v1 中,这些交易对合约是使用 CREATE 操作码创建的,这意味着此类合约的地址取决于创建该交易对的顺序。Uniswap v2 使用以太坊新的 CREATE2 操作码 8 生成具有确定性地址的交易对合约。这意味着可以在链下计算交易对的地址(如果存在),而无需查看链状态。
3.7 Maximum token balance (最大代币余额)
In order to efficiently implement the oracle mechanism, Uniswap v2 only support reserve balances of up to \(2^{112}-1\). This number is high enough to support 18-decimal-place tokens with a totalSupply over 1 quadrillion.
为了高效地实现预言机机制,Uniswap v2 仅支持最高为 \(2^{112}-1\) 的储备余额。这个数字足够大,可以支持总供应量超过 1 千万亿的 18 位小数代币。
If either reserve balance does go above \(2^{112}-1\), any call to the swap function will begin to fail (due to a check in the _update() function). To recover from this situation, any user can call the skim() function to remove excess assets from the liquidity pool.
如果任何一个储备余额超过 \(2^{112}-1\),对 swap 函数的任何调用都将开始失败(由于 _update() 函数中的检查)。为了从这种情况中恢复,任何用户都可以调用 skim() 函数从流动性池中移除多余的资产。
References
4 Disclaimer
This paper is for general information purposes only. It does not constitute investment advice or a recommendation or solicitation to buy or sell any investment and should not be used in the evaluation of the merits of making any investment decision. It should not be relied upon for accounting, legal or tax advice or investment recommendations. This paper reflects current opinions of the authors and is not made on behalf of Paradigm or its affiliates and does not necessarily reflect the opinions of Paradigm, its affiliates or individuals associated with Paradigm. The opinions reflected herein are subject to change without being updated.
这份文件仅供一般信息参考。它不构成投资建议,也不构成购买或出售任何投资的推荐或招揽,不应用于评估任何投资决策的优点。不应将其作为会计、法律或税务建议或投资推荐的依据。这份文件反映了作者当前的观点,并非代表 Paradigm 或其关联公司发表,也不一定反映 Paradigm、其关联公司或与 Paradigm 相关联的个人的观点。此处反映的观点可能会发生变化,恕不另行更新。
footnode (脚注)
-
For a real-world example of how using Uniswap v1 as an oracle can make a contract vulnerable to such an attack, see 3.对于现实世界中使用 uniswap v1 作为预言机能让合约本身易受到攻击的例子,可见3。 ↩ ↩2
-
Since miners have some freedom to set the block timestamp, users of the oracle should be aware that these values may not correspond precisely to real-world times.由于矿工本身对于设置区块时间戳有一定的自由度,使用预言机的用户应该清楚获取到的时间戳不一定精准地与现实世界同步。 ↩ ↩2
-
The arithmetic mean price of asset A in terms of asset B over a given period is equal to the reciprocal of the harmonic mean price of asset B in terms of asset A over that period. If the contract measured the geometric mean price, then the prices would be the reciprocals of each other. However, the geometric mean TWAP is less commonly used, and is difficult to compute on Ethereum. 给定一段时间内,资产 A 相对于资产 B 的算术平均价格等于该段时间内资产 B 相对于资产 A 的调和平均价格的倒数。如果合约测量几何平均价格,则价格将互为倒数。然而,几何平均 TWAP 并不常用,并且在以太坊上难以计算。 ↩ ↩2
-
The theoretical upper bound of \(2^{112} - (1/2^{112})\) does not apply in this setting, as UQ112.112 numbers in Uniswap are always generated from the ratio of two uint112s. The largest such ratio is \(\frac{2^{112}-1}{1} = 2^{112}-1\) \(2^{112} - (1/2^{112})\) 的理论上限不适用于此设置,因为 Uniswap 中的 UQ112.112 数字始终由两个 uint112 的比率生成。这种最大比率是 \(\frac{2^{112}-1}{1} = 2^{112}-1\) ↩ ↩2
-
Because Uniswap charges fees on input amounts, the fee relative to the withdrawn amount is actually slightly higher: \(\frac{1}{1-0.003} - 1 = \frac{3}{997} \approx 0.3009203\%\) 由于 Uniswap 对输入金额收取费用,因此相对于提取金额的费用实际上略高:\(\frac{1}{1-0.003} - 1 = \frac{3}{997} \approx 0.3009203\%\) ↩ ↩2
-
We can use this invariant, which does not account for liquidity tokens that were minted or burned, because we know that fees are collected every time liquidity is deposited or withdrawn.
-
The signed message conforms to the EIP-712 standard, the same one used by meta transactions for tokens like CHAI and DAI. 签名消息符合 EIP-712 标准,与 CHAI 和 DAI 等代币的元交易使用的标准相同。 ↩ ↩2
-
Note that using the new architecture, \(x_{in}\) is not provided by the user; instead, it is calculated by measuring the contract’s balance after the callback, \(x_1\), and subtracting \((x_0 - x_{out})\) from it. This logic does not distinguish between assets sent into the contract before it is called and assets sent into the contract during the callback. \(y_{in}\) is computed in the same way, based on \(y_0\), \(y_1\), and \(y_{out}\).
请注意,使用新架构,\(x_{in}\) 不是由用户提供的;相反,它是通过测量回调后合约的余额 \(x_1\),并从中减去 \((x_0 - x_{out})\) 来计算的。此逻辑不区分在调用合约之前发送到合约的资产和在回调期间发送到合约的资产。\(y_{in}\) 以相同的方式计算,基于\(y_0\)、\(y_1\) 和 \(y_{out}\)。 ↩ ↩2
-
As described above in section 3.2, Uniswap v2 core does not use transferFrom(). 如上文 3.2 节所述,Uniswap v2 核心不使用 transferFrom()。 ↩ ↩2
-
This also reduces the likelihood of rounding errors, since the number of bits in the quantity of shares will be approximately the mean of the number of bits in the quantity of asset X in the reserves, and the number of bits in the quantity of asset Y in the reserves: \(\log_2 \sqrt{x \cdot y} = \frac{\log_2 x + \log_2 y}{2}\) 这也降低了舍入误差的可能性,因为份额数量的位数将大约是储备中资产 X 数量的位数和储备中资产 Y 数量的位数的平均值:\(\log_2 \sqrt{x \cdot y} = \frac{\log_2 x + \log_2 y}{2}\) ↩ ↩2
-
In theory, there are some cases where this burn could be non-negligible, such as pairs between high-value zero-decimal tokens. However, these pairs are a poor fit for Uniswap anyway, since rounding errors would make trading infeasible. 理论上,在某些情况下,这种销毁可能是不可忽略的,例如在高价值零小数代币之间的交易对。然而,这些交易对无论如何都不适合 Uniswap,因为舍入误差会使交易变得不可行。 ↩ ↩2
-
As of this writing, one of the highest-liquidity pairs on Uniswap v1 is the pair between ETH and WETH7. 截至撰写本文时,Uniswap v1 上流动性最高的交易对之一是 ETH 和 WETH 之间的交易对7。 ↩ ↩2
V3
Uniswap V3 白皮书
whitepaper 原 pdf 经过 minerU 转换
Uniswap v3 Core
March 2021
Hayden Adams hayden@uniswap.org
Noah Zinsmeister noah@uniswap.org
Moody Salem moody@uniswap.org
River Keefer river@uniswap.org
Dan Robinson dan@paradigm.xyz
ABSTRACT
Uniswap v3 is a noncustodial automated market maker implemented for the Ethereum Virtual Machine. In comparison to earlier versions of the protocol, Uniswap v3 provides increased capital efficiency and fine-tuned control to liquidity providers, improves the accuracy and convenience of the price oracle, and has a more flexible fee structure.
1 INTRODUCTION
Automated market makers (AMMs) are agents that pool liquidity and make it available to traders according to an algorithm [5]. Constant function market makers (CFMMs), a broad class of AMMs of which Uniswap is a member, have seen widespread use in the context of decentralized finance, where they are typically implemented as smart contracts that trade tokens on a permissionless blockchain [2].
CFMMs as they are implemented today are often capital inefficient. In the constant product market maker formula used by Uniswap v1 and v2, only a fraction of the assets in the pool are available at a given price. This is inefficient, particularly when assets are expected to trade close to a particular price at all times.
Prior attempts to address this capital efficiency issue, such as Curve [3] and YieldSpace [4], have involved building pools that use different functions to describe the relation between reserves. This requires all liquidity providers in a given pool to adhere to a single formula, and could result in liquidity fragmentation if liquidity providers want to provide liquidity within different price ranges.
In this paper, we present Uniswap v3, a novel AMM that gives liquidity providers more control over the price ranges in which their capital is used, with limited effect on liquidity fragmentation and gas inefficiency. This design does not depend on any shared assumption about the price behavior of the tokens. Uniswap v3 is based on the same constant product reserves curve as earlier versions [1], but offers several significant new features:
• Concentrated Liquidity: Liquidity providers (LPs) are given the ability to concentrate their liquidity by “bounding“ it within an arbitrary price range. This improves the pool’s capital efficiency and allows LPs to approximate their preferred reserves curve, while still being efficiently aggregated with the rest of the pool. We describe this feature in section 2 and its implementation in Section 6.
Flexible Fees: The swap fee is no longer locked at $0.30%$ . Rather, the fee tier for each pool (of which there can be multiple per asset pair) is set on initialization (Section 3.1). The initially supported fee tiers are $0.05%$ , $0.30%$ , and $1%$ . UNI governance is able to add additional values to this set.
• Protocol Fee Governance: UNI governance has more flexibility in setting the fraction of swap fees collected by the protocol (Section 6.2.2).
Improved Price Oracle: Uniswap v3 provides a way for users to query recent price accumulator values, thus avoiding the need to checkpoint the accumulator value at the exact beginning and end of the period for which a TWAP is being measured. (Section 5.1).
• Liquidity Oracle: The contracts expose a time-weighted average liquidity oracle (Section 5.3).
The Uniswap v2 core contracts are non-upgradeable by design, so Uniswap v3 is implemented as an entirely new set of contracts, available here. The Uniswap v3 core contracts are also non-upgradeable, with some parameters controlled by governance as described in Section 4.
2 CONCENTRATED LIQUIDITY
The defining idea of Uniswap v3 is that of concentrated liquidity: liquidity bounded within some price range.
In earlier versions, liquidity was distributed uniformly along the
$$ x\cdot y=k $$
reserves curve, where $x$ and $y$ are the respective reserves of two assets $\mathsf{X}$ and Y, and $k$ is a constant [1]. In other words, earlier versions were designed to provide liquidity across the entire price range $(0,\infty)$ . This is simple to implement and allows liquidity to be efficiently aggregated, but means that much of the assets held in a pool are never touched.
Having considered this, it seems reasonable to allow LPs to concentrate their liquidity to smaller price ranges than $(0,\infty)$ . We call liquidity concentrated to a finite range a position. A position only needs to maintain enough reserves to support trading within its range, and therefore can act like a constant product pool with larger reserves (we call these the virtual reserves) within that range.
Figure 1: Simulation of Virtual Liquidity
Specifically, a position only needs to hold enough of asset X to cover price movement to its upper bound, because upwards price movement1 corresponds to depletion of the X reserves. Similarly, it only needs to hold enough of asset Y to cover price movement to its lower bound. Fig. 1 depicts this relationship for a position on a range $[p_{a},p_{b}]$ and a current price $p_{c}\in[p_{a},p_{b}]$ . $x_{\mathrm{real}}$ and $y_{\mathrm{real}}$ denote the position’s real reserves.
When the price exits a position’s range, the position’s liquidity is no longer active, and no longer earns fees. At that point, its liquidity is composed entirely of a single asset, because the reserves of the other asset must have been entirely depleted. If the price ever reenters the range, the liquidity becomes active again.
The amount of liquidity provided can be measured by the value $L$ , which is equal to ${\sqrt{k}}.$ . The real reserves of a position are described by the curve:
$$ (x+{\frac{L}{\sqrt{p_{b}}}})(y+L\sqrt{p_{a}})=L^{2} $$
This curve is a translation of formula 2.1 such that the position is solvent exactly within its range (Fig. 2).
Figure 2: Real Reserves
Liquidity providers are free to create as many positions as they see fit, each on its own price range. In this way, LPs can approximate any desired distribution of liquidity on the price space (see Fig. 3 for a few examples). Moreover, this serves as a mechanism to let the market decide where liquidity should be allocated. Rational LPs can reduce their capital costs by concentrating their liquidity in a narrow band around the current price, and adding or removing tokens as the price moves to keep their liquidity active.
2.1 Range Orders
Positions on very small ranges act similarly to limit orders—if the range is crossed, the position flips from being composed entirely of one asset, to being composed entirely of the other asset (plus accrued fees). There are two differences between this range order and a traditional limit order:
• There is a limit to how narrow a position’s range can be. While the price is within that range, the limit order might be partially executed.
• When the position has been crossed, it needs to be withdrawn. If it is not, and the price crosses back across that range, the position will be traded back, effectively reversing the trade.
Figure 3: Example Liquidity Distributions
3 ARCHITECTURAL CHANGES
Uniswap v3 makes a number of architectural changes, some of which are necessitated by the inclusion of concentrated liquidity, and some of which are independent improvements.
3.1 Multiple Pools Per Pair
In Uniswap v1 and v2, every pair of tokens corresponds to a single liquidity pool, which applies a uniform fee of $0.30%$ to all swaps. While this default fee tier historically worked well enough for many tokens, it is likely too high for some pools (such as pools between two stablecoins), and too low for others (such as pools that include highly volatile or rarely traded tokens).
Uniswap v3 introduces multiple pools for each pair of tokens, each with a different swap fee. All pools are created by the same factory contract. The factory contract initially allows pools to be created at three fee tiers: $0.05%$ , $0.30%$ , and $1%$ . Additional fee tiers can be enabled by UNI governance.
3.2 Non-Fungible Liquidity
3.2.1 Non-Compounding Fees. Fees earned in earlier versions were continuously deposited in the pool as liquidity. This meant that liquidity in the pool would grow over time, even without explicit deposits, and that fee earnings compounded.
In Uniswap v3, due to the non-fungible nature of positions, this is no longer possible. Instead, fee earnings are stored separately and held as the tokens in which the fees are paid (see Section 6.2.2).
3.2.2 Removal of Native Liquidity Tokens. In Uniswap v1 and v2, the pool contract is also an ERC-20 token contract, whose tokens represent liquidity held in the pool. While this is convenient, it actually sits uneasily with the Uniswap v2 philosophy that anything that does not need to be in the core contracts should be in the periphery, and blessing one “canonical“ ERC-20 implementation discourages the creation of improved ERC-20 token wrappers. Arguably, the ERC-20 token implementation should have been in the periphery, as a wrapper on a single liquidity position in the core contract.
The changes made in Uniswap v3 force this issue by making completely fungible liquidity tokens impossible. Due to the custom liquidity provision feature, fees are now collected and held by the pool as individual tokens, rather than automatically reinvested as liquidity in the pool.
As a result, in v3, the pool contract does not implement the ERC-20 standard. Anyone can create an ERC-20 token contract in the periphery that makes a liquidity position more fungible, but it will have to have additional logic to handle distribution of, or reinvestment of, collected fees. Alternatively, anyone could create a periphery contract that wraps an individual liquidity position (including collected fees) in an ERC-721 non-fungible token.
4 GOVERNANCE
The factory has an owner, which is initially controlled by UNI tokenholders.2 The owner does not have the ability to halt the operation of any of the core contracts.
As in Uniswap v2, Uniswap v3 has a protocol fee that can be turned on by UNI governance. In Uniswap v3, UNI governance has more flexibility in choosing the fraction of swap fees that go to the protocol, and is able to choose any fraction $\textstyle{\frac{1}{N}}$ where $4\leq N\leq10$ or 0. This parameter can be set on a per-pool basis.
UNI governance also has the ability to add additional fee tiers. When it adds a new fee tier, it can also define the tickSpacing (see Section 6.1) corresponding to that fee tier. Once a fee tier is added to the factory, it cannot be removed (and the tickSpacing cannot be changed). The initial fee tiers and tick spacings supported are $0.05%$ (with a tick spacing of 10, approximately $0.10%$ between initializable ticks), $0.30%$ (with a tick spacing of 60, approximately $0.60%$ between initializable ticks), and $1%$ (with a tick spacing of 200, approximately $2.02%$ between ticks.
Finally, UNI governance has the power to transfer ownership to another address.
5 ORACLE UPGRADES
Uniswap v3 includes three significant changes to the time-weighted average price (TWAP) oracle that was introduced by Uniswap v2.
Most significantly, Uniswap v3 removes the need for users of the oracle to track previous values of the accumulator externally. Uniswap v2 requires users to checkpoint the accumulator value at both the beginning and end of the time period for which they wanted to compute a TWAP. Uniswap v3 brings the accumulator checkpoints into core, allowing external contracts to compute onchain TWAPs over recent periods without storing checkpoints of the accumulator value.
Another change is that instead of accumulating the sum of prices, allowing users to compute the arithmetic mean TWAP, Uniswap v3 tracks the sum of log prices, allowing users to compute the geometric mean TWAP.
Finally, Uniswap v3 adds a liquidity accumulator that is tracked alongside the price accumulator, which accumulates $\textstyle{\frac{1}{L}}$ for each second. This liquidity accumulator is useful for external contracts that want to implement liquidity mining on top of Uniswap v3. It can also be used by other contracts to inform a decision on which of the pools corresponding to a pair (see section 3.1) will have the most reliable TWAP.
5.1 Oracle Observations
As in Uniswap v2, Uniswap v3 tracks a running accumulator of the price at the beginning of each block, multiplied by the number of seconds since the last block.
A pool in Uniswap v2 stores only the most recent value of this price accumulator—that is, the value as of the last block in which a swap occurred. When computing average prices in Uniswap v2, it is the responsibility of the external caller to provide the previous value of the price accumulator. With many users, each will have to provide their own methodology for checkpointing previous values of the accumulator, or coordinate on a shared method to reduce costs. And there is no way to guarantee that every block in which the pool is touched will be reflected in the accumulator.
In Uniswap v3, the pool stores a list of previous values for the price accumulator (as well as the liquidity accumulator described in section 5.3). It does this by automatically checkpointing the accumulator value every time the pool is touched for the first time in a block, cycling through an array where the oldest checkpoint is eventually overwritten by a new one, similar to a circular buffer. While this array initially only has room for a single checkpoint, anyone can initialize additional storage slots to lengthen the array, extending to as many as 65,536 checkpoints. This imposes the one-time gas cost of initializing additional storage slots for this array on whoever wants this pair to checkpoint more slots.
The pool exposes the array of past observations to users, as well as a convenience function for finding the (interpolated) accumulator value at any historical timestamp within the checkpointed period.
5.2 Geometric Mean Price Oracle
Uniswap v2 maintains two price accumulators—one for the price of token0 in terms of token1, and one for the price of token1 in terms of token0. Users can compute the time-weighted arithmetic mean of the prices over any period, by subtracting the accumulator value at the beginning of the period from the accumulator at the end of the period, then dividing the difference by the number of seconds in the period. Note that accumulators for token0 and token1 are tracked separately, since the time-weighted arithmetic mean price of token0 is not equivalent to the reciprocal of the time-weighted arithmetic mean price of token1.
Using the time-weighted geometric mean price, as Uniswap v3 does, avoids the need to track separate accumulators for these ratios. The geometric mean of a set of ratios is the reciprocal of the geometric mean of their reciprocals. It is also easy to implement in Uniswap v3 because of its implementation of custom liquidity provision, as described in section 6. In addition, the accumulator can be stored in a smaller number of bits, since it tracks log $P$ rather than $P$ , and $\log P$ can represent a wide range of prices with consistent precision. Finally, there is a theoretical argument that the timeweighted geometric mean price should be a truer representation of the average price.
Instead of tracking the cumulative sum of the price $P$ , Uniswap v3 accumulates the cumulative sum of the current tick index $(l o g_{1.0001}P$ , the logarithm of price for base 1.0001, which is precise up to 1 basis point). The accumulator at any given time is equal to the sum of $l o g_{1.0001}(P)$ for every second in the history of the contract:
$$ a_{t}=\sum_{i=1}^{t}\log_{1.0001}\left(P_{i}\right) $$
We want to estimate the geometric mean time-weighted average price $(p_{t_{1},t_{2}})$ over any period $t_{1}$ to $t_{2}$ .
$$ P_{t_{1},t_{2}}=\left(\prod_{i=t_{1}}^{t_{2}}P_{i}\right)^{\frac{1}{t_{2}-t_{1}}} $$
To compute this, you can lo«ok at t¬he accumulator’s value at $t_{1}$ and at $t_{2}$ , subtract the first value from the second, divide by the number of seconds elapsed, and compute $1.0001^{x}$ to compute the time weighted geometric mean price.
$$ \log_{1.0001}\left(P_{t_{1},t_{2}}\right)=\frac{\sum_{i=t_{1}}^{t_{2}}\log_{1.0001}\left(P_{i}\right)}{t_{2}-t_{1}} $$
$$ \log_{1.0001}\left(P_{t_{1},t_{2}}\right)=\frac{a_{t_{2}}-a_{t_{1}}}{t_{2}-t_{1}} $$
$$ \begin{array}{r}{P_{t_{1},t_{2}}=1.0001^{\frac{a_{t_{2}}-a_{t_{1}}}{t_{2}-t_{1}}}}\end{array} $$
5.3 Liquidity Oracle
In addition to the seconds-weighted accumulator of $\log_{1.0001}{p r i c e}$ Uniswap v3 also tracks a seconds-weighted accumulator of $\textstyle{\frac{1}{L}}$ (the reciprocal of the virtual liquidity currently in range) at the beginning of each block: secondsPerLiquidityCumulative $(s_{p l})$ .
This can be used by external liquidity mining contracts to fairly allocate rewards. If an external contract wants to distribute rewards at an even rate of $R$ tokens per second to all active liquidity in the contract, and a position with $L$ liquidity was active from $t_{0}$ to $t_{1}$ , then its rewards for that period would be $R{\cdot}\mathrm{L}{\cdot}(s_{p l}(t_{1})-s_{p l}(t_{0}))$ .
In order to extend this so that concentrated liquidity is rewarded only when it is in range, Uniswap v3 stores a computed checkpoint based on this value every time a tick is crossed, as described in section 6.3.
This accumulator can also be used by on-chain contracts to make their oracles stronger (such as by evaluating which fee-tier pool to use the oracle from).
6 IMPLEMENTING CONCENTRATED LIQUIDITY
The rest of this paper describes how concentrated liquidity provision works, and gives a high-level description of how it is implemented in the contracts.
6.1 Ticks and Ranges
To implement custom liquidity provision, the space of possible prices is demarcated by discrete ticks. Liquidity providers can provide liquidity in a range between any two ticks (which need not be adjacent).
Each range can be specified as a pair of signed integer tick indices: a lower tick $(i_{l})$ and an upper tick $(i_{u})$ . Ticks represent prices at which the virtual liquidity of the contract can change. We will assume that prices are always expressed as the price of one of the tokens—called token0—in terms of the other token—token1. The assignment of the two tokens to token0 and token1 is arbitrary and does not affect the logic of the contract (other than through possible rounding errors).
Conceptually, there is a tick at every price $\boldsymbol{p}$ that is an integer power of 1.0001. Identifying ticks by an integer index $i$ , the price at each is given by:
$$ p(i)=1.0001^{i} $$
This has the desirable property of each tick being a . $01%$ (1 basis point) price movement away from each of its neighboring ticks.
For technical reasons explained in 6.2.1, however, pools actually track ticks at every square root price that is an integer power of $\sqrt{1.0001}$ . Consider the above equation, transformed into square root price space:
$$ {\sqrt{p}}(i)={\sqrt{1.0001}}^{i}=1.0001^{\frac{i}{2}} $$
As an example, $\sqrt{p}(0)-$ the square root price at tick 0—is 1, ${\sqrt{p}}(1)$ is √1.0001 ≈ 1.00005, and √𝑝 (−1) is √1.10001 $\begin{array}{r}{\frac{1}{\sqrt{1.0001}}\approx0.99995}\end{array}$ .
When liquidity is added to a range, if one or both of the ticks is not already used as a bound in an existing position, that tick is initialized.
Not every tick can be initialized. The pool is instantiated with a parameter, tickSpacing $(t_{s})$ ; only ticks with indexes that are divisible by tickSpacing can be initialized. For example, if tickSpacing is 2, then only even ticks $(…4,-2,0,2,4…)$ can be initialized. Small choices for tickSpacing allow tighter and more precise ranges, but may cause swaps to be more gas-intensive (since each initialized tick that a swap crosses imposes a gas cost on the swapper).
Whenever the price crosses an initialized tick, virtual liquidity is kicked in or out. The gas cost of an initialized tick crossing is constant, and is not dependent on the number of positions being kicked in or out at that tick.
Ensuring that the right amount of liquidity is kicked in and out of the pool when ticks are crossed, and ensuring that each position earns its proportional share of the fees that were accrued while it was within range, requires some accounting within the pool. The pool contract uses storage variables to track state at a global (per-pool) level, at a per-tick level, and at a per-position level.
6.2 Global State
The global state of the contract includes seven storage variables relevant to swaps and liquidity provision. (It has other storage variables that are used for the oracle, as described in section 5.)
Table 1: Global State
| Type | Variable Name | Notation |
| uint128 | liquidity | L |
| uint160 | sqrtPriceX96 | √P |
| int24 | tick | ic |
| uint256 | feeGrowthGlobal0X128 | fg,0 |
| uint256 | feeGrowthGlobal1X128 | fg,1 |
| uint128 | protocolFees.token0 | fp,0 |
| uint128 | protocolFees.token1 | fp,1 |
6.2.1 Price and Liquidity. In Uniswap v2, each pool contract tracks the pool’s current reserves, $x$ and $y$ . In Uniswap v3, the contract could be thought of as having virtual reserves—values for $x$ and $y$ that allow you to describe the contract’s behavior (between two adjacent ticks) as if it followed the constant product formula.
Instead of tracking those virtual reserves, however, the pool contract tracks two different values: liquidity $(L)$ and sqrtPrice $({\sqrt{P}})$ . These could be computed from the virtual reserves with the following formulas:
$$ L={\sqrt{x y}} $$
$$ {\sqrt{P}}={\sqrt{\frac{y}{x}}} $$
Conversely, these values could be used to compute the virtual reserves:
$$ x={\frac{L}{\sqrt{P}}} $$
$$ y=L\cdot{\sqrt{P}} $$
Using $L$ and $\sqrt{P}$ is convenient because only one of them changes at a time. Price (and thus $\sqrt{P}$ ) changes when swapping within a tick; liquidity changes when crossing a tick, or when minting or burning liquidity. This avoids some rounding errors that could be encountered if tracking virtual reserves.
You may notice that the formula for liquidity (based on virtual reserves) is similar to the formula used to initialize the quantity of liquidity tokens (based on actual reserves) in Uniswap v2. before any fees have been earned. In some ways, liquidity can be thought of as virtual liquidity tokens.
Alternatively, liquidity can be thought of as the amount that token1 reserves (either actual or virtual) changes for a given change in $\sqrt{P}$ :
$$ L={\frac{\Delta Y}{\Delta{\sqrt{P}}}} $$
We track $\sqrt{P}$ instead of $P$ to take advantage of this relationship, and to avoid having to take any square roots when computing swaps, as described in section 6.2.3.
The global state also tracks the current tick index as tick $(i_{c})$ , a signed integer representing the current tick (more specifically, the nearest tick below the current price). This is an optimization (and a way of avoiding precision issues with logarithms), since at any time, you should be able to compute the current tick based on the current sqrtPrice. Specifically, at any given time, the following equation should be true:
$$ i_{c}=\left\lfloor\log_{\sqrt{1.0001}}\sqrt{P}\right\rfloor $$
6.2.2 Fees. Each pool is initialized with an immutable value, fee $(\gamma)$ , representing the fee paid by swappers in units of hundredths of a basis point $(0.0001%)$ .
It also tracks the current protocol fee, $\phi$ (which is initialized to zero, but can changed by UNI governance). This number gives you the fraction of the fees paid by swappers that currently goes to the protocol rather than to liquidity providers. $\phi$ only has a limited set of permitted values: $0,1/4,1/5,1/6,1/7,1/8,1/9$ , or $1/10$ .
The global state also tracks two numbers: feeGrowthGlobal0 $(f_{g,0})$ and feeGrowthGlobal1 $(f_{g,1})$ . These represent the total amount of fees that have been earned per unit of virtual liquidity (𝐿), over the entire history of the contract. You can think of them as the total amount of fees that would have been earned by 1 unit of unbounded liquidity that was deposited when the contract was first initialized. They are stored as fixed-point unsigned $128\mathrm{x}128$ numbers. Note that in Uniswap v3, fees are collected in the tokens themselves rather than in liquidity, for reasons explained in section 3.2.1.
Finally, the global state tracks the total accumulated uncollected protocol fee in each token, protocolFees0 $(f_{p,0})$ and protocolFees1 $(f_{p,1})$ . This is an unsigned uint128. The accumulated protocol fees can be collected by UNI governance, by calling the collectProtocol function.
6.2.3 Swapping Within a Single Tick. For small enough swaps, that do not move the price past a tick, the contracts act like an $x\cdot y=k$ pool.
Suppose $\gamma$ is the fee, i.e., 0.003, and $y_{i n}$ as the amount of token1 sent in.
First, feeGrowthGlobal1 and protocolFees1 are incremented:
$$ \Delta f_{g,1}=y_{i n}\cdot\gamma\cdot(1-\phi) $$
$$ \Delta f_{\boldsymbol{p},1}=\boldsymbol{y}_{i n}\cdot\boldsymbol{\gamma}\cdot\boldsymbol{\phi} $$
$\Delta y$ is the increase in $y$ (after the fee is taken out).
$$ \Delta y=y_{i n}\cdot(1-\gamma) $$
If you used the computed virtual reserves $(x\operatorname{and}y)$ for the token0 and token1 balances, then this formula could be used to find the amount of token0 sent out:
$$ x_{e n d}=\frac{x\cdot y}{y+\Delta y} $$
But remember that in v3, the contract actually tracks liquidity $(L)$ and square root of price $({\sqrt{P}})$ instead of $x$ and $y$ . We could compute $x$ and $y$ from those values, and then use those to calculate the execution price of the trade. But it turns out that there are simple formulas that describe the relationship between $\Delta\sqrt{P}$ and $\Delta y$ , for a given $L$ (which can be derived from formula 6.7):
$$ \Delta{\sqrt{P}}={\frac{\Delta y}{L}} $$
$$ \Delta y=\Delta{\sqrt{P}}\cdot L $$
There are also simple formulas that describe the relationship between $\Delta{\frac{1}{\sqrt{P}}}$ and $\Delta x$ :
$$ \Delta\frac{1}{\sqrt{P}}=\frac{\Delta x}{L} $$
$$ \Delta x=\Delta{\frac{1}{\sqrt{P}}}\cdot L $$
When swapping one token for the other, the pool contract can first compute the new $\sqrt{P}$ using formula 6.13 or 6.15, and then can compute the amount of token0 or token1 to send out using formula 6.14 or 6.16.
These formulas will work for any swap that does not push $\sqrt{P}$ past the price of the next initialized tick. If the computed $\Delta\sqrt{P}$ would cause $\sqrt{P}$ to move past that next initialized tick, the contract must only cross up to that tick—using up only part of the swap—and then cross the tick, as described in section 6.3.1, before continuing with the rest of the swap.
6.2.4 Initialized Tick Bitmap. If a tick is not used as the endpoint of a range with any liquidity in it—that is, if the tick is uninitialized—then that tick can be skipped during swaps.
As an optimization to make finding the next initialized tick more efficient, the pool tracks a bitmap tickBitmap of initialized ticks. The position in the bitmap that corresponds to the tick index is set to 1 if the tick is initialized, and 0 if it is not initialized.
When a tick is used as an endpoint for a new position, and that tick is not currently used by any other liquidity, the tick is initialized, and the corresponding bit in the bitmap is set to 1. An initialized tick can become uninitialized again if all of the liquidity for which it is an endpoint is removed, in which case that tick’s position on the bitmap is zeroed out.
6.3 Tick-Indexed State
The contract needs to store information about each tick in order to track the amount of net liquidity that should be added or removed when the tick is crossed, as well as to track the fees earned above and below that tick.
Figure 4: Swap Control Flow
The contract stores a mapping from tick indexes (int24) to the following seven values:
Table 2: Tick-Indexed State
| Type | Variable Name | Notation |
| int128 | liquidityNet | △L |
| uint128 | liquidityGross | Lg |
| uint256 | feeGrowthOutside0X128 | fo,0 |
| uint256 | feeGrowth0utside1X128 | fo,1 |
| uint256 | secondsOutside | So |
| uint256 | tickCumulativeOutside | io |
| uint256 | secondsPerLiquidityOutsideX128 | Slo |
Each tick tracks $\Delta L$ , the total amount of liquidity that should be kicked in or out when the tick is crossed. The tick only needs to track one signed integer: the amount of liquidity added (or, if negative, removed) when the tick is crossed going left to right. This value does not need to be updated when the tick is crossed (but only when a position with a bound at that tick is updated).
We want to be able to uninitialize a tick when there is no longer any liquidity referencing that tick. Since $\Delta L$ is a net value, it’s necessary to track a gross tally of liquidity referencing the tick, liquidityGross. This value ensures that even if net liquidity at a tick is 0, we can still know if a tick is referenced by at least one underlying position or not, which tells us whether to update the tick bitmap.
feeGrowthOutside{0,1} are used to track how many fees were accumulated within a given range. Since the formulas are the same for the fees collected in token0 and token1, we will omit that subscript for the rest of this section.
You can compute the fees earned per unit of liquidity in token 0 above $(f_{a})$ and below $(f_{b})$ a tick 𝑖 with a formula that depends on whether the price is currently within or outside that range—that is, whether the current tick index $i_{c}$ is greater than or equal to $i$ :
$$ \begin{array}{r l}&{f_{a}(i)=\left{\begin{array}{l l}{f_{g}-f_{o}(i)}&{i_{c}\geq i}\ {f_{o}(i)}&{i_{c}<i}\end{array}\right.}\ &{f_{b}(i)=\left{\begin{array}{l l}{f_{o}(i)}&{i_{c}\geq i}\ {f_{g}-f_{o}(i)}&{i_{c}<i}\end{array}\right.}\end{array} $$
We can use these functions to compute the total amount of cumulative fees per share $f_{r}$ in the range between two ticks $-\mathtt{a}$ lower tick $i_{l}$ and an upper tick $i_{u}$ :
$$ f_{r}=f_{g}-f_{b}(i_{l})-f_{a}(i_{u}) $$
$f_{o}$ needs to be updated each time the tick is crossed. Specifically, as a tick $i$ is crossed in either direction, its $f_{o}$ (for each token) should be updated as follows:
$$ f_{o}(i):=f_{g}-f_{o}(i) $$
$f_{o}$ is only needed for ticks that are used as either the lower or upper bound for at least one position. As a result, for efficiency, $f_{o}$ is not initialized (and thus does not need to be updated when crossed) until a position is created that has that tick as one of its bounds. When $f_{o}$ is initialized for a tick $i$ , the value—by convention—is chosen as if all of the fees earned to date had occurred below that tick:
$$ f_{o}:=\left{{f}{g}\quad i{c}\geq i\atop0\quad i_{c}<i\right. $$
Note that since $f_{o}$ values for different ticks could be initialized at different times, comparisons of the $f_{o}$ values for different ticks are not meaningful, and there is no guarantee that values for $f_{o}$ will be consistent. This does not cause a problem for per-position accounting, since, as described below, all the position needs to know is the growth in $g$ within a given range since that position was last touched.
Finally, the contract also stores secondsOutside $(s_{o})$ , secondsPerLiquidityOutside, and tickCumulativeOutside for each tick. These values are not used within the contract, but are tracked for the benefit of external contracts that need more finegrained information about the pool’s behavior (for purposes like liquidity mining).
All three of these indexes work similarly to the fee growth indexes described above. But where the feeGrowthOutside ${\emptyset,1}$ indexes track feeGrowthGlobal{0,1}, the secondsOutside index tracks seconds (that is, the current timestamp), secondsPerLiquidityOutside tracks the $1/L$ accumulator (secondsPerLiquidityCumulative) described in section 5.3, and tickCumulativeOutside tracks the $\log_{1.0001}P$ accumulator described in section 5.2.
For example, the seconds spent above $(s_{a})$ and below $(s_{b})$ a given tick is computed differently based on whether the current price is within that range, and the seconds spent within a range $(s_{r})$ can be computed using the values of $s_{a}$ and $s_{b}$ :
$$ \begin{array}{l l}{t_{a}(i)=\left{\begin{array}{l l}{t-t_{o}(i)}&{i_{c}\geq i}\ {t_{o}(i)}&{i_{c}<i}\end{array}\right.}\ {t_{b}(i)=\left{\begin{array}{l l}{t_{o}(i)}&{i_{c}\geq i}\ {t-t_{o}(i)}&{i_{c}<i}\end{array}\right.}\end{array} $$
$$ t_{r}(i_{l},i_{u})=t-t_{b}(i_{l})-t_{a}(i_{u}) $$
The number of seconds spent within a range between two times $t_{1}$ and $t_{2}$ can be computed by recording the value of $s_{r}(i_{l},i_{u})$ at $t_{1}$ and at $t_{2}$ , and subtracting the former from the latter.
Like $f_{o},s_{o}$ does not need to be tracked for ticks that are not on the edge of any position. Therefore, it is not initialized until a position is created that is bounded by that tick. By convention, it is initialized as if every second since the Unix timestamp 0 had been spent below that tick:
$$ t_{o}(i):=\left{\begin{array}{l l}{t}&{i_{c}\geq i}\ {0}&{i_{c}<i}\end{array}\right. $$
As with $f_{o}$ values, $t_{o}$ values are not meaningfully comparable across different ticks. $t_{o}$ is only meaningful in computing the number of seconds that liquidity was within some particular range between some defined start time (which must be after $t_{o}$ was initialized for both ticks) and some end time.
6.3.1 Crossing a Tick. As described in section 6.2.3, Uniswap v3 acts like it obeys the constant product formula when swapping between initialized ticks. When a swap crosses an initialized tick, however, the contract needs to add or remove liquidity, to ensure that no liquidity provider is insolvent. This means the $\Delta L$ is fetched from the tick, and applied to the global $L$ .
The contract also needs to update the tick’s own state, in order to track the fees earned (and seconds spent) within ranges bounded by this tick. The feeGrowthOutside ${\boldsymbol{\mathfrak{0}},1}$ and secondsOutside values are updated to both reflect current values, as well as the proper orientation relative to the current tick:
$$ f_{o}:=f_{g}-f_{o} $$
$$ t_{o}:=t-t_{o} $$
Once a tick is crossed, the swap can continue as described in section 6.2.3 until it reaches the next initialized tick.
6.4 Position-Indexed State
The contract has a mapping from user (an address), lower bound (a tick index, int24), and upper bound (a tick index, int24) to a specific Position struct. Each Position tracks three values:
Table 3: Position-Indexed State
| Type | Variable Name Notation |
| uint128 | liquidity |
| uint256 | feeGrowthInside0LastX128 fr,o(to) |
| uint256 | feeGrowthInside1LastX128 fr,1(to) |
liquidity (𝑙) means the amount of virtual liquidity that the position represented the last time this position was touched. Specifically, liquidity could be thought of as $\sqrt{x\cdot y}$ , where $x$ and $y$ are the respective amounts of virtual token0 and virtual token1 that this liquidity contributes to the pool at any time that it is within range. Unlike pool shares in Uniswap v2 (where the value of each share grows over time), the units for liquidity do not change as fees are accumulated; it is always measured as $\sqrt{x\cdot y}$ , where $x$ and $y$ are quantities of token0 and token1, respectively.
This liquidity number does not reflect the fees that have been accumulated since the contract was last touched, which we will call uncollected fees. Computing these uncollected fees requires additional stored values on the position, feeGrowthInside0Last $(f_{r,0}(t_{0}))$ and feeGrowthInside1Last $(f_{r,1}(t_{0}))$ , as described below.
6.4.1 setPosition. The setPosition function allows a liquidity provider to update their position.
Two of the arguments to setPosition —lowerTick and upperTick— when combined with the msg.sender, together specify a position.
The function takes one additional parameter, liquidityDelta, to specify how much virtual liquidity the user wants to add or (if negative) remove.
First, the function computes the uncollected fees $(f_{u})$ that the position is entitled to, in each token.7 The amount collected in fees is credited to the user and netted against the amount that they would send in or out for their virtual liquidity deposit.
To compute uncollected fees of a token, you need to know how much $f_{r}$ for the position’s range (calculated from the range’s $i_{l}$ and $i_{r}$ as described in section 6.3) has grown since the last time fees were collected for that position. The growth in fees in a given range per unit of liquidity over between times $t_{0}$ and $t_{1}$ is simply $f_{r}(t_{1})-f_{r}(t_{0})$ (where $f_{r}(t_{0})$ is stored in the position as feeGrowthInside{0,1}Last, and $f_{r}(t_{1})$ can be computed from the current state of the ticks). Multiplying this by the position’s liquidity gives us the total uncollected fees in token 0 for this position:
$$ f_{u}=l\cdot(f_{r}(t_{1})-f_{r}(t_{0})) $$
Then, the contract updates the position’s liquidity by adding liquidityDelta. It also adds liquidityDelta to the liquidityNet value for the tick at the bottom end of the range, and subtracts it from the liquidityNet at the upper tick (to reflect that this new liquidity would be added when the price crosses the lower tick going up, and subtracted when the price crosses the upper tick going up). If the pool’s current price is within the range of this position, the contract also adds liquidityDelta to the contract’s global liquidity value.
Finally, the pool transfers tokens from (or, if liquidityDelta is negative, to) the user, corresponding to the amount of liquidity burned or minted.
The amount of token0 $(\Delta X)$ or token1 $(\Delta Y)$ that needs to be deposited can be thought of as the amount that would be sold from the position if the price were to move from the current price $(P)$ to the upper tick or lower tick (for token0 or token1, respectively). These formulas can be derived from formulas 6.14 and 6.16, and depend on whether the current price is below, within, or above the range of the position:
$$ \begin{array}{l l}{\Delta Y=\left{\begin{array}{l l}{0}&{i_{c}<i_{l}}\ {\Delta L\cdot(\sqrt{P}-\sqrt{P(i_{l})})}&{i_{l}\leq i_{c}<i_{u}}\ {\Delta L\cdot(\sqrt{P(i_{u})}-\sqrt{P(i_{l})})}&{i_{c}\geq i_{u}}\end{array}\right.}\ {\Delta X=\left{\begin{array}{l l}{\Delta L\cdot(\frac{1}{\sqrt{P(i_{l})}}-\frac{1}{\sqrt{P(i_{u})}})}&{i_{c}<i_{l}}\ {\Delta L\cdot(\frac{1}{\sqrt{P}}-\frac{1}{\sqrt{P(i_{u})}})}&{i_{l}\leq i_{c}<i_{u}}\ {0}&{i_{c}\geq i_{u}}\end{array}\right.}\end{array} $$
REFERENCES
[1] Hayden Adams, Noah Zinsmeister, and Dan Robinson. 2020. Uniswap v2 Core. Retrieved Feb 24, 2021 from https://uniswap.org/whitepaper.pdf
[2] Guillermo Angeris and Tarun Chitra. 2020. Improved Price Oracles: Constant Function Market Makers. In Proceedings of the 2nd ACM Conference on Advances in Financial Technologies (AFT ’20). Association for Computing Machinery, New York, NY, United States, 80–91. https://doi.org/10.1145/3419614.3423251
[3] Michael Egorov. 2019. StableSwap - Efficient Mechanism for Stablecoin Liquidity. Retrieved Feb 24, 2021 from https://www.curve.fi/stableswap-paper.pdf
[4] Allan Niemerg, Dan Robinson, and Lev Livnev. 2020. YieldSpace: An Automated Liquidity Provider for Fixed Yield Tokens. Retrieved Feb 24, 2021 from https: //yield.is/YieldSpace.pdf
[5] Abraham Othman. 2012. Automated Market Making: Theory and Practice. Ph.D. Dissertation. Carnegie Mellon University.
DISCLAIMER
This paper is for general information purposes only. It does not constitute investment advice or a recommendation or solicitation to buy or sell any investment and should not be used in the evaluation of the merits of making any investment decision. It should not be relied upon for accounting, legal or tax advice or investment recommendations. This paper reflects current opinions of the authors and is not made on behalf of Uniswap Labs, Paradigm, or their affiliates and does not necessarily reflect the opinions of Uniswap Labs, Paradigm, their affiliates or individuals associated with them. The opinions reflected herein are subject to change without being updated.
安全与审计
智能合约部署后具备不可篡改性与资金直连性,其安全保障依赖于完备的威胁建模、代码防御范式与严格的审计流程。
本专题将以太坊安全体系划分为两个层次进行系统剖析:
目录索引
1. 基础漏洞模型
梳理单合约内部由于逻辑校验缺失、EVM 底层调用控制流转移或算术回绕导致的经典漏洞形态:
- 重入攻击:经典单函数回调与检查-生效-交互(CEI)模式。
- 权限控制:所有者管理、修饰器权限校验及常见越权缺陷。
- DoS 攻击:无界循环 Gas 耗尽与转账回滚拒绝服务。
- 整数溢出:Solidity 算术回绕机理与安全数学库。
- 授权竞争:ERC-20
approve机制的前置竞态条件。 - 预言机操纵与闪电贷:DEX 现货流动性冲击与瞬时价格操纵。
- 签名滥用与重放:签名重放、延展性与 EIP-712 结构化签名基础。
- 可升级代理漏洞:存储槽重叠冲突与初始化入口防护。
2. 进阶机制与复合风险
剖析现代 DeFi 生产环境中跨协议交互、只读中间态暴露、语言编译器底层缺陷以及最新 EVM 特性(如 EIP-1153 瞬态存储)引发的深度攻防:
- 重入攻击进阶:只读重入(Read-Only Reentrancy)、跨合约状态未同步与编译器重入锁失效。
- 权限控制进阶:
tx.origin上下文陷阱、未保护实现合约劫持与瞬态存储槽鉴权穿透。 - DoS 进阶:EIP-150 63/64 Gas Griefing、Return Bomb 内存二次方爆炸与强制注资不变式破坏。
- 整数与精度进阶:
unchecked边界溢出、除法截断舍入差与 ERC-4626 金库通胀攻击。 - 授权机制进阶:无限授权系统性敞口、USDT 强制归零约束与 Permit2 集中授权。
- 预言机操纵进阶:广义预言机判定准则、TWAP 操纵成本量化模型与 Chainlink 降级防护。
- 签名验证进阶:EIP-712 域分隔符动态缓存安全、ERC-1271 合约钱包验签与伪 Permit 接口漏洞。
- 可升级代理进阶:EIP-1967 标准伪随机槽、ERC-7201 命名空间隔离与 Nomad 零哈希穿透复盘。
智能合约安全基础模型
本章梳理以太坊智能合约开发中的经典漏洞模型、底层触发机理与基础防护模式。
在以太坊虚拟机(EVM)与 Solidity 开发早期,大多数严重安全事件源于代码层面的边界校验疏漏、不安全的外部调用以及基本的算术假设失效。本部分重点解构 8 类经典漏洞的单点复现与通用防御:
- 重入攻击:单函数内状态更新滞后于底层
call调用,通过递归回调抽干合约资金。 - 权限控制:未配置访问控制修饰器、函数修饰器执行流反转或特权密钥泄露。
- DoS 攻击:无界循环耗尽 Gas、外部转账失败阻断关键业务流转(Push 模式缺陷)。
- 整数溢出:Solidity 0.8 之前的算术回绕模型及 SafeMath 的防护边界。
- 授权竞争:ERC-20
approve接口在广播修改额度时的内存池前置抢跑利用。 - 预言机操纵与闪电贷:依赖 DEX 瞬时现货储备作为定价依据,遭闪电贷大额 Swap 破坏抵押品估值。
- 签名滥用与重放:缺少 Nonce、缺失 ChainId 隔离、椭圆曲线签名延展性(Malleability)利用。
- 可升级代理漏洞:实现合约与代理合约存储槽位直接重叠、未受保护的公开初始化函数。
更深层次的跨合约中间态暴露、EIP-1153 瞬态存储鉴权、ERC-4626 金库通胀与编译器底层缺陷,参见 进阶机制与复合风险。
重入攻击 (Reentrancy Attack)
在区块链和去中心化应用 (DApps) 的世界中,智能合约扮演着至关重要的角色。它们以代码的形式定义了资产的管理和逻辑的执行,但在其强大功能背后,也潜藏着安全风险。重入攻击 (Reentrancy Attack) 是以太坊等区块链平台上最著名且最具破坏性的智能合约漏洞之一。理解其原理、影响和防御方法对于开发者和用户都至关重要。
什么是重入攻击?
重入攻击是一种利用智能合约在与外部合约交互后,但在完成内部状态更新之前,允许外部(通常是恶意的)合约重新调用自身函数的漏洞。简单来说,它允许攻击者在合约完成一次操作的完整流程之前,再次触发该操作,从而可能导致非预期的状态改变,例如重复提取资金。
重入攻击的经典场景:The DAO 事件
重入攻击最为人所知的案例莫过于 2016 年发生的 The DAO (Decentralized Autonomous Organization) 事件。The DAO 是一个基于以太坊的去中心化风险投资基金,其智能合约存在重入漏洞。攻击者利用该漏洞,在 The DAO 合约向其发送 ETH 的过程中,通过精心构造的恶意合约多次重新调用 The DAO 的提款函数,最终窃取了约 360 万枚 ETH(当时价值约 5000 万美元)。
重入攻击的技术原理
理解重入攻击的关键在于把握智能合约执行的顺序以及外部调用可能带来的影响。当合约 A 调用另一个合约 B 的函数时,控制权会暂时转移到合约 B。如果合约 B 在执行过程中又回调(call back)合约 A 的某个函数,这就为重入创造了机会。
如果合约 A 在发起对合约 B 的调用后,先执行了外部调用,然后再更新内部状态(例如记录已发送的金额),那么恶意合约 B 在回调合约 A 时,合约 A 的状态可能仍然处于“未完成”的状态。攻击者可以利用这个时间差,再次调用合约 A 的关键函数(例如提款),因为此时合约 A 仍然认为之前的操作尚未完成,从而允许重复执行。
一个简化的代码示例 (存在重入漏洞):
可以直接放入 remix 测试
pragma solidity ^0.6.2;
contract VulnerableContract {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 存在漏洞:先进行外部调用,后更新状态
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= _amount;
}
}
contract AttackerContract {
VulnerableContract public target;
constructor(VulnerableContract _target) public {
target = _target;
}
function attack() public payable {
target.deposit{value: 1 ether}(); // 存入一些 ETH 以触发漏洞
target.withdraw(1 ether); // 触发提款,控制权转移到 AttackerContract 的 fallback 函数
}
fallback() external payable {
if (address(target).balance >= 1 ether) {
target.withdraw(1 ether); // 再次调用提款函数,发生重入
}
}
}
在上面的例子中,VulnerableContract 的 withdraw 函数在向调用者发送 ETH 之后才更新 balances。恶意合约 AttackerContract 在收到第一次 ETH 时,其 fallback 函数被触发,它可以再次调用 VulnerableContract 的 withdraw 函数。由于 VulnerableContract 尚未更新其 balances,它会再次认为 AttackerContract 拥有足够的余额进行提款,从而导致攻击者可以提取超出其应得的资金。
重入攻击的类型
重入攻击可以分为以下几种类型:
- 外部函数调用重入: 如上述示例,攻击者通过回调函数重新进入原始合约。
- 跨合约重入: 攻击者利用一个合约调用另一个合约,而第二个合约又回调第一个合约的情况。
- 多重重入: 攻击者可以进行多次嵌套的重入调用,进一步扩大损失。
如何防御重入攻击?
为了防止重入攻击,开发者应该遵循以下最佳实践:
-
Checks-Effects-Interactions 模式: 这是最推荐的防御方法。在与外部合约交互之前,完成所有必要的检查(例如余额是否足够),然后更新合约的内部状态,最后再进行外部调用。
function withdraw(uint256 _amount) public { require(balances[msg.sender] >= _amount, "Insufficient balance"); // 先更新状态 balances[msg.sender] -= _amount; // 再进行外部调用 (bool success, ) = msg.sender.call{value: _amount}(""); require(success, "Transfer failed"); } -
互斥锁 (Mutex) 或状态变量: 使用状态变量或修饰器来限制关键函数在同一时间只能被一个调用者执行,防止在外部调用返回之前再次进入。
bool private withdrawing; modifier nonReentrant() { require(!withdrawing, "ReentrancyGuard: reentrant call"); withdrawing = true; _; withdrawing = false; } function withdraw(uint256 _amount) public nonReentrant { // ... (提款逻辑) ... } -
使用
transfer()或send()(谨慎使用): 这两种方法在发送 ETH 时会限制外部调用的 Gas 量,通常不足以进行深度的重入攻击。然而,它们也存在一些限制(例如 Gas 限制可能在未来发生变化,且不适用于所有类型的外部调用),因此不应作为主要的防御手段,而应与其他更可靠的方法结合使用。 -
OpenZeppelin 的
ReentrancyGuard库: 这是一个经过广泛测试和社区认可的库,提供了@nonReentrant修饰器,可以轻松地为关键函数添加重入保护。
结论
重入防护依赖外部调用窗口中的状态一致性。CEI、共享互斥锁与回调权限分别约束记账顺序、重复进入和调用主体,仍需覆盖跨函数及跨合约路径。
进阶(跨函数 / 只读 / 编译器锁 / EIP-1153):重入攻击进阶
权限控制问题
权限管理不当的严重性
智能合约中的权限管理问题本质上源于对关键功能访问控制的不严谨设计或实现错误。这类漏洞可能允许未经授权的用户执行本应受限的操作,如无限制铸造代币、转移用户资产或修改合约核心逻辑。
典型案例包括管理员账户被过度赋权,例如一个简单的代币合约中,管理员可以无限制地铸造新代币并将其转移到任意账户。如果管理员私钥泄露或初始设置错误,攻击者便可利用这一点无限增发代币,导致严重的通胀和经济系统崩溃。
更危险的是,某些合约中存在“后门“函数,允许特权账户(如 owner 或 admin)直接销毁或转移任意用户的资产。安全研究人员在对大量合约审计中发现,这类问题相当普遍,包括burn()、burnFrom()、destroyTokens()等函数都可能被滥用。例如,某些合约中的burnFrom()函数允许合约所有者销毁任意地址的代币,而melt()函数则允许 CFO(首席财务官角色)执行类似操作。
常见权限漏洞模式
1. 过度集中的权限控制
许多合约采用单一管理员模式,将所有特权集中在一个地址上。如下面这个漏洞合约示例:
contract MismanagedPermissions {
mapping(address => uint256) public balances;
address public admin;
constructor() {
admin = msg.sender;
}
function mint(address to, uint256 amount) public {
require(msg.sender == admin, "Only admin can mint");
balances[to] += amount;
}
}
这个合约中,admin可以无限制地调用mint函数创建新代币。一旦这个账户被攻破,整个经济系统将面临灾难性后果。
2. 条件判断逻辑错误
某些合约中存在条件判断逻辑写反的真实案例。2018 年 10 月,SlowMist 披露韩国项目 ICON (ICX) 的官方 ERC-20 代币合约因权限判断逻辑可绕过,任何人可调用受限函数凭空铸币,攻击者实际铸出 4 亿 ICX,ICON 被迫冻结旧合约按 1:1 迁移:
modifier onlyFromWallet {
require(msg.sender != walletAddress); // 错误地使用了!=
_;
}
本意是限制只有钱包地址能调用特定函数,但由于条件判断写反,实际上除了钱包地址,其他所有地址都能调用该函数。
3. 缺乏权限分层与限制
即使实施了权限控制,很多合约也缺乏对特权操作的合理限制。例如,管理员可能被赋予无限的铸造权限,而没有每日限额、总量上限或时间锁等安全机制。
4. 后门函数与过度特权
许多合约包含允许特权账户直接操作用户资产的函数,如:
burn(address from, uint amount):允许 owner 销毁任意用户的代币sweep(address from, address to, uint amount):允许 owner 转移任意用户的资金zero_fee_transaction(address from, address to, uint amount):允许特殊账户无需授权即可转账
这些函数本意可能是用于紧急情况或特殊业务需求,但一旦被滥用或泄露,将严重破坏用户信任。
权限管理最佳实践
1. 最小权限原则
每个角色只应拥有完成其职责所需的最小权限。例如,铸造功能可以设置每日上限:
contract SafePermissions {
uint256 public dailyMintLimit;
uint256 public dailyMinted;
function mint(address to, uint256 amount) public onlyAdmin {
require(dailyMinted + amount <= dailyMintLimit, "Daily limit exceeded");
balances[to] += amount;
dailyMinted += amount;
}
}
这种方式即使管理员账户被攻破,攻击者也无法无限制地铸造代币。
2. 多签与多重验证
对于关键操作,应引入多签机制,要求多个独立方批准才能执行。OpenZeppelin 的 AccessControl 库提供了角色管理的标准实现:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract MyContract is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
constructor() {
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
_setupRole(ADMIN_ROLE, msg.sender);
}
function criticalOperation() public onlyRole(ADMIN_ROLE) {
// 需要ADMIN_ROLE才能执行
}
}
这种方式可以避免单点故障。
3. 基于角色的访问控制(RBAC:Role-Based Access Control)
更复杂的系统可以采用基于角色的访问控制模型,定义不同角色并分配相应权限。中国工商银行的专利“一种业务合约权限控制方法、装置及设备“(CN114969788B)提出了通过权限调用接口与权限中心平台统一管控业务合约权限的方案,体现了金融场景对细粒度权限管理的要求。
4. 权限审计与时效性
所有权限操作应记录在事件日志中供审计,同时权限应设置有效期,过期后需要重新授权。例如:
mapping(address => uint256) public adminExpiry;
function addAdmin(address newAdmin, uint256 expiryTime) public onlyAdmin {
admins[newAdmin] = true;
adminExpiry[newAdmin] = expiryTime;
}
modifier onlyAdmin() {
require(admins[msg.sender] && adminExpiry[msg.sender] > block.timestamp, "Not admin or expired");
_;
}
5. 严格的代码审查与测试
特别要检查权限修饰器的逻辑是否正确,避免出现类似onlyFromWallet中的反向条件错误。自动化工具和形式化验证可以帮助捕捉这类问题。
行业创新与未来方向
金融科技领域也在探索更先进的权限管理方案。中国工商银行的专利“一种业务合约权限控制方法、装置及设备“(CN114969788B)提出通过权限调用接口与权限中心平台(公有云/本地两级)统一管控权限。这种集中式权限管控思路,有望缓解智能合约权限管理过于静态化的问题。
另一个重要趋势是将基于属性的访问控制(ABAC:Attribute-Based Access Control)引入智能合约。与传统的 RBAC 相比,ABAC 可以根据交易上下文、时间条件、参与方信誉等多维属性做出更精细的权限决策。
现实案例
1. The DAO 攻击(2016 年)
- 漏洞类型:重入攻击(访问控制不足)
- 影响:攻击者利用递归调用漏洞,从 The DAO 合约中盗取了 360 万枚 ETH(当时价值约 5000 万美元),最终导致以太坊硬分叉,分裂为 ETH 和 ETC。
- 原因:合约在转账时未采用“检查-生效-交互”(Checks-Effects-Interactions, CEI)模式,允许攻击者在余额清零前多次提取资金。
2. Parity 多重签名钱包漏洞(2017 年,两次事件)
- 漏洞类型:初始化函数未鉴权(函数可见性错误)
- 7 月事件(被盗):攻击者利用已部署钱包未保护的
initWallet成为 owner,盗取约 15.3 万枚 ETH(当时约 3200 万美元)。 - 11 月事件(冻结):另一位“用户“(devops199)对共享库合约调用
initWallet自任 owner,随后kill()(selfdestruct)删除库代码,所有依赖该库的钱包同时瘫痪,约 51.3 万枚 ETH 永久冻结;EIP-999 恢复提案于 2018 年被社区否决。 - 原因:两次事件根源相同——关键初始化/自毁函数未限制调用权限。
3. SushiSwap xSUSHI 事件(2023-04)
- 漏洞类型:访问控制错误(
setMinter未鉴权,minter 可被任意地址认领) - 实际结果:白帽研究员抢先利用该漏洞示警——铸造 2000 万枚 xSUSHI 后立即销毁,仅卖出约 30 枚(约 22 美元);官方随后销毁非法份额并移除
setMinter。并未发生恶意铸造套现数百万美元(复盘见 rekt.news)。 - 同期另一独立事件:旧 router 的无限授权(approve)漏洞潜在风险约 330 万美元,同样被白帽抢先救回,仅约 300 ETH 未能追回。
- 教训:这类“谁能调
setMinter“的漏洞,后果取决于谁先发现——白帽案例恰好是最好的证明。
4. Akutars NFT 事件(2022 年)
- 漏洞类型:过度限制函数(合约被永久锁定)
- 影响:由于合约管理员权限过大,且未正确处理退款逻辑,导致合约被冻结,3400 万美元资金无法提取。
- 原因:合约采用过于严格的权限控制,导致合法操作(如退款)被意外阻止。
5. HashForEther(教学案例,非真实攻击)
- 出处:第 1 届 Underhanded Solidity Contest(2017)冠军作品,作者 Nick Johnson,从未部署到主网。
- 机制:合约看似允许胜者提奖,实则“胜者地址“的判定条件与 EOA/合约地址的二象性相矛盾,数学上永远无法取款——用于演示“不加审视的资金提取设计“有多隐蔽,后被广泛用作模糊测试的 ether-leak 基准用例。
6. SIR.trading 杠杆交易攻击(2025 年 2 月)
- 漏洞类型:未鉴权入口 + 瞬态存储数据未校验(EIP-1153 引入的新问题)
- 影响:攻击者通过未经鉴权的
claim()进入铸造赎回逻辑,把瞬态存储(TSTORE)中的费率操纵到极大值后低买高赎,抽干 PrivateVault 约 35.5 万美元。安全公司 Decurity 称其为首起利用 EIP-1153 瞬态存储的黑客事件。 - 原因:
state.epoch未变化导致对 Uniswap TWAP 的 sanity check 被跳过——瞬态存储省了 gas,但存进去的值同样要过鉴权与校验。
建议
- 遵循最小权限原则,避免过度赋权
- 实施多因素认证和多重签名机制
- 采用经过验证的权限库如 OpenZeppelin AccessControl
- 建立完善的权限审计和监控机制
- 对特权函数进行严格的安全审查
- 开发者应使用
OpenZeppelin的Ownable或AccessControl库,并严格遵循最小权限原则。
进阶(tx.origin、initialize 哨兵值、瞬态存储鉴权):权限控制进阶
DOS 攻击(拒绝服务攻击)
不同于传统的网络层 DoS 攻击,智能合约中的 DoS 攻击利用的是以太坊虚拟机(EVM)的特性、Solidity 语言的缺陷或合约逻辑漏洞,使合约功能无法正常执行或永久锁定用户资金。
智能合约 DoS 攻击的本质与危害
在区块链环境中,拒绝服务攻击是指通过技术手段使智能合约无法正常执行其预定功能的攻击行为。与传统的网络层 DoS 攻击不同,智能合约 DoS 攻击通常不依赖于流量洪水,而是利用以下机制实现攻击目的:
- Gas 耗尽:通过精心构造的交易使合约执行超出区块 Gas 限制
- 状态锁定:使合约关键状态无法更新,永久阻塞正常功能
- 资源竞争:垄断合约关键资源,阻止其他用户访问
- 外部依赖破坏:利用对外部合约的错误假设导致功能失效
智能合约一旦部署便不可更改的特性使得 DoS 攻击的影响尤为严重——一旦中招,往往没有补救窗口。成功的 DoS 攻击可导致以下后果:
- 资金永久锁定:用户资产无法提取,如退款或派奖函数被外部调用失败永久阻断
- 业务逻辑中断:关键功能无法执行,如众筹合约无法完成资金分发
- 系统信誉受损:用户对项目方技术能力产生质疑,导致代币价值下跌
- 连锁反应:依赖该合约的其他协议也可能受到影响,如 DeFi 组合性风险
Solidity 中常见的 DoS 攻击类型与技术分析
1. Gas 耗尽攻击(Gas Exhaustion Attacks)
Gas 耗尽攻击是最典型的智能合约 DoS 形式,攻击者通过操纵合约中的循环或递归结构,使交易执行超过区块 Gas 限制而失败。
典型场景:
- 遍历外部用户可控制的变长数组
- 递归调用没有深度限制
- 复杂计算未设置 Gas 上限
漏洞合约示例:
contract VulnerableAirdrop {
address[] public recipients;
mapping(address => uint) public balances;
function addRecipient(address recipient) public {
recipients.push(recipient);
}
// 危险:遍历外部可控的数组
function distribute() public {
for(uint i = 0; i < recipients.length; i++) {
balances[recipients[i]] += 1 ether;
}
}
}
在此合约中,攻击者可以不断调用addRecipient添加大量地址,使distribute函数遍历时消耗的 Gas 超过区块限制。
防御方案:
- 使用提现模式(Pull over Push):让用户自行提取而非合约主动分发
- 限制单次操作处理的元素数量
- 使用映射(mapping)替代数组存储大规模数据集
2. 外部调用阻塞攻击(External Call Freezing)
当合约状态变更依赖于外部调用结果时,恶意合约可能通过故意使调用失败来永久阻塞主合约功能。
典型案例:
contract VulnerableAuction {
address public highestBidder;
uint public highestBid;
function bid() external payable {
require(msg.value > highestBid);
// 危险:状态变更依赖于外部调用
require(payable(highestBidder).send(highestBid));
highestBidder = msg.sender;
highestBid = msg.value;
}
}
攻击者可部署一个恶意合约,在fallback函数中实现revert(),使其成为highestBidder后,所有后续bid调用都会因退款失败而回滚。
防御方案:
- 采用“检查-效果-交互“模式(Checks-Effects-Interactions)
- 分离状态变更与外部调用
- 为关键操作设置超时机制
3. 拒绝 Ether 接收攻击(Ether Rejection Attacks)
某些合约逻辑要求必须成功转账 Ether 才能继续执行,但如果接收方是恶意合约或未实现receive/fallback函数的合约,将导致整个交易回滚。
漏洞模式:
function claimThrone() external payable {
require(msg.value > currentBid);
// 危险:业务推进依赖上一位国王接受退款
(bool success, ) = currentKing.call{value: currentBid}("");
require(success, "Refund failed");
currentKing = msg.sender;
currentBid = msg.value;
}
当 currentKing 是如下合约时,退款返回 false,随后 require 使整笔 claimThrone 交易回滚。若忽略 call 的返回值,退款失败本身不会自动使主调函数回滚,而会形成另一种资金记账错误:
contract MaliciousKing {
fallback() external payable { revert(); }
}
防御方案:
- 使用提现模式替代主动转账
- 为每个接收者单独记录退款额度,失败时保留其提取权;
extcodesize不能保证对方接受 ETH,也不应作为防御手段 - 记录欠款让用户自行提取
4. 特权账户丢失攻击(Privileged Account Loss)
当合约关键功能依赖于特定特权账户(如 owner)时,若该账户私钥丢失或变为非活动状态,将导致合约功能永久冻结。
漏洞示例:
contract FrozenContract {
address public owner;
bool public isFinalized;
constructor() { owner = msg.sender; }
function finalize() public {
require(msg.sender == owner);
isFinalized = true;
}
function transfer(address to, uint amount) public {
require(isFinalized);
// ...转账逻辑
}
}
如果owner私钥丢失,finalize将无法调用,所有transfer操作被永久禁用。
防御方案:
- 采用多签机制替代单一管理员
- 设置时间锁自动执行关键操作
- 实现权限恢复机制
5. Return Bomb 攻击
这是一种较隐蔽的 DoS 攻击,利用 EVM 处理大返回值时的高 Gas 消耗特性,通过恶意合约返回超大体积数据使调用方交易因 Gas 不足而失败。
攻击原理:
- EVM 处理
call返回数据时需要将数据存入内存 - 内存扩展的 Gas 成本随数据量平方级增长
- 攻击合约通过汇编返回极大数据块:
contract ReturnBomb {
fallback() external payable {
assembly {
return(0, 0xFFFF) // 返回超大内存区域
}
}
}
当受害合约调用此类合约时,可能因处理返回数据耗尽 Gas。
防御方案:
- 限制外部调用的 Gas 用量
- 检查返回数据大小
- 在复制返回数据前限制长度;普通安全调用包装并不自动提供 Return Bomb 防护
防御 DoS 攻击的综合策略
1. 合约架构设计原则
- 提现模式优先:让用户主动提取资金而非合约主动发送
- 最小依赖原则:减少对外部合约的状态依赖
- 权限分散:避免单一控制点,采用多签或 DAO 治理
- 状态隔离:关键功能不共享状态变量
2. 编码最佳实践
-
Gas 消耗控制:
function safeBatchTransfer(uint start, uint end) public { require(end - start < 50, "Too many items"); for(uint i = start; i < end && gasleft() > 50000; i++) { // 处理单个元素 } }限制单次操作处理的项目数量并检查剩余 Gas。
-
外部调用防护:
function safeExternalCall(address target, uint value) internal { (bool success, ) = target.call{gas: 100000, value: value}(""); if(!success) { // 记录失败,不影响主逻辑 failedTransfers[target] += value; } }限制调用 Gas 并处理失败情况。
-
权限与时效控制:
struct Proposal { uint executeAfter; bool executed; } mapping(bytes32 => Proposal) public proposals; function executeProposal(bytes32 id) public { Proposal storage p = proposals[id]; require(block.timestamp >= p.executeAfter && !p.executed); // 执行操作 }为关键操作引入时间锁机制。
3. 测试与监控
- Gas 消耗分析:使用
eth_estimateGas测试边界情况 - 模糊测试:输入异常参数验证合约健壮性
- 事件监控:实时检测异常交易模式
- 压力测试:模拟高负载下合约行为
4. 安全工具与库
- OpenZeppelin Contracts:提供
ReentrancyGuard、PullPayment等安全基元 - SafeMath:防止算术操作导致的意外回滚(Solidity 0.8+已内置)
- MythX:智能合约安全分析平台
- Slither:静态分析工具检测潜在漏洞
历史案例分析
1. King of the Ether 攻击(2016)
早期以太坊游戏合约因未考虑合约账户可能拒绝接收 Ether 而导致 DoS 漏洞。攻击者部署恶意合约成为“国王“后,所有后续挑战交易都因 Ether 退款失败而回滚,游戏永久停滞。
漏洞代码:
function claimThrone() external payable {
require(msg.value > currentBid);
king.transfer(currentBid); // 可能失败
king = msg.sender;
currentBid = msg.value;
}
2. GovernMental 合约锁死(2016)
著名的庞氏合约的最后一轮奖池(约 1100 ETH)payout 需要遍历不断膨胀的分红名单,消耗约 630 万 gas,超出了当时约 470 万的区块 gas 上限。领奖交易因此无法被打包,在内存池滞留约 66 天(2016-11 至 2017-02),直到矿工上调区块 gas 上限才完成支付——SWC-128 “DoS with Block Gas Limit” 的教科书案例:合约功能随状态增长而退化,最终无人能触发。
3. 模式总结:Gas 消耗随状态增长而退化
GovernMental 的困境在后来的许多协议中反复出现:函数 Gas 消耗与任何人可增长的状态成比例(遍历存款名单、分红名单、历史记录)。攻击者无需任何特殊手法——只要不断往数组里加数据,就能把目标函数推到永远无法打包。审查协议时要问:这个函数的 Gas 消耗上限由谁控制?
未来防护趋势
- 形式化验证:数学方法证明合约不存在特定类型的 DoS 漏洞
- EVM 字节码格式升级(EOF):曾被视为 gas 计量与静态验证的改进方向,但 eWASM 已被放弃,EOF(EIP-3540 系列)也于 2025 年从 Fusaka 升级中移除——执行层短期内仍维持传统 EVM 字节码,防御重心不在虚拟机换代
- 状态通道:将频繁交互移至链下,减少链上 DoS 风险
- 零知识证明:将复杂计算移至链下验证,降低主链负载
总结与建议
- 严格限制循环和递归:避免遍历外部可控的数据结构,或设置明确的上限
- 谨慎处理外部调用:假定所有外部调用都可能失败,不影响关键状态
- 采用提现模式:让用户主动提取而非合约主动发送资金
- 分散权限控制:避免单一故障点,采用多签或时间锁机制
- 全面测试:包括 Gas 消耗分析、边界测试和压力测试
开发者在编写智能合约时应始终假设所有外部调用都可能失败,所有用户输入都可能恶意,所有状态都可能被操纵。通过遵循这些原则和使用 OpenZeppelin 等经过验证的安全库,可以显著降低合约遭受 DoS 攻击的风险。
检查清单:
- 所有循环都有明确的迭代上限
- 不依赖外部调用的成功来完成状态变更
- 关键功能有超时或替代执行路径
- 使用提现模式处理用户资金分发
- 权限控制采用多因素认证而非单一账户
- 进行了充分的 Gas 消耗测试
进阶(63/64 gas grief、return bomb、强制到账):DoS 进阶
整数溢出
概述
整数溢出(Integer Overflow)是一种常见的编程错误,发生在数值运算结果超出了数据类型所能表示的范围时。这种错误在智能合约中尤其危险,因为它可能导致意外的资金损失或安全漏洞。 在 0.8.0 版本中自带了溢出检查的功能,而早期的智能合约依赖 safemath 库
基本原理
有符号整数溢出
对于 n 位有符号整数:
- 取值范围:[-2^(n-1), 2^(n-1)-1]
- 例如 8 位有符号整数:[-128, 127]
在 Solidity 0.8.0 及以上版本,下面的运算会直接回滚;只有旧编译器或显式 unchecked 才会得到取模结果。为了展示旧版本行为,示例使用 unchecked:
int8 a = 127;
int8 b = 1;
int8 c;
unchecked { c = a + b; } // 结果按 2^8 取模,变为 -128
无符号整数溢出
对于 n 位无符号整数:
- 取值范围:[0, 2^n-1]
- 例如 8 位无符号整数:[0, 255]
同样,Solidity 0.8.0 及以上默认回滚;以下代码显式关闭检查:
uint8 a = 255;
uint8 b = 1;
uint8 c;
unchecked { c = a + b; } // 结果按 2^8 取模,变为 0
常见溢出场景
- 加法溢出
uint8 a = 255;
uint8 b = 1;
uint8 c;
unchecked { c = a + b; } // 旧版本或 unchecked 下溢出为 0
- 乘法溢出
uint8 a = 16;
uint8 b = 16;
uint8 c;
unchecked { c = a * b; } // 旧版本或 unchecked 下溢出为 0
- 减法下溢
uint8 a = 0;
uint8 b = 1;
uint8 c;
unchecked { c = a - b; } // 旧版本或 unchecked 下溢出为 255
智能合约中的风险
- 资金损失
function transfer(uint256 amount) public {
balances[msg.sender] += amount; // 可能溢出
// 转账逻辑
}
- 权限绕过
function checkAccess(uint256 userId) public view returns (bool) {
return userId < totalUsers; // 可能被溢出绕过
}
防护措施
- 旧版本使用 SafeMath 库
using SafeMath for uint256;
function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
return a.add(b); // 使用 SafeMath 防止溢出
}
- Solidity 0.8.0+ 内置检查
// 0.8.0 及以上版本默认启用溢出检查
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // 自动检查溢出
}
- 前置条件检查
function transfer(uint256 amount) public {
require(balances[msg.sender] + amount >= balances[msg.sender], "Overflow");
balances[msg.sender] += amount;
}
最佳实践
-
使用合适的数据类型
- 根据实际需求选择合适位数的整数类型
- 避免使用过小的数据类型
-
进行边界检查
- 在运算前检查操作数
- 验证运算结果
-
使用安全库
- Solidity 0.8.0+ 默认检查;旧版本使用 SafeMath
- 避免自己实现数学运算
-
代码审计
- 定期进行安全审计
- 使用自动化工具检测
历史漏洞案例
-
BEC (BeautyChain) BatchOverflow(2018-04)
batchTransfer中cnt * _value乘法溢出,绕过余额检查凭空铸造巨量代币- 交易所随即暂停 BEC 充提,代币价格崩盘;同一时期 SMT 也出现相同模式
- 该事件直接推动了批量转账类函数的溢出审计,也是 0.8.0 内置检查被加速采纳的背景之一
-
PoWHC (PonziToken)(2018-02)
transfer路径的余额减法下溢(wrap 成天文数字假余额),随后经兑换/提现路径套现- 合约内约 866 ETH 被清空
溢出解释
图片来源为 csapp+个人笔记
进阶(unchecked、舍入方向、ERC-4626 通胀):整数溢出与精度
授权竞争:旧额度与新额度如何都被消费
ERC-20 的 approve(spender, value) 覆盖当前额度,不表示“从最初授权起累计最多花 value”。当旧额度已存在,Spender 可以先花旧额度,再在覆盖交易生效后使用新额度。ERC-20 规范
这里每一笔交易仍然具有原子性。竞争发生在多笔交易的排序之间,不能把等待确认解释为交易“非原子”。攻击者需要已经拥有有效额度,或能利用已被授权 Spender 的业务漏洞;旁观内存池本身不会获得 transferFrom 权限。
1. 完整的排序实例
假设 Alice 有 200 个代币,Spender 已获 100 的额度。Alice 准备把剩余额度改成 50:
| 顺序 | 已执行操作 | 剩余额度 | Spender 累计转走 |
|---|---|---|---|
| 初始 | approve(spender, 100) | 100 | 0 |
| 抢先消费 | Spender transferFrom(..., 100) | 0 | 100 |
| 覆盖交易 | Alice approve(spender, 50) | 50 | 100 |
| 再次消费 | Spender transferFrom(..., 50) | 0 | 150 |
150 来自旧额度 100 加新额度 50。approve(50) 不会检查此前已花了多少,也不会把历史转账回滚。
公开内存池会增加提前观察交易的机会,但实际排序由区块构建过程决定。提高 Gas 费用或使用私有交易通道可能改变观察和排序条件,不能保证消除额度模型本身的风险。
2. 先归零的作用与局限
approve(spender, 0) 确认后,旧剩余额度不能再用;确认前仍可能被消费。清零无法追回已经转走的代币。
如果随后不检查期间发生的转账,直接重新授权 50,同样可能形成“旧额度已经消费,再给一份新额度”的结果。用户或应用应在清零确认后重新核对余额、消费和真实业务需求,再决定是否设置新额度。
对于始终不可信的 Spender,重新给额度仍允许其按规则转账;这种权限风险也不能由清零步骤解决。
3. 增量调整与 permit
某些代币提供 increaseAllowance / decreaseAllowance,按执行时的余额增减额度,避免简单覆盖重新补足旧额度。减少值大于当时剩余额度时通常回滚;并不代表所有待确认交易都不能被抢先排序。
这两个接口不是 ERC-20 必需接口,OpenZeppelin 5.x 基础 ERC20 已不提供。应按代币实现调用,不能假设所有代币都支持,也不应把它们的移除推导为“permit 永久解决竞态”。
EIP-2612 允许签名设置额度。单独提交 permit 仍然覆盖 allowance;在 deadline 前任何人可提交许可。把许可和消费放进同一业务交易可以缩小操作窗口,但原有 allowance、Spender 权限和业务身份仍要分析。
ERC-777 的 operator、ERC-1155 的全局授权采用不同权限模型,不是对 ERC-20 覆盖额度风险的通用补丁。
4. 运行一个可复现的状态序列
仓库 examples/ethereum-signatures 中的 testApprovalRaceSpendsOldAndNewAllowance 用 Foundry 分别模拟 Owner 与 Spender,按上表顺序调用。
npm ci --ignore-scripts
forge test --match-test testApprovalRace -vv
预期累计收款为 150 个单位、剩余额度为 0。测试复现的是选定排序下的状态转换,不模拟真实内存池、竞价或区块构建者,也不证明某个历史攻击的金额或归因。
5. 集成时真正需要检查的权限
- 谁是 Token 上的 Spender,是否能升级,哪些入口能够触发代扣。
from、收款人、金额与业务调用者或已验证签名是否绑定。- 当前 allowance、待提交许可及撤销交易各处于什么状态。
- 限额和授权期限由哪一层实现;标准 ERC-20 allowance 没有自动失效时间。
延伸阅读:无限授权、forceApprove 与 Permit2。
预言机操纵与闪电贷 (Oracle Manipulation & Flash Loan Attacks)
这是 2020 年以来 DeFi 领域损失金额最大的一类攻击。它通常不是一个独立的代码缺陷,而是两种机制的叠加:
- 预言机:合约获取链外/外部数据的通道,喂价错误会直接污染所有依赖它的估值逻辑;
- 闪电贷:让攻击者在单笔交易内免费借用天量本金,把“操纵价格需要巨额资金“这个门槛抹平。
预言机是什么
合约运行在沙箱里(见 EVM 与内部状态),无法主动访问外部世界。“预言机“就是外部数据进入合约的通道。DeFi 中最常见的是价格预言机——抵押借贷、清算、流动性挖矿估值全都依赖它。常见实现按可信度从低到高:
| 喂价来源 | 原理 | 风险 |
|---|---|---|
| AMM 现货价 | 直接读 getReserves() 算比值 | 单笔交易即可改写,最危险 |
| TWAP | 时间加权平均价 | 窗口选择不当仍可被操纵 |
| 预言机网络 | Chainlink 等多节点聚合喂价 | 需校验数据新鲜度;长尾资产无覆盖 |
| 内部记账 | 协议自己维护的累积汇率 | 不受外部价格影响,但设计复杂 |
漏洞原理:现货价可以被单笔交易改写
以 Uniswap V2 的恒定乘积池为例,池内 reserveETH = 100、reserveUSDC = 100,000,现货价即储备比值:
100,000 / 100 = 1000 USDC/ETH
攻击者用 10,000 ETH 单笔卖出:新储备为 10100 : 990,现货价瞬间变成约 10 USDC/ETH——被压到 1/100。这笔交易结束后价格会因套利回归,但在同一笔交易内,任何读取这个现货价的合约看到的都是被扭曲的价格:
// 受害模式:直接用 AMM 现货价当抵押品估值
contract Lending {
IUniswapV2Pair public pair;
function borrow(uint256 collateralAmount) external {
// 用当前池价给抵押品估值 —— 漏洞所在
(uint112 r0, uint112 r1, ) = pair.getReserves();
uint256 price = r1 * 1e18 / r0; // 可被单笔 swap 改写
uint256 value = collateralAmount * price;
require(value >= minBorrow, "insufficient collateral");
// ... 按虚高估值放出真金白银
}
}
闪电贷:攻击的原子性原语
闪电贷允许用户在一笔交易内借出任意金额,只要交易结束前归还(连本带费),否则整个交易回滚。这利用了 EVM 交易原子性——它不是漏洞,而是协议功能:
- 接口已标准化为 EIP-3156,Aave、Uniswap(flash swap)等均支持;
- 费用极低(Aave V3 默认 0.05%,由治理参数
FLASHLOAN_PREMIUM_TOTAL控制且可调),金额不设上限; - 失败零成本:交易回滚只损失 gas。
对攻击者意味着:操纵一个百万美元级资金池所需的千万级本金,可以免费借来,用完即还。“需要巨额本金“从此不再是安全假设。
完整攻击流程(三段式)
- 借:闪电贷借入用于砸盘/拉盘的资产;
- 扭曲:在喂价来源的池子里单笔大额 swap,把现货价推到极端;
- 收割:在受害协议上按扭曲价格行动——超借抵押贷款、清算他人头寸、低买高卖份额——获利后恢复池价、归还闪电贷。
contract Attacker {
function attack() external {
// 1. 借入巨额 ETH
AAVE_POOL.flashLoanSimple(address(this), ETH, 10_000e18, "", 0);
// 2~3. 在 swap 回调中:砸盘 → 用虚低价格在受害协议套利 → 归还
}
function executeOperation(address, uint256 amount, uint256 premium, address, bytes calldata) external {
// 砸盘扭曲价格
ROUTER.swapExactTokensForTokens(amount, 0, path, address(this), block.timestamp);
// 在受害协议上按被扭曲的价格收割 ...
// 归还闪电贷本息
uint256 owed = amount + premium;
ETH.approve(address(AAVE_POOL), owed);
}
}
历史案例
| 时间 | 事件 | 损失 | 关键点 |
|---|---|---|---|
| 2020-02 | bZx | 约 100 万美元 | 首次公开揭示“闪电贷 + 现货价操纵“组合拳 |
| 2020-10 | Harvest Finance | 约 2400 万美元 | 闪电贷扭曲 Curve 池价,套利金库份额定价 |
| 2022-10 | Mango Markets | 约 1.17 亿美元 | 长尾资产深度差,现货拉价后按虚高抵押开仓借出 |
| 2023-02 | BonqDAO | 约 1.2 亿美元 | 预言机(Tellor)喂价本身被操纵,抵押品估值虚高 |
事件复盘资料:rekt.news。具体机制需结合攻击交易、合约版本和原始调查报告确认。
防御措施
- 抛弃现货价,用 TWAP:Uniswap V2 的
price0CumulativeLast/ V3 的observe(secondsAgo)提供时间加权价。注意权衡:窗口越长越抗操纵但越滞后;窗口太短时攻击者可以提前一个窗口开始推价,仍有操纵空间。 - 用预言机网络并校验新鲜度:读 Chainlink 必须用
latestRoundData()并检查updatedAt是否过期、answer > 0,而非已废弃的latestAnswer();关键资产多源交叉。 - 单交易内价格不可信:估值逻辑不应依赖“本交易刚开始时的外部报价“——用协议内部记账(如 Aave 自己累积的 aToken 汇率)或本交易内不变的量。
- 长尾资产不给杠杆:没有可靠喂价的资产,不要提供借贷/杠杆功能;深度差的池子即使 TWAP 也不安全。
- 审查“谁能影响价格“:审计时的核心问题是——喂价的每一次更新,攻击者需要花多少成本?如果答案是“一笔闪电贷“,就还没有完成防御。
检查清单
- 协议中所有估值都来自哪里?(
getReserves、slot0、latestAnswer全部标出) - 是否存在单笔交易内可改写的喂价?
- TWAP 窗口多长?窗口内操纵需要多少成本?
- 预言机数据是否校验了新鲜度与有效性?
- 长尾资产是否被赋予了杠杆/借贷功能?
进阶(只读重入喂价、TWAP 成本、内部汇率):预言机操纵进阶
签名滥用与重放:验签不等于业务授权
签名验证回答“这份消息是否符合指定账户的签名规则”。资产操作还要回答“谁收款、哪个合约执行、允许执行几次、是否已经取消”。即使验签正确,业务参数没有绑定也可能让攻击者使用受害者的授权。
先阅读 EIP-712、ERC-1271 与 EIP-2612,再逐项检查下面的攻击条件。
1. 重放的范围
| 缺少的约束 | 可能的结果 |
|---|---|
| 一次性 nonce 或订单消费状态 | 同一资产操作被重复执行 |
| 验签合约或业务域 | 另一个接受同样消息的应用也接受签名 |
| 链 ID 或其他网络标识 | 接受相同消息规则的其他网络可以使用签名 |
| 收款人、金额或业务动作 | 合法签名被用在不同于用户意图的执行中 |
这些结果以相关系统采用兼容的验签规则为前提,不是签名缺一个字段就必然在所有链或所有合约上可重放。
nonce 和 deadline 是应用策略,不是 EIP-712 的强制组成。一次性转账通常需要消费标记;允许反复证明身份的消息则需要另一套有效期和使用场景设计。deadline 本身只限时间,不限制时间内的使用次数。
2. ECDSA 延展性需要同时考虑 s 与 v
对于可恢复签名 (v, r, s),将 s 变成曲线阶 n - s 时,还需翻转恢复分支 v(27 与 28 互换),才能恢复同一地址。只说“翻转 s 就恢复同一地址”是不完整的。
EIP-2 限制交易签名的 high-s,但保留了 ecrecover 预编译接受 high-s 的行为。合约消息验签不能假设协议已经替自己完成 low-s 检查。
如果按 keccak256(signature) 去重,两种有效字节形式可能对应同一业务授权。应按 Owner 与 nonce、或明确的订单摘要去重,并选用检查 low-s 的库。支持其他签名格式时,同样不能把字节唯一性当作业务唯一性。
示例工程的 testHighSMalleabilityRequiresFlippedVAndIsRejected 确认原始 ecrecover 接受两种恢复形式,而固定 OpenZeppelin 5.2.0 的验签库拒绝 high-s。
3. 恢复地址、编码与执行参数
ecrecover 是 Solidity 提供的预编译调用入口,不是 EVM 操作码。失败时可返回零地址;仅检查结果是否等于未初始化的 expectedSigner,可能把两者均为零当成通过。
多个动态字段的 abi.encodePacked 有分界歧义,例如 ("a", "bc") 和 ("ab", "c") 都得到 0x616263。这里是编码不唯一,不是攻破 Keccak。使用明确 ABI 编码或 EIP-712 的动态字段哈希规则。ABI 规范
定长的 0x1901、domainSeparator 和 structHash 可以按规范 packed 拼接。不能把这一合法用法与动态业务字段拼接混为一谈。
4. 账户类型和可撤销签名
传统合约账户按 ERC-1271 查询当前策略;EOA 可用私钥签名。EIP-7702 委托 EOA 仍保留私钥,也可通过委托代码执行钱包策略。是否支持 ERC-1271 取决于代码与应用约定,不是委托的自动效果。EIP-7702
合约签名的有效性可能随钱包状态变化。链下预检查成功后,结算时仍需重验;业务取消和 nonce 消费还应由结算协议检查。
5. 假 permit 与任意 from
一种可以直接复现的缺陷:用户先给 Router 授权;Router 允许任意调用者传入 from 和收款人,却没有独立验证该业务操作。攻击者可以利用已有 allowance 从受害者转出资产。
在这样的入口里加一个可能被忽略、或只触发空 fallback 的 permit 调用,不会补上缺失的业务权限。但正确绑定身份和收款行为时,容忍被抢先提交的 permit 失败也可以安全,不能一概要求捕获异常后必须回滚。
历史安全事件需要具体合约版本、交易和原始分析才能归因;这里以可运行模型说明机制,不用未经核实的项目名、资产清单或损失金额代替证据。
6. 可运行的攻防对照
仓库 examples/ethereum-signatures 包括:
- 任意
from的漏洞夹具,复现受害者历史额度被利用。 - 身份绑定的
PermitDeposit,不能用攻击者身份消费受害者额度。 - 签入收款人的
SignedTransfer,支持 Relayer,却不能修改收款人或重放 nonce。
npm ci --ignore-scripts
forge test --match-test 'testSignedTransfer|testHighS|testArbitraryFrom|testFakePermit' -vv
测试夹具中的 UnsafeDeposit 是故意有漏洞的反例,不应部署为业务合约。关于域缓存与代理上下文,继续阅读签名验证进阶。
可升级代理漏洞
代理通过 DELEGATECALL 执行实现合约的代码。委托执行时,业务状态保存在代理中,address(this) 为代理地址,msg.sender 保留代理收到调用时的调用者。直接调用实现地址则使用实现自己的状态。
代理模式与升级入口
| 模式 | 升级逻辑 | 权限与状态 |
|---|---|---|
| Transparent | 位于代理侧,OpenZeppelin 实现通常由 ProxyAdmin 管理 | 代理区分管理员调用与业务调用 |
| UUPS | 位于当前实现中,经代理委托执行 | 实现检查权限并修改代理的实现地址槽 |
| Beacon | Beacon 保存实现地址 | 多个代理使用该 Beacon 返回的实现 |
模式名称不决定具体库版本的接口。OpenZeppelin 5.4.0 的 UUPS 升级入口为 upgradeToAndCall;原始 EIP-1822 与现代 OpenZeppelin 使用的实现地址槽也不相同,见 UUPS。
存储槽冲突
手写代理若把 owner、implementation 等变量放在线性布局起始位置,委托执行的实现变量可能写入相同槽位。冲突发生在代理的存储中,并不是两个部署地址天然共享全部存储。
EIP-1967 为实现地址、管理员和 Beacon 定义特殊槽位,降低与 Solidity 常规布局重叠的风险。该设计依赖哈希安全假设,不能阻止实现代码主动向这些槽位写入数据。
代理初始化与实现初始化
代理初始化设置代理的 owner、角色和业务参数。若代理部署后仍暴露未保护的初始化入口,攻击者可能抢先调用并取得代理权限。部署时附带初始化数据,可将创建代理和设置状态放在同一笔交易中。
实现合约的构造函数仅影响实现地址。OpenZeppelin 可升级实现通常在构造函数调用 _disableInitializers(),锁定实现自身的初始化入口;该操作不会替代理完成初始化。
直接调用实现的 initialize() 不会自动修改代理的 owner。历史 UUPS 漏洞涉及特定旧版本的直接升级调用和委托自毁路径,影响的是实现代码可用性,详见代理进阶。
OpenZeppelin 5.4.0 的升级入口使用 onlyProxy 检查活动代理上下文,再由 _authorizeUpgrade 检查升级权限。msg.sender == address(this) 表示调用者是合约自身,不能替代上述两个检查。
函数选择器与外部执行权限
代理管理接口与业务接口可能出现相同选择器。透明代理按管理员身份区分路由;UUPS 将升级接口放在实现中,但仍需避免业务接口冲突,并保护升级权限。
外部输入若能选择 delegatecall 目标,可能让不可信代码修改当前合约的全部状态。普通 call 使用被调用合约的上下文,也可能调用未正确鉴权的外部入口,但不能直接通过 ABI 调用 internal 或 private 函数。
消息转发和插件系统需要同时约束调用者、目标、方法与参数。只限制目标地址不能证明所有可调用方法都安全。
升级时的布局兼容
// V1:a 在 slot 0,b 在 slot 1
uint256 public a;
uint256 public b;
// 不兼容的 V2:b 改为 slot 0,将读取旧 a 的值
uint256 public b;
普通线性布局的升级通常保留既有变量顺序、类型和含义,再按兼容规则追加状态。__gap 用于预留布局空间,不能自动修复已删除或重排的业务变量。
ERC-7201 按命名空间组织存储,但命名空间内部仍需保持布局兼容。从旧布局切换到命名空间布局需要明确的数据迁移;改变寻址方式不会自动搬移旧值。
升级安全条件
- 代理初始化与实现锁定分别验证,初始化参数对应明确的权限主体。
- UUPS 检查调用上下文、升级授权及新实现的兼容接口。
- 新旧布局与业务不变量保持兼容,迁移和重复初始化路径可被验证。
- 升级治理明确多签、时间锁或其他授权机制,管理员能力纳入信任模型。
- 部署地址、实现槽、源码版本和实际执行字节码相互对应。
参考资料
智能合约安全进阶
安全与审计 讨论了常见漏洞的机理、最小复现代码与基础防御模式。本章在此基础上,深入分析生产环境中由跨合约调用、中间态暴露、编译器实现缺陷以及底层存储特性引发的复合安全风险。
前置知识:EVM 调用语义(CALL / DELEGATECALL / 瞬态存储)参见 EVM 与内部状态;代理存储布局参见 存储布局。
基础漏洞模式与进阶复合风险对照
| 漏洞主题 | 基础模式(单合约/标准调用) | 进阶复合风险(跨合约/协议交互/底层机制) |
|---|---|---|
| 重入攻击 | 单函数 ETH 转账回调 | 跨函数/跨合约状态未同步、只读重入(AMM 价格函数暴露)、代币回调钩子、编译器重入锁分配缺陷 |
| 权限控制 | 单一所有者、修饰器缺失 | tx.origin 鉴权伪造、未保护的 initialize 参数特权注入、瞬态存储槽鉴权穿透 |
| 拒绝服务 | 无界循环、转账失败回滚 | EIP-150 63/64 Gas 截留(Gas Griefing)、Return Bomb 内存二次方消耗、强制定向充值破坏状态不变式 |
| 整数与精度 | 算术加减乘整型溢出 | unchecked 优化边界溢出、除法截断与舍入方向差、ERC-4626 初始存款通胀攻击 |
| 授权竞争 | ERC-20 approve 竞态条件 | 无限授权的长期系统性风险、USDT 强制归零限制、EIP-2612 与 Permit2 签名授权机制与边界 |
| 预言机操纵 | DEX 即时现货价与闪电贷操纵 | 只读重入污染瞬时价格、TWAP 时间加权窗口操纵成本与抗操纵性度量、Chainlink 降级检查完整性 |
| 签名安全 | ecrecover 签名延展性与重放 | EIP-712 域分隔符动态缓存缺陷、假 Permit 实现穿透、ERC-1271 智能合约钱包签名边界 |
| 可升级代理 | 存储槽重叠、开放 initializer | EIP-1967 标准槽与 ERC-7201 命名空间存储规范、Nomad 零哈希哨兵值认证穿透、UUPS 裸实现合约初始化风险 |
典型复合风险的机理分类
- 协议间瞬态不一致暴露(只读重入):被调用协议在执行修改操作时具备重入锁,但其暴露的
view查询接口(如 AMM 的get_virtual_price)未加限制。第三方借贷或清算协议依赖该接口进行抵押品定价时,将读取到未完成记账闭环的中间状态。 - 初始化与配置参数的语义歧义:初始化函数将未验证的哨兵值(如
bytes32(0))直接作为可信根写入存储,导致所有未初始化槽位因默认值为 0 而均被断言判定为已授权或已验证。 - 语言编译器底层代码生成缺陷:合约源码层面的互斥锁修饰器在低版本编译器中被映射到了不同的物理存储槽位,导致多入口间实际上未形成互斥保护。
- 份额换算精度损耗与通胀攻击:Solidity 0.8+ 提供了内建溢出保护,但算术逻辑在份额计算(如
totalAssets / totalSupply)中的除法向下取整特性,使得极低份额攻击者可以通过小额初始质押配合强制转账放大每股净值,导致后续存款人资金被截断稀释。
架构审计通用分析维度
在审计跨合约系统时,应从以下维度建立状态机分析模型:
- 控制权转移点识别:明确交易执行链条中向外部地址转移控制权的所有指令(包括
CALL原语、ETH 转账、ERC-777/721/1155 接收钩子、DELEGATECALL)。 - 外部调用窗口期的存储一致性:验证在控制权转移的瞬间,本合约及关联模块的关键状态(余额映射、总发行量、质押比例)是否已完成闭环更新。
- 预言机数据的抗瞬时操纵性:验证核心计算依赖的价格或状态变量是否可在同一笔交易内通过瞬时操作(闪电贷、极值兑换)被篡改。
- 特权函数与生命周期约束:验证初始化函数与升级接口的调用次数限制、访问权限,以及入参是否与映射类型的默认值发生重叠。
- 状态推进路径的容错性:系统关键业务是否依赖单一外部调用成功或无界数据结构遍历,是否存在降级与资产提取兜底通道。
章节索引
参考文献与规范
- SWC Registry
- Solidity Security Considerations
- pcaversaccio/reentrancy-attacks
- SunWeb3Sec/DeFiHackLabs
重入攻击进阶
基础模式(单函数内先外部调用再更新余额、检查-生效-交互模式(CEI)、基础互斥锁)参见 重入攻击。EVM 底层控制权转移机制参见 EVM 与内部状态。
攻击形态分类
重入攻击的本质是调用方在自身会计状态未达成一致性闭环前,将执行流交给了外部不可信地址。根据外部调用发生时受影响的边界,可细分为以下类型:
| 重入类型 | 执行上下文与暴露状态 | 常见利用途径 |
|---|---|---|
| 单函数重入 | 当前执行函数内部状态未更新完成 | 在 withdraw() 的 ETH 转账回调中再次调用当前 withdraw() |
| 跨函数重入 | 被调函数状态修改中途,同合约内未加锁或使用独立锁的其他相关函数仍可调用 | 在 withdraw() 的回调中调用 transfer() 转移尚未扣减的余额 |
| 跨合约重入 | 合约 A 的内部锁无法约束外部合约 B;合约 B 依赖合约 A 未闭环的状态或直接操作合约 C | 借贷市场在结算抵押物赎回时向外部转账,回调流进入另一个借贷市场利用脏状态借款 |
| 只读重入 | 写操作入口受到互斥锁保护,但暴露的状态查询视图函数(view)未加保护 | 流动性池在移除流动性执行中途,其 get_virtual_price() 暴露了暂态抵押率偏差 |
| 代币回调钩子重入 | 代币转账过程触发接收者回调扩展逻辑,隐式转移控制权 | ERC-777 tokensToSend/tokensReceived、ERC-721/1155 onERC*Received |
sequenceDiagram participant Attacker as 攻击者合约 participant Victim as 被攻击协议 (Victim) participant Oracle as 依赖协议 / 外部组件 Attacker->>Victim: 调用写操作 (如 remove_liquidity / withdraw) Victim->>Victim: 部分更新存储变量 (处于不一致中间态) Victim->>Attacker: 触发外部调用 (ETH transfer / ERC-777 hook) Note over Attacker: 捕获执行流 Attacker->>Oracle: 调用依赖 Victim 中间态的第三方协议 Oracle->>Victim: 调用 view 查询状态 (获取失真数据) Oracle-->>Attacker: 基于失真状态完成不当套利/清算 Attacker-->>Victim: 恢复执行 Victim->>Victim: 完成最终记账闭环
transfer 与 send 的 Gas 限制演进
Solidity 原生提供的 address.transfer() 和 address.send() 固定转发 2300 Gas 的调用津贴(Stipend)。在早期 EVM 规则下,2300 Gas 仅允许接收方触发基础日志(LOG),不足以支撑修改存储(SSTORE),曾被广泛当作简易的防重入手段。
然而,随着以太坊升级对操作码 Gas 成本的调整,依赖固定 Gas 津贴存在严重隐患:
- 合法交互因 OOG 失败:EIP-1884(Istanbul 硬分叉)将冷状态
SLOAD成本提升至 800 Gas,后续 EIP-2929(Berlin 硬分叉)进一步提升冷访问开销。接收方若为智能合约钱包(如 Gnosis Safe 或 ERC-4337 账户)或包含基本入账校验逻辑,执行即会消耗超过 2300 Gas,导致合法转账交易持续 Revert。 - 无法防范只读重入与跨合约调用:2300 Gas 虽然不足以写入存储,但足以执行基础查询(如读取已在内存或暖访问中的状态)并向其他无状态组件发起联动。
现代合约开发应统一使用底层 call 原语配合显式鉴权与状态保护:
// 存在拒绝服务风险的过时写法
payable(to).transfer(amount);
// 推荐的标准模式:显式使用 call 并遵循 CEI / 互斥锁约束
(bool success, ) = to.call{value: amount}("");
require(success, "ETH transfer failed");
跨函数状态一致性
检查-生效-交互(CEI)模式在单个函数内能有效保证状态先行落库。但在多个函数共享底层账本状态的系统中,若互斥锁粒度不足,攻击者可在外部调用回调中直接触发其他未锁定的操作接口。
// 漏洞示例:withdraw 具备互斥锁,但 transfer 未受保护
function withdraw() external nonReentrant {
uint256 balance = balances[msg.sender];
require(balance > 0, "Zero balance");
// 外部调用转移执行权
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// 状态更新滞后,且未覆盖同合约其他入口
balances[msg.sender] = 0;
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
在该模型中,攻击者在 withdraw() 的回调中执行 transfer() 将余额转移给受控辅助地址,在回调完成后当前函数再次执行 balances[msg.sender] = 0,从而实现余额的双重提取。
防御规范:所有访问、修改或依赖同一业务账本的对外接口,必须受同一互斥状态(Mutex)或事务级控制流的保护;避免在同一合约中混合使用独立的互斥修饰器。
跨合约与只读重入(Read-Only Reentrancy)
机理分析
只读重入并非在回调中直接修改被攻击合约的状态,而是在被调合约处于暂时不平衡的瞬态时,读取其对外公开的计算型视图函数(view)。
以自动化做市商(AMM)为例,remove_liquidity 的标准实现通常包含:
- 销毁用户的 LP 代币;
- 计算并按比例向用户转出底层代币(触发代币转账或原生 ETH 发送);
- 调整流动性池内部记录的资产储备与相关参数。
在步骤 2 向接收方转移原生 ETH 时,合约执行流被转移。此时 LP 代币已被销毁,但池内的物理资产尚未完全转出,导致单位 LP 代币对应的净资产(如 Curve 的 get_virtual_price())被瞬时放大。若外部借贷协议或合成资产协议以此价格作为抵押品计价依据,攻击者即可在回调函数中发起借贷或杠杆操作,完成超额借款。
历史案例
- Fei Protocol / Rari Capital (2022-04):Fuse 借贷池在处理特定 cETH 赎回时向攻击者转出 ETH。在接收 ETH 的
receive()回调执行期间,内部市场记账尚未完成同步,攻击者利用该执行窗口向同一借贷网络中的其他借贷市场进行无抵押借贷,损失约 8000 万美元。 - Curve Finance / Conic Finance (2023-07):Conic 的
Omnipool实现依赖 Curve 池的get_virtual_price()评估储备资产价值。攻击者利用 Curve 池在移除流动性过程中的重入窗口,通过只读重入操纵价格函数,致使 Conic 遭到套利攻击,损失约 360 万美元。
防御模式:重入探测与视图互斥
对于被当作价格预言机使用的接口,必须防范重入调用:
-
瞬态重入锁保护视图函数(
nonReentrantView): 在写操作进入时设置重入状态位,在暴露的关键view函数中校验该状态,若检测到写操作未完成则直接回滚:// 基于 OpenZeppelin ReentrancyGuardTransient (EIP-1153) 的视图防重入 function getVirtualPrice() external view nonReentrantView returns (uint256) { return _calculateVirtualPrice(); } -
跨合约锁状态探测(External Mutex Probe): 若外部合约未提供
nonReentrantView,依赖方协议在读取其预言机价格前,可先尝试调用该外部合约的一个受互斥锁保护的写函数或特定检查接口;若由于外部锁处于锁定状态导致该调用回滚,则终止当前定价流程。
编译器实现缺陷导致的互斥失效
生产环境中必须将编译器版本及已知安全缺陷纳入威胁建模范围。即便合约源码逻辑严格遵循了安全修饰器规范,编译器代码生成错误仍可能导致安全机制完全失效。
Vyper @nonreentrant 存储槽冲突漏洞 (CVE-2023-37901)
2023 年 7 月,Curve Finance 的多个流动性池(pETH/ETH、msETH/ETH、alETH/ETH、CRV/ETH)遭到攻击,累计损失逾 4100 万美元。
根本原因:
在 Vyper 编译器版本 0.2.15、0.2.16 及 0.3.0 中,@nonreentrant 修饰器的状态生成逻辑存在缺陷。编译器未能为不同的互斥键(Key)或不同函数分配独立的存储槽,而是将全局统一的重入锁分配到了不同槽位,或者在生成跨函数锁时发生了存储槽重叠覆盖,导致本应互斥的函数能够相互重入。
该缺陷已在 Vyper 0.3.1 版本中修复。该事件表明,合约审计不能仅停留在静态源码语法审查,必须严格对照编译器版本的已发布漏洞通告(Release Notes / Advisory Notices)。
重入锁实现机制比较
| 方案 | 底层存储介质 | 适用运行环境 | 特性与开销评估 |
|---|---|---|---|
状态变量 bool 标记 | 持久化 Storage(Slot 0 等) | 任意 EVM 版本 | 手动管理容易因未处理异常产生脏状态;0 到 1 的状态翻转无法有效利用 Gas 退款优化。 |
OpenZeppelin ReentrancyGuard | 持久化 Storage(1 与 2 翻转) | Cancun 升级前主流实现 | 使用非零常数(NOT_ENTERED = 1, ENTERED = 2)避免存储槽从未分配到分配的冷写高额开销,充分利用 EIP-2200 暖访问退款机制。 |
OpenZeppelin ReentrancyGuardTransient | EIP-1153 瞬态存储(TSTORE/TLOAD) | Cancun 升级后支持网络 | 每次操作固定消耗 100 Gas,状态生命周期局限于当前交易执行上下文,交易结束自动释放,大幅降低执行成本并支持 nonReentrantView。 |
EIP-1153 瞬态存储行为边界
EIP-1153 引入的 TSTORE 与 TLOAD 极大地降低了重入防护的开销,但开发中需注意其执行语义:
- 执行域范围:瞬态存储在当前整笔交易(Transaction)上下文内全局有效,不随普通内部调用的返回而清空。
- 回滚语义:当发生调用执行 Revert 时,该调用上下文内执行的
TSTORE会随之回滚,这与持久化 Storage 的事务一致性保持一致。 - 隔离性:瞬态存储不具备天然的访问控制权限,跨上下文传递数据时需防范未鉴权写入。
安全检查清单
- 严格遵循 CEI 架构:所有外部调用发生前,本合约内的所有相关账本状态必须已完成写操作闭环。
- 统一互斥锁范围:共享同一账本状态的所有外部写入口必须使用同一把互斥锁。
- 视图函数定价防范:供外部依赖的计算型视图接口(尤其是 AMM 估值函数)必须具备防止中间态读取的重入限制或外部校验逻辑。
- 转账原语安全性:废除以
transfer()/send()的 2300 Gas 作为安全假设的防重入模式,改用受控底层call。 - 代币钩子风险评估:对涉及任意 ERC-20、ERC-777、ERC-721、ERC-1155 转账的交互逻辑,均应视为控制权转移点处理。
- 编译器安全版本核验:确认编译工具链版本不在已知重入漏洞影响区间内(如 Vyper 0.2.15/0.2.16/0.3.0)。
- 瞬态存储生命周期审计:在基于 EIP-1153 构建重入锁时,确保逻辑符合链上分叉支持情况,并隔离不同业务逻辑在瞬态槽位上的复用。
参考文献与规范
- 基础篇:重入攻击
- 预言机操纵进阶
- EIP-1153: Transient Storage Opcodes
- Vyper Compiler Security Advisory (GHSA-4p35-3c8v-vh68)
- OpenZeppelin Contracts ReentrancyGuardTransient.sol
权限控制进阶
权限判断需要同时明确调用者身份、执行地址、状态归属和操作生命周期。代理委托、签名转发与瞬态存储不会自动提供业务鉴权。基础权限模式见权限控制。
调用上下文与 tx.origin
普通 CALL 中,msg.sender 是直接调用者;DELEGATECALL 保留当前帧的 msg.sender 和 msg.value,并在调用者的地址及存储上下文中执行。tx.origin 是顶层交易的发送账户,在嵌套调用中不变。
若特权 EOA 先调用攻击合约,攻击合约再调用受害合约,受害合约中的 tx.origin 仍是特权 EOA,但 msg.sender 为攻击合约。只检查 tx.origin 会把未经授权的下游调用视为合法操作。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract OriginWallet {
address public immutable owner;
constructor() payable {
owner = msg.sender;
}
function transferTo(address payable to, uint256 amount) external {
require(tx.origin == owner, "Unauthorized"); // 存在钓鱼授权风险
(bool success, ) = to.call{value: amount}("");
require(success, "Transfer failed");
}
}
contract OriginForwarder {
OriginWallet public immutable wallet;
constructor(OriginWallet target) {
wallet = target;
}
function trigger() external {
wallet.transferTo(payable(address(this)), address(wallet).balance);
}
receive() external payable {}
}
当 owner 调用 OriginForwarder.trigger(),钱包的校验通过,资金转入转发合约。直接权限操作应验证相应调用主体;元交易则需要可信转发器或经过验证的签名来确定授权者,不能把任意传入的地址当成身份。
EOA 与签名授权
EIP-7702 允许 EOA 委托执行代码,账户仍保留私钥。tx.origin == msg.sender 或代码长度检查不能完整界定账户能力。ERC-4337 的账户验证由具体实现决定,也不意味着所有账户都使用同一种签名算法。
消息签名授权需要绑定操作参数、域、nonce 和有效期等业务约束。ERC-1271 表达合约账户的签名验证接口;验证返回值并不能替代转账接收者、执行者或额度的业务检查。
代理初始化的权限归属
实现的构造函数仅初始化实现自己的状态。代理 owner 和角色需要通过代理上下文的初始化建立。未初始化代理可能被抢先设置权限;直接初始化实现只改变实现自身,二者是不同攻击面。
OpenZeppelin 5.4.0 的 UUPS 实现使用 _disableInitializers() 锁定实现初始化,使用 onlyProxy 约束升级调用上下文,并由 _authorizeUpgrade 判断权限。代理部署附带初始化数据可避免独立初始化交易之间的抢占窗口。
旧版本未初始化实现与委托自毁的风险见代理进阶。msg.sender == address(this) 只允许自调用,不能作为通用代理升级保护。
默认值与隐式授权
Solidity mapping 中未写入的键返回默认零值。如果零值同时被解释为有效根、有效角色或已验证状态,未经证明的输入可能通过判断。
约束是让缺省状态与有效授权保持可区分,并验证完整的状态转换;并非禁止所有零地址、零哈希或零数值。Nomad 默认根问题属于这类状态语义冲突。
瞬态存储的权限边界
EIP-1153 的瞬态存储在交易结束时清除,同一合约上下文的多个调用帧可访问同一份瞬态状态。不同合约不能仅靠相同槽号读写彼此的瞬态存储。
普通调用使用被调用合约的瞬态空间,委托调用使用调用者的瞬态空间。调用帧回滚也会回滚该帧及其内部调用产生的瞬态修改。STATICCALL 上下文允许 TLOAD,禁止 TSTORE。
若公开入口能修改费率、授权标记或回调预期值,后续函数又将其视为可信状态,攻击者可能在同一交易内伪造执行条件。风险取决于可达的写入路径与业务含义,不能由“交易结束自动清除”消除。
重入锁一般在操作退出时清除,允许同一交易的后续合法调用。持续整笔交易的结算标记则可保留到交易结束。清理策略应对应生命周期,鉴权条件也不必一律是管理员角色修饰器。
权限检查维度
- 确认每个入口使用的身份来源及转发器信任关系。
- 分别验证代理初始化、实现锁定、升级授权与新版本迁移。
- 确认默认状态不会隐式进入有效授权集合。
- 对回调绑定调用者、预期参数和操作阶段,而非只检查槽位是否非零。
- 检查委托执行模块的瞬态槽和持久槽是否存在业务冲突。
参考资料
拒绝服务攻击进阶
基础模式(无界循环 Gas 耗尽、外部转账失败阻断流程、Push 与 Pull 支付模式、King of the Ether 案例)参见 DoS 攻击。EVM 底层 Gas 机制与内存扩展成本参见 EVM 与内部状态。
EIP-150 63/64 规则与 Gas Griefing 攻击
63/64 规则的 EVM 机制
在 Tangerine Whistle 硬分叉(EIP-150)之前,EVM 存在因调用栈深度耗尽(Call Stack Depth Attack,深度上限 1024)导致的攻击。EIP-150 引入了 63/64 规则:主调合约发起子调用(CALL / STATICCALL / DELEGATECALL)时,最多只能将当前剩余 Gas 的 63/64 传递给子调用帧,剩余的 1/64 强制保留给当前调用帧处理善后逻辑。
$$ \text{Gas}_{\text{forwarded}} = \min\left(\text{gas_requested},; \text{gasleft}() - \left\lfloor \frac{\text{gasleft}()}{64} \right\rfloor\right) $$
Gas Griefing 攻击模型
尽管 1/64 的 Gas 留给了主调合约,若主调合约在子调用返回后需要执行持久化写入(如 SSTORE,开销可能达 5000 至 20000 Gas),留存的 1/64 Gas 可能不足以完成后续操作。
恶意接收合约可以在其回退函数中故意消耗绝大部分转发给它的 Gas,然后以成功状态(STOP / RETURN)正常退出,而非触发 Revert。此时主调合约误认为子调用成功,但在继续执行后续记账时遭遇 Out of Gas(OOG),导致整笔顶层事务意外回滚。
// 恶意 Gas 消耗合约
contract GasGriefer {
fallback() external payable {
// 动态消耗绝大部分 Gas,仅保留微小余量成功返回
while (gasleft() > 2000) {}
}
}
防范策略
- 显式指定子调用 Gas 上限:
在发起外部交互时,必须显式限制子调用可用 Gas,确保主调合约保留足够的执行预算:
(bool success, ) = recipient.call{value: amount, gas: 10000}(""); - 异步结账模式(Pull Payment): 关键业务状态的流转严禁同步依赖外部未知地址的调用结果。若转账失败或 Gas 耗尽,应将资产记入待提取映射,由接收方主动发起提取操作。
Return Bomb(返回数据内存炸弹)
EVM 内存扩展的二次方成本模型
EVM 的内存(Memory)使用按线性加二次方的复合函数计费。当合约分配的内存字数(Word,32 字节)由 \(a\) 扩展到 \(a^\prime\) 时,消耗的 Gas 增量为:
$$ C(a) = 3a + \left\lfloor \frac{a^2}{512} \right\rfloor $$
当分配的内存规模显著增大时,二次方项 \(\frac{a^2}{512}\) 会导致 Gas 成本呈指数级飙升。
攻击原理
子调用合约可以通过汇编指令 return(offset, size) 返回任意超大规模的数据段。若主调合约采用高级语法解码或在底层调用中显式接收 bytes memory data:
// 易受 Return Bomb 影响的调用方式
(bool success, bytes memory returnData) = target.call(payload);
Solidity 编译器会在底层自动执行 returndatacopy,将返回的全部字节流复制至主调合约的内存空间中。攻击合约若返回长达数兆字节的伪造数据,将瞬间强制触发主调合约的内存二次方扩展,直接引发 OOG 异常导致交易失败。
contract ReturnBomber {
fallback() external payable {
assembly {
// 返回超大虚假长度数据,直接引发主调方内存扩充爆炸
return(0, 0x100000) // 1 MB 数据
}
}
}
防御范式
- 忽略不必要的返回数据:对不可信外部交互,若无需读取返回值,直接省略接收参数:
(bool success, ) = target.call{gas: CALL_GAS_CAP}(payload); - 基于汇编的受控数据复制:若必须解析返回值,应在内联汇编中先通过
returndatasize()获取返回长度,施加安全阈值截断后再执行returndatacopy:function safeCallWithLimit(address target, bytes memory data, uint256 maxCopy) internal returns (bool success) { assembly { success := call(gas(), target, 0, add(data, 0x20), mload(data), 0, 0) let size := returndatasize() if gt(size, maxCopy) { size := maxCopy } // 按限定上限截断读取至内存 } }
强制注资对系统状态不变式的破坏
在很多智能合约中,开发者错误地假设合约持有的资产余额只能通过合约自身定义的充值(deposit)或转账接口产生。
强制向合约注入资产的物理途径
合约逻辑无法拒绝以下途径的原生代币(ETH)注入:
SELFDESTRUCT强制转账: 在 Cancun 升级之前,任意合约执行selfdestruct(victimAddress)均可无条件将其全部余额清空并转入目标地址,绕过目标地址的receive()与fallback()逻辑。Cancun(EIP-6780)实施后,虽然同一交易外部署的旧合约不再支持就地擦除代码,但强制向指定地址转移代币资产的底层行为依然保留。- 验证者/出块奖励与交易费用定向(Coinbase Beneficiary): 区块打包者或验证者可将 Coinbase 地址直接指定为目标合约地址,导致出块奖励直接入账。
- 预先计算部署地址的预先转账:
利用
CREATE或CREATE2可预测合约未来部署地址,在合约代码正式部署前向该地址预先转账。
破坏不变式导致的业务瘫痪
若合约将 address(this).balance 作为确定性系统状态不变式(Invariant)的一部分:
// 危险假设:内部状态记账严格等于实际物理余额
require(address(this).balance == totalDeposited, "State mismatch");
攻击者仅需向合约强制转账 1 wei 的 ETH,即可使上述条件永久无法达成,直接导致协议的清算、取款或状态推进接口完全瘫痪。类似地,对于 ERC-20 代币,通胀代币、通缩代币或直接向合约地址执行 transfer 同样会打破 balanceOf(this) == internalTotal 的强假设。
设计准则:协议内部记账逻辑必须基于持久化状态变量(如 totalShares、totalDeposited)维护确定性状态机,严禁将动态物理余额作为严格相等比较的前提。
生产环境 DoS 检查清单
- 外部交互 Gas 隔离:外部调用必须配置合理的 Gas 上限,避免子调用恶意截留耗尽计算资源。
- 防御 Return Bomb 内存扩张:对不可信外部交互避免使用高级语法接收任意长度的
bytes memory返回值。 - 消除严格物理余额相等断言:禁止使用
assert(address(this).balance == totalShares)作为核心前置校验条件。 - 数据结构分页与解耦:管理逻辑避免遍历可由外部无许可递增的动态数组,强制执行分页提取机制。
- 灾备与降级出口:确保系统在依赖的外部预言机宕机或治理多签失联时,提供基于时间锁的降级保护或用户直接紧急赎回接口。
参考文献与规范
- 基础篇:DoS 攻击
- EIP-150: Gas cost changes for IO-heavy operations
- EIP-6780: SELFDESTRUCT only in same transaction
- Solidity Security: Gas Limit and Loops
整数计算与精度设计进阶
底层加减乘在定长位宽下的回绕机理、Solidity 0.8+ 默认算术检查以及 SafeMath 的历史演进参见 整数溢出。经典案例(如 BEC BatchOverflow、PoWHC 下溢)亦在该篇讨论。
随着 Solidity 0.8+ 在编译器层面内建溢出校验,纯粹由算术回绕导致的严重漏洞大幅减少。现代 DeFi 系统的数值风险主要转向:unchecked 块的边界安全、整数除法截断与舍入方向差、以及份额计算模型在初始状态下的金库通胀攻击(Vault Inflation Attack)。
unchecked 优化的安全边界
在 Solidity 0.8+ 中,unchecked 代码块用于关闭编译器的自动溢出检查,通常用于节省循环索引递增或在数学上已证明不会溢出的运算 Gas。然而,如果在缺乏严格界限证明的逻辑中使用 unchecked,将导致整数回绕风险重新引入:
// 潜在下溢缺陷示例
unchecked {
// 若 recipients.length 为 0,0 - 1 将下溢为 2^256 - 1,引发越界访问或无限循环
for (uint256 i = recipients.length - 1; ; --i) {
_payout(recipients[i]);
if (i == 0) break;
}
}
此外,即便算术溢出被默认检查捕获,复合计算如 (a * b) / c 若在中间步 a * b 超过 \(2^{256} - 1\),即便最终结果小于 \(2^{256} - 1\),依然会触发 Revert,使正常业务流转被阻断,引发计算型拒绝服务。
防范要求:针对大数乘除混合运算,应统一采用 512 位中间精度的数学库(如 OpenZeppelin Math.mulDiv),并建立每个 unchecked 区域的输入范围上下界断言。
整数除法截断与舍入方向(Rounding Direction)
EVM 算术指令不提供浮点支持,所有除法运算 DIV 默认向零截断(Round Down)。在涉及资产流转与份额分配的系统中,截断产生的舍入误差(Dust)若未严格约束,将形成套利或资产流失空间。
舍入方向的设计准则
在资产清算、质押和提取场景中,舍入方向必须严格遵循有利于协议账本完整性原则:
$$\text{shares}_{\text{minted}} = \left\lfloor \frac{\text{assets} \times \text{totalSupply}}{\text{totalAssets}} \right\rfloor \quad (\text{向下取整,避免超发份额})$$
$$\text{assets}_{\text{withdrawn}} = \left\lfloor \frac{\text{shares} \times \text{totalAssets}}{\text{totalSupply}} \right\rfloor \quad (\text{向下取整,避免过度偿还资产})$$
若在铸造份额时向上取整,或在赎回底层资产时向上取整,用户即可通过高频微量操作持续从资金池中榨取尾数差额。
ERC-4626 金库通胀攻击(First Deposit / Inflation Attack)
攻击原理
ERC-4626 标准化了代币化金库的份额换算模型。标准公式如下:
$$\text{shares} = \text{assets} \times \frac{\text{totalSupply}}{\text{totalAssets}}$$
当金库处于初始空池状态(\(\text{totalSupply} = 0\))时,系统通常将汇率退化为 1:1(即 1 单位资产换 1 单位份额)。攻击者利用空池状态与除法向下截断特性,可实施通胀攻击:
- 初始建仓:攻击者先存入 \(1\text{ wei}\) 底层资产,金库铸造并返回 \(1\text{ wei}\) 份额(此时 \(\text{totalSupply} = 1\),\(\text{totalAssets} = 1\))。
- 捐赠拉伸净值:攻击者不通过
deposit(),而是直接通过 ERC-20 的transfer()向金库地址强制转入巨额资产 \(A\)(例如 \(10^6\) 代币)。此时金库实际资产变为 \(\text{totalAssets} = 1 + A\),但份额总量保持 \(\text{totalSupply} = 1\)。每个份额对应的理论净值被拉升至 \(1 + A\)。 - 正常用户存款遭截断:后续受害者存款金额为 \(B\)(假设 \(B < 1 + A\))。根据计算公式: $$\text{shares}_{\text{victim}} = \left\lfloor \frac{B \times 1}{1 + A} \right\rfloor = 0$$ 由于向下取整,受害者资产被转入金库,但获得的份额为 0。
- 赎回套利:攻击者赎回其最初持有的 \(1\text{ wei}\) 份额,取走金库内包含受害者存入在内的全部资产(\(1 + A + B\))。
类似原理曾影响 Compound v2 分叉项目、Hundred Finance 及 WiseLending 等协议,造成大额资产损失。
sequenceDiagram participant Attacker as 攻击者 participant Vault as ERC-4626 金库 participant Victim as 正常存款用户 Attacker->>Vault: 存入 1 wei 资产 Vault-->>Attacker: 铸造 1 wei 份额 (Supply=1, Assets=1) Attacker->>Vault: 外部转账 A 单位资产 (直接捐赠) Note over Vault: 净值畸变 (Supply=1, Assets=1+A) Victim->>Vault: 存入 B 单位资产 (B < 1+A) Note over Vault: 计算 shares = B * 1 / (1+A) = 0 Vault-->>Victim: 铸造 0 份额 (资产沉淀在金库) Attacker->>Vault: 赎回 1 wei 份额 Vault-->>Attacker: 提取全部资产 (1+A+B)
通胀攻击防御机制
- 虚拟偏移量机制(Virtual Shares / Virtual Assets): OpenZeppelin ERC-4626 实现中引入了虚拟资产与虚拟份额偏移量(如各增加 \(10^{\text{decimals}}\)),确保在空池状态下分子分母均具备基准底数,使单一小额存款无法大幅畸变每股净值: $$\text{shares} = \text{assets} \times \frac{\text{totalSupply} + 10^a}{\text{totalAssets} + 1}$$
- 初始化死锁份额(Dead Shares):
借鉴 Uniswap V2 在流动性池首次铸造时将前 1000 个最小单位的 LP 代币永久转移给
address(0),消除拥有极小份额(如 1 wei)垄断全部流动性的可行性。 - 零份额铸造保护:
在存款与铸币函数中强制执行非零断言:
require(shares > 0, "Zero shares minted"); - 账本内部化追踪:
严禁直接以
asset.balanceOf(address(this))作为totalAssets的绝对依据,改用内部状态变量记录合法充值的累计额,使外部直接转账无法单向操纵状态机内部汇率。
生产环境数值与精度检查清单
-
unchecked逻辑前置校验:所有置于unchecked块内的算术操作均具备数学上的上下界断言。 - 溢出导致 DoS 预防:针对乘除混合算式,使用支持 512 位中间精度的乘除库(
mulDiv),消除中间项溢出引发的事务中断。 - 除法舍入方向核查:确认所有涉及代币换算、利息累加的除法操作,截断方向均偏向保护协议系统账本。
- ERC-4626 初始通胀防护:金库或收益池实现必须包含虚拟偏移量或初始化锁定份额逻辑,彻底消除初始通胀攻击面。
- 强制充值不变量解耦:总资产度量逻辑脱离单纯的物理余额查询,避免直接外部转账影响状态计算。
参考文献与规范
- 基础篇:整数溢出
- ERC-4626: Tokenized Vaults Standard
- OpenZeppelin Math.sol mulDiv API
- OpenZeppelin ERC4626 Virtual Offset Implementation
授权机制进阶:兼容调用与撤销范围
基础篇展示了旧额度和新额度都被消费的排序。这里区分三件事:更新代币 allowance 的兼容方式、许可签名的使用方式,以及撤销是否覆盖未来提交的签名。
1. 无限授权的信任边界
approve(spender, type(uint256).max) 给 Spender 长期代扣权限。若路由可升级、存在允许任意 from 的入口,或其管理权被攻破,历史额度都可能被利用。不可升级也不等于没有逻辑漏洞。
按需额度可以限制一次性暴露,但不能追回已经消费的资产。清理旧路由授权时还要识别真实 Token 和 Spender,不能仅凭前端展示名称判断。
2. forceApprove 做了什么?
某些代币拒绝从非零额度直接改成另一个非零额度。以 OpenZeppelin Contracts 5.2.0 为准,SafeERC20.forceApprove 先尝试目标值;若该尝试的返回行为不被接受,再尝试归零并设置目标值。源码
它不是“先读取 allowance,再决定归零”。它解决调用兼容性,不改变 approve 的覆盖语义,也不是用户历史授权的防抢跑证明。
// 位于业务合约内部;这设置的是该合约自身的 allowance。
using SafeERC20 for IERC20;
IERC20(token).forceApprove(spender, amount);
业务合约调用该方法时,Token 看到的 Owner 是业务合约,不是提交交易的用户。不能用它直接修改用户已经给其他 Spender 的额度。
示例工程中的 testForceApproveZeroFirstCompatibilityAndCallerScope 实际验证了先归零代币的更新路径,同时确认业务合约的授权变化不会修改 Alice 自己的 allowance。
forge test --match-test testForceApprove -vv
3. EIP-2612:许可与业务意图分开
permit 签入 Owner、Spender、Value、Nonce 和 Deadline。它授权 Spender 使用额度,没有签入实际 transferFrom 的收款人、swap 路径或最低输出。
所以“许可来源地址一致”只是必要的身份关联,不足以证明调用者可以把钱转给自己。支持 Relayer 的路由还需要业务签名或其他明确授权。自助存款可绑定 from = msg.sender 并给同一地址记账,详见可运行 permit 示例。
允许捕获 permit 失败也可以是正确设计:抢先提交已建立额度时,合法业务应仍能使用它。实际转账失败必须回滚;不能将异常容忍扩大为任意人可使用任意 Owner 的历史授权。
4. Permit2 的两层权限
| 层或模块 | 保存什么权限 | 典型失效方式 |
|---|---|---|
| 原始 ERC-20 → Permit2 | ERC-20 allowance | 在原始 Token 上撤销或消耗 |
AllowanceTransfer | Token/Spender 的额度、expiration、顺序 nonce | 修改额度、到期、推进许可 nonce |
SignatureTransfer | 一次性签名转账的无序 nonce bitmap | 消费或显式失效对应 nonce 位 |
AllowanceTransfer.lockdown 针对传入的 Token/Spender 列表把额度设为零;不是一键撤销所有协议,也不负责清除原始 Token 对 Permit2 的基础额度。实现
清零当前额度不等于使所有离线许可失效。若仍有未消费、未到签名 deadline 的 permit,可以被提交以重新建立额度;需要按目标模块失效 nonce。AllowanceTransfer 的额度 expiration 与 permit 提交的 sigDeadline 也不同。
SignatureTransfer 按无序 nonce 消费一次性签名,不维护相同的持久 Spender allowance。其接收参数并非都自动受普通 permit 消息保护;路由要绑定调用上下文,必要时通过 Witness 签入业务条件。官方集成边界
5. 撤销前后的检查
撤销时分别确认:基础 ERC-20 额度、Permit2 内部额度,以及尚未提交的许可签名。等待相关交易确认后再查询状态;撤销交易被抢先排序前,旧权限仍可能被使用。
本篇 Permit2 部分按官方接口和实现核对,未运行链上部署或完整 Permit2 流程。仓库中的测试覆盖 ERC-20 排序、EIP-2612 与业务身份绑定,不应作为 Permit2 实现验证结果。
参考资料
- OpenZeppelin 5.2.0 SafeERC20
- Permit2 AllowanceTransfer
- Permit2 SignatureTransfer
- Permit2 AllowanceTransfer 源码
预言机操纵与定价机制进阶
预言机向业务提供外部价格或状态。AMM 储备、金库汇率和外部喂价都可能成为估值输入;风险取决于输入可被谁改变、何时读取,以及错误报价能够影响多大的资产敞口。
报价来源与攻击边界
| 来源 | 风险 | 主要检查 |
|---|---|---|
| AMM 即时储备或价格 | 交易可改变现货比例,低深度下偏移成本较低 | 深度、滑点、读价时机与业务用途 |
| LP 或金库汇率 | 捐赠、舍入或回调中的暂态状态可能改变计算结果 | 会计不变量、份额公式、状态同步 |
| 时间加权报价 | 窗口过短、历史不足或持续操纵仍可能造成偏移 | 窗口、观察数据、流动性与敞口 |
| 外部价格 Feed | 陈旧、配置错误、报价单位不匹配或服务故障 | 更新时间、资产对、精度与故障处理 |
合约读取其他合约的 view 函数,不代表返回值必然稳定或不能被间接影响。相同的状态来源用于交换报价和用于借贷抵押估值时,要求也不同。
只读重入与中间状态
流动性退出过程中,若 LP 总供应已减少,而估值使用的资产余额尚未完成相应更新,回调中的查询可能返回失真的每份资产价值。外部借贷协议依据该值放款,就会将局部中间状态变为持久债务。
sequenceDiagram participant A as 回调合约 participant P as 流动性池 participant L as 借贷协议 A->>P: 移除流动性 P->>P: 更新部分会计状态 P->>A: 外部转账或回调 A->>L: 发起依赖池估值的操作 L->>P: 查询份额价值 P-->>L: 返回尚未同步的中间状态 A-->>P: 回调结束 P->>P: 完成剩余状态更新
具体受影响字段和调用顺序依赖池版本。池侧可在外部交互前维持会计一致性,或在不安全窗口拒绝估值;集成方需要确认读价接口的保证。普通写操作互斥锁并不会自动限制所有 view 入口。
Uniswap V3 的时间加权价格
V3 记录累计 tick。令累计值为 \(C(t)\),窗口长度为 \(T\),则平均 tick 为:
$$ \overline{\mathrm{tick}}=\frac{C(t)-C(t-T)}{T} $$
以最小代币单位计价的近似报价为:
$$ P_{\mathrm{TWAP}}\approx 1.0001^{\overline{\mathrm{tick}}} $$
平均 tick 对应几何时间加权价格。tick 可以为负数或零,不能再对 tick 取对数。将报价转换为通常的 token1/token0 单位价格,还需要按双方 decimals 调整,并确认报价方向。
实际实现使用离散 tick、整数累计值及舍入规则。观察记录必须覆盖所需窗口;不能以单个即时 slot0 值代替历史 TWAP。
操纵持续时间与成本
同一交易内的瞬时价格变化没有持续时间,通常不会改写已经累计的历史时间权重。但偿还闪电贷只表示贷款已还,不保证 AMM 储备或价格自动恢复;交易后的偏离可能持续并影响后续观察。
窗口拉长通常增加维持偏离的成本,也增加报价滞后。成本与区间流动性、套利竞争、控制出块能力、手续费、可获得资金和持续时间有关,不能仅用窗口分钟数给出通用安全结论。
操纵成本高于可提取收益是一项经济分析目标,不是仅由 TWAP 名称保证的不变量。敞口评估需要考虑重复操作、多个市场与组合资产,而非只计算一次借贷。
Chainlink 数据校验
latestRoundData() 提供报价和更新时间。价格场景通常需要正值校验、新鲜度检查,以及明确的 Feed、网络、资产对和 decimals。以下是使用该接口的简化读取器:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface PriceFeed {
function latestRoundData() external view returns (
uint80 roundId, int256 answer, uint256 startedAt,
uint256 updatedAt, uint80 answeredInRound
);
}
contract PriceReader {
function read(PriceFeed feed, uint256 maxAge)
external view returns (uint256)
{
(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
require(answer > 0, "Invalid price");
require(updatedAt != 0 && updatedAt <= block.timestamp, "Invalid time");
require(block.timestamp - updatedAt <= maxAge, "Stale price");
return uint256(answer);
}
}
maxAge 需要匹配具体 Feed 的更新规则及业务容忍度,不能对所有资产采用同一固定值。返回整数尚未完成精度归一化;读取成功也不等于适合当前业务估值。
Chainlink 当前文档将 answeredInRound 标为弃用字段;旧轮次语义需按实现判断。多数 Feed 不再使用 minAnswer/maxAnswer,应用断路器不能假定底层聚合器一定提供有效的价格上下限。
L2 集成还需考虑定序器停机与恢复宽限期。报价异常时暂停新增风险、允许减仓、切换数据源或进入其他降级状态,需要业务状态机明确约定。
多源与闪电贷边界
闪电贷受可用流动性、费用和偿还条件约束,可降低单笔交易的资金门槛,但不是无限或零成本资本。无闪电贷的攻击者也可能通过自有资金或跨区块融资影响价格。
多个价格源需要比较报价单位、更新时间和共同依赖。两个来源不自动构成抗单点异常的中位数机制;取最小值对抵押资产和债务资产的风险方向也不同,聚合策略须对应业务含义。
参考资料
签名验证进阶:域缓存、执行上下文与许可容错
基础篇讨论重放、延展性和业务参数绑定。进阶分析重点是:域在什么执行上下文计算,以及哪一层签名真正授权了后续资产操作。
1. 分清三种域缓存场景
| 场景 | 风险或预期行为 |
|---|---|
| 同一实例后来处于不同 chain ID | 构造时固化旧域可能仍接受原域签名 |
| 在新链重新部署并执行构造函数 | 使用该链当前 chain ID,不自动继承原链缓存 |
| 通过不同 Proxy 委托调用同一 Implementation | 必须核对验签地址是否分别绑定各 Proxy |
若分叉后 chain ID 未分离,只动态读取它也无法区分分叉。另行硬编码错误链 ID、复用不适当的初始化值或遗漏域字段,属于不同错误,应按实际部署取证。
固定 OpenZeppelin 5.2.0 的 EIP712 缓存执行地址和 chain ID,只有两者匹配时复用域,否则重算。代理项目还需正确的可升级版本、初始化和域升级策略,不能直接用普通构造函数代替初始化。源码
本博客 EIP-712 示例 使用该库;测试实际改变 chain ID 并验证旧签名失败,也验证另一个合约地址不接受旧签名。它没有复现完整代理部署或真实链分叉。
2. ERC-1271 验的是钱包当前策略
isValidSignature 成功返回 0x1626ba7e。低级调用成功、非空数据或任意非零数都不能代替魔数检查。钱包策略可变,结算时应查询当前结果。ERC-1271
静态查询禁止该调用树内的状态修改,但不禁止外部读取或递归 view 调用。业务在中间状态对外暴露价格、份额或权限时,仍需分析只读重入。Gas 上限和单项失败策略也会影响复杂钱包兼容性。
EIP-7702 委托 EOA 仍由私钥控制,同时可以提供代码策略;不能把委托后的账户描述为“没有私钥的合约钱包”。本例验签库的分派规则固定在 5.2.0,其他版本或签名扩展应重新核对。
3. permit 授权 Spender,不自动授权某次提款
EIP-2612 签名包含 Owner、Spender、Value、Nonce、Deadline。它不包含 Router 后续提款的收款人,也不直接授权某条 swap 路径或最低输出。
所以仅断言 transferFrom 的 from 与 permit 的 Owner 相等仍不足够:如果 Owner 可任意填写、Router 又把资产转给调用者,攻击者可借助用户历史 allowance 偷取资金。
危险模式可以表示为:
+ 任意调用者提供 from
+ 尝试 permit,错误被忽略或不支持接口的 fallback 返回成功
+ transferFrom(from, msg.sender, amount)
= 调用者可能使用 from 的历史额度
缺失的是对业务提款的授权。反过来,也不能把修复简单写成“所有 permit 失败都必须回滚”:合法许可可能已经被其他人提交,重复提交失败却留下了足够的额度。
4. 两种身份模型,对应两种集成方式
| 集成方式 | 独立的业务授权 |
|---|---|
| 用户自己存款 | from = msg.sender,资产进入固定业务合约,给同一调用者记账 |
| 任意 Relayer 代提交 | 验证业务签名,绑定 Owner、收款人、金额、nonce 及其他执行条件 |
自助模型可以把 permit 当作可选的额度建立步骤,随后让真实 safeTransferFrom 决定是否能完成业务。无余额或无额度时仍回滚;在旧额度足够时,当前许可错误或过期也可能不影响普通存款,这是必须写明的行为。
Relayer 模型必须验证业务授权。EIP-2612 的 allowance 只说明 Router 有代扣能力,不说明任意外部调用者能决定收款人。可信转发者与元交易模式也需要定义可信上下文,不能机械套用裸 msg.sender。
两个模型都可以在仓库 examples/ethereum-signatures 中运行:PermitDeposit 绑定调用者,SignedTransfer 签入收款人并消费 nonce。
5. 验证与审阅顺序
- 从真实入口确定资产提供方、收款方和执行者。
- 列出哪些字段进入签名,哪些依赖业务状态。
- 检查域的 chain ID、执行地址和版本变化。
- 检查 nonce、取消和重入时的状态推进。
- 让签名正确而转账失败,确认整个调用的状态回滚。
本例测试覆盖 permit 抢先提交后的合法存款、无额度的失败、任意 from 的反例,以及签名转账的收款人绑定和 nonce 回滚。permit 集成说明
参考资料
可升级代理进阶
代理安全涉及实现地址寻址、业务布局、调用上下文与初始化状态。标准槽位解决元数据的寻址约定,升级权限和业务状态兼容仍由实现与治理机制负责。基础机制见可升级代理漏洞。
EIP-1967 标准槽位
标准槽位由命名空间字符串的 Keccak-256 哈希减一得到:
$$ \text{slot}=\text{bytes32}(\text{uint256}(\text{keccak256}(\text{namespace}))-1) $$
| 用途 | 命名空间 | 槽位 |
|---|---|---|
| 实现地址 | eip1967.proxy.implementation | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc |
| 管理员 | eip1967.proxy.admin | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 |
| Beacon | eip1967.proxy.beacon | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 |
这些槽位与常规线性布局分离,安全分析依赖哈希抗碰撞及抗原像假设,不能表述为数学上不存在任何碰撞。任意存储写入能力仍可能破坏标准槽。
eth_getStorageAt 可读取槽位值,但识别实际路由还需核对代理字节码和版本。例如某些代理将管理员或 Beacon 同时保存在 immutable 字段中,不能只凭槽位推导所有调用行为。
ERC-7201 命名空间布局
ERC-7201 为结构体指定独立存储根,Solidity 等价表达式为:
keccak256(abi.encode(uint256(keccak256(bytes(id))) - 1))
& ~bytes32(uint256(0xff))
外层哈希避免命名空间树与普通布局树的某些直接重叠。低 8 位清零使根按 256 槽对齐,规范讨论的是未来 Verkle 状态结构下可能获得的访问局部性,并不承诺当前 EVM 访问这些槽位统一预热或免哈希。
命名空间结构体仍按普通规则打包;mapping 和动态数组仍需哈希派生其数据位置。对齐不会给每个模块划定仅有 256 槽的固定容量,也不意味着第几个字段必占第几个槽。
隔离降低继承布局变化的耦合,但模块内部重排、改变字段类型或更换命名空间仍可能丢失对旧数据的正确解释。已有线性状态迁移到新命名空间,需要显式迁移方案。
旧版 UUPS 漏洞与调用上下文
OpenZeppelin 2021 年复盘披露的路径发生在缺少直接调用保护的旧实现中:攻击者初始化实现自身,取得其升级权限,再直接调用实现的升级函数,令实现委托执行包含 SELFDESTRUCT 的代码。
在当时的 EVM 规则下,这会删除实现地址的代码。依赖该代码提供业务与升级入口的代理可能失去执行能力。代理状态并未因此被改成攻击者的 owner,漏洞也不是通过直接初始化实现来改写所有代理管理员。
OpenZeppelin 4.3.2 加入 onlyProxy 修复直接调用路径。5.4.0 同时使用实现初始化锁定、活动代理上下文检查和业务升级鉴权。三种保护针对不同状态与入口,不能互相替代。
EIP-6780 后,SELFDESTRUCT 通常转移余额而不删除既有合约的代码和存储;同一交易内创建后销毁的合约有例外。历史攻击路径需要按当时库版本和目标链规则判断。
Nomad 默认根认证问题
Nomad 2022 年事件的核心是未证明消息的默认根值与已接受根发生重叠。以下代码为抽取该因果关系的简化模型,不是完整历史合约:
mapping(bytes32 => bytes32) public messages;
mapping(bytes32 => uint256) public confirmAt;
function acceptableRoot(bytes32 root) public view returns (bool) {
uint256 time = confirmAt[root];
return time != 0 && block.timestamp >= time;
}
若初始化写入 confirmAt[bytes32(0)] = 1,未经证明的消息在 messages 中返回默认值零;后续只检查 acceptableRoot(messages[messageHash]),就可能把没有证明的消息当作有效消息。
漏洞来自默认值与有效状态的解释冲突,不是 mapping 或所有零值初始化天然不安全。防护可以明确区分“未证明”和“已证明”,并验证初始化参数不会使缺省状态通过认证。
升级与迁移约束
- 比较普通布局及命名空间内部的字段变化,不只比较标准实现槽。
- 检查初始化、reinitializer 和迁移函数的调用者、次数及执行上下文。
- 验证升级后业务功能与继续升级能力,兼容 UUID 不能证明这两者成立。
- 验证默认值、已授权值和已处理值在各状态转换中保持可区分。
- 标明库版本、部署链和治理权限,避免将历史漏洞路径套用到其他实现。
参考资料
以太坊进阶
以太坊基础架构(账户模型、EVM 执行环境、交易池机制)参见 以太坊基础。进阶部分主要讨论以太坊网络层的可扩展性演进、计算外包模型与前沿密码学证明系统:
- Rollup 扩容机制:将交易执行下沉至 Layer 2,通过数据可用性(DA)、有效性/欺诈证明以及双向跨链桥将最终安全性锚定在以太坊主网。分析 L2 系统需系统考察状态根提议与争议裁决机制、数据发布成本结构,以及抗审查强制提款通道。
- 零知识证明系统:基于多项式承诺与算术化约束系统的完备性(Completeness)、可靠性(Soundness)与零知识性(Zero-Knowledge)。有效性证明(Validity Proof)保障了链下计算状态转移在代数层面满足预设规则,构成了 ZK-Rollup 与隐私计算的基础。
关于智能合约工程层面的安全审计与进阶复合漏洞模型,已统一归并在 安全与审计 专题中。
Layer 2 Rollup 架构与数据可用性机制
Rollup 是一种基于以太坊的 Layer 2 扩容机制:其核心思想是将昂贵的**事务执行(Execution)下沉至链下,而将关键的状态承诺(State Commitment)与数据可用性(Data Availability, DA)**锚定在以太坊 Layer 1 主网。
在架构层面,Rollup 在以太坊 L1 上表现为一组普通的智能合约(Smart Contracts)。无论是 Optimistic Rollup(OP)还是 Zero-Knowledge Rollup(ZK),系统安全性均最终受制于 L1 验证合约的裁决逻辑、资产金库的托管模型以及底层数据发布层的生命周期约束。
跨层数据互信与资金转移的不对称性
跨层资产转移在 L1 \(\rightarrow\) L2 与 L2 \(\rightarrow\) L1 方向上存在天然的计算与信任不对称性。
sequenceDiagram
autonumber
actor User as 用户
participant L1Contract as L1 桥接托管合约 (Escrow)
participant L1Consensus as L1 共识层 (Finality)
participant L2Sequencer as L2 排序器 / 节点
participant L2Contract as L2 映射代币合约
Note over User, L2Contract: L1 -> L2 充值流程(天然确定性)
User->>L1Contract: deposit() 锁定资金
L1Contract->>L1Consensus: 产生 L1 充值事件并等待 Finalized
L2Sequencer->>L1Consensus: 监听 L1 日志事件
L2Sequencer->>L2Contract: 在 L2 铸造 / 解锁等额映射代币 (秒级入账)
Note over User, L2Contract: L2 -> L1 提款流程(需解决信任证明)
User->>L2Contract: withdraw() 销毁 L2 资产
L2Sequencer->>L1Contract: 提交包含提款事件的 L2 状态根 (State Root)
alt Optimistic 官方桥
Note over L1Contract: 强制等待 7 天挑战期 (Fraud Proof Window)
L1Contract->>User: 挑战期结束且无人质疑,释放 L1 资产
else ZK 官方桥
L2Sequencer->>L1Contract: 提交 ZK 有效性证明 (Validity Proof)
Note over L1Contract: 合约执行 verifyProof() 验证通过
L1Contract->>User: 立即放行提款 (无需等待 7 天)
else 第三方流动性桥 (Fast Bridge)
Note over User, L1Contract: 做市商 LP 监听 L2 事件,直接在 L1 自有资金垫付给用户
end
1. L1 \(\rightarrow\) L2 充值:确定性秒级入账
当用户从以太坊 L1 向 Layer 2 充值时:
- 用户调用 L1 桥接合约的
deposit(),将资产锁入托管金库(Escrow),并触发充值事件(Deposit Event)。 - 信任根源:所有 L2 验证节点与排序器(Sequencer)在底层全天候运行着以太坊 L1 节点。由于以太坊 L1 的安全级别高于 L2,L2 节点能够无条件信任 L1 的状态转移。
- 最终性判定:一旦该笔 L1 交易达到区块确定性(Finalized,通常在 PoS 机制下经历 2 个 Epochs 约 12.8 分钟;在轻量场景下依赖一定区块确认数),L2 排序器便可 100% 确认资产转移不可逆,立即在 L2 为用户铸造或释放对应的映射代币。
2. L2 \(\rightarrow\) L1 提款:证明机制与延迟分化
以太坊 L1 节点不会也不可能去全量运行或同步所有 L2 的虚拟机状态。因此,当用户在 L2 发起提款并将资产销毁后,L1 合约必须通过严谨的逻辑验证该状态转移的合法性。
A. 官方规范桥(Native / Canonical Bridge)
- Optimistic Rollup(欺诈证明机制):
- L2 排序器仅将一批交易执行后的最终状态根(
stateRoot)发布至 L1。 - 由于 L1 合约无法即时断定该状态根是否真实,必须进入7 天挑战期(Fraud Proof Window)。
- 任何诚实验证者若发现状态根与交易数据执行结果不符,可在 7 天内向 L1 提交欺诈证明发起争议仲裁。
- 提款请求必须在 7 天无人质疑后,方可由 L1 金库释放资金。
- L2 排序器仅将一批交易执行后的最终状态根(
- ZK Rollup(有效性证明机制):
- 排序器在提交状态根的同时,提交一份由链下证明系统生成的零知识证明(Validity Proof,如 Groth16、PLONK 或 STARK)。
- L1 验证合约在链上执行代数验证算法。一旦验证通过,即在数学上确保了该批次内所有交易(包括提款)均合法有效。
- 用户提款仅受限于批次打包与 ZK 证明生成的物理时间,无需经历 7 天挑战期,验证通过后即可立即解锁。
B. 第三方流动性快速桥(Fast Bridges,如 Across、Hop 等)
第三方桥能够在数秒至数分钟内完成 L2 到 L1 的资金跨转,其本质是引入流动性做市商(LP)承担回款时间成本与资金垫付风险:
- 做市商在本地运行了独立的 L2 全节点,能够即时感知并自行验证用户的 L2 销毁行为真实有效,不存在双花。
- 只要该笔 L2 交易被排序器确认为不可逆(或对应的数据批次在 L1 敲定),做市商便直接在 L1 使用自有现货流动性垫付给用户,并扣除少量通道手续费。
- 官方桥 7 天后解锁的资产,最终由做市商协议自身回收入账。
L1 上的 Rollup 合约架构与状态记账
在以太坊 L1 上,Rollup 并没有特殊的底层系统协议,其核心就是一组遵循特定规范部署的智能合约系统。
graph TD
subgraph L1 智能合约集群
Portal[Rollup Portal / Bridge Escrow<br/>资产锁定金库]
RollupCore[Rollup 核心逻辑合约<br/>批次流水索引 & 状态提交]
Verifier[验证器合约<br/>OP 交互仲裁 / ZK 电路验证]
end
subgraph 提交内容
SR[状态承诺: State Root]
BlobData[EIP-4844 Blob 数据<br/>交易压缩批次]
end
RollupCore -->|校验与调用| Verifier
RollupCore -->|放行提款指令| Portal
SR -->|写入存储| RollupCore
BlobData -.->|KZG 承诺校验| RollupCore
1. 合约持久化记录的核心状态数据
L1 核心 Rollup 合约维护的核心状态极其有限且紧凑:
| 存储数据项 | 数据类型 | 作用与物理意义 |
|---|---|---|
状态根历史映射 (stateRoots) | mapping(uint256 => bytes32) | 记录每个已提交 L2 批次对应的状态根(Merkle/Verkle Root),作为提款有效性的锚定基准。 |
批次序列号 (batchIndex) | uint256 | 严格自增的批次编号,保证交易执行序列的绝对单调递增。 |
数据承诺引用 (dataHash / versionedHash) | bytes32 | 对应交易批次发布在 L1 Calldata 或 EIP-4844 Blob 上的密码学哈希(如 KZG Commitment 衍生出的版本化哈希)。 |
时间戳与挑战状态 (proposals) | struct Proposal | (针对 OP)记录状态根提交时的 L1 区块时间戳,用于计算 7 天锁定期;记录是否处于被挑战状态。 |
金库资产负债表 (balances) | 状态变量与 ERC-20 账本 | 记录主网用户存入且尚未被提走的 ETH 及所有 ERC-20 代币的物理余额。 |
2. OP 体系合约与 ZK 体系合约的底层职责对比
Optimistic 仲裁合约(以 OP Stack / Arbitrum 为例)
- 乐观接收状态根:允许被授权的定序者直接推入新的
stateRoot,合约无条件记录并启动倒计时计时器。 - 管理交互式欺诈证明(Interactive Fraud Proof):当挑战者对某状态根提出异议时,合约切换为二分查找交互模式(Bisection Game)。
- 单步指令证明执行器(One-step Prover):双方在链下不断二分折半,最终在 L1 仲裁合约中定位到引发分歧的单条指令。L1 验证合约在链上模拟执行该条指令,判断原始提议者是否造假,若造假则回滚该状态根并没收其质押保证金。
ZK 验证合约(以 Scroll、Starknet、zkSync 为例)
- 严苛的数学门禁:合约不提供任何宽限期,拒绝未经证明的状态更新。
- 集成密码学配对验证器(Pairing / Verifier Contract):
function verifyBatches( uint256 prevBatchIndex, bytes32 prevBatchRoot, uint256 newBatchIndex, bytes32 newBatchRoot, bytes calldata proof ) external { // 1. 断言前置状态连续性 require(stateRoots[prevBatchIndex] == prevBatchRoot, "Invalid parent root"); // 2. 将 publicInputs(包含新旧状态根、L1 数据哈希等)与 proof 一并传入验证器 bytes32 publicInputHash = keccak256(abi.encode(prevBatchRoot, newBatchRoot, ...)); bool valid = verifier.verifyProof(proof, publicInputHash); require(valid, "Invalid ZK validity proof"); // 3. 验证通过,状态转移确立 stateRoots[newBatchIndex] = newBatchRoot; }
EIP-4844 Blob 机制与数据可用性(DA)的真相
1. 为什么 Rollup 全面转向 Blob?
在 Cancun 升级之前,Rollup 必须将交易压缩数据作为 calldata 强行塞入 L1 交易中。calldata 会永久占用以太坊执行层节点的内存与磁盘,成本极高。
EIP-4844 引入了Blob 携带交易(Type-3 Transaction):
- 每个 Blob 大小为 128 KB,每个区块目前支持挂载多达 6 个 Blob。
- 与 EVM 执行层完全解耦:EVM 内部代码无法直接读取 Blob 的具体明文数据,仅能通过操作码
BLOBHASH获取其 32 字节的 KZG 版本化哈希(versionedHash)。 - 数据发布费用进入独立的动态定价费用市场(Blob Gas),使得 L2 提交 DA 的成本相较于常规 Calldata 下降了 90% 以上。
2. Blob 的临时性生命周期与剪枝(Pruning)
必须明确的核心事实是:Blob 数据并不具备以太坊主网的永久存储保证。
- 18 天生命周期:以太坊共识层节点对 Blob 的保留周期固定为 4096 个 Epochs(约 18 天)。
- 物理自动丢弃:超过 18 天后,全网验证者节点会将该 Blob 数据从本地磁盘中物理剔除,不再对 P2P 网络提供同步服务。
3. 极端灾难恢复(Disaster Recovery)的局限性
过去社区常有一种简化认知:“因为 Rollup 数据全部上链,即使 L2 节点全部消失,用户也能从 L1 恢复出完整的 L2 链。”
在引入 Blob 机制之后,这种假设在工程现实中必须重新修正:
- 无法直接恢复完整的全历史事务记录(Full Transaction Trace):
若某 Layer 2 的官方定序器、节点运营商集体发生物理灾难瘫痪,且外界第三方归档节点(如区块浏览器、专业存储服务商)均未备份历史数据:
- 18 天前的历史 Blob 已经在以太坊共识层中被清理。
- 新加入的节点无法仅凭以太坊 L1 节点恢复出所有的历史交易明细、智能合约部署代码细节与事件日志(Events)。
- L1 上永久存留的确定性边界:
以太坊 L1 永久保存的仅有:
- 历次批次确认后的最终状态根(Finalized State Root)。
- 资产桥接金库中真实的物理抵押资产。
- 逃生通道(Escape Hatch / Forced Exit)的实际运作:
在极端情况下,用户要证明“我在 L2 上拥有这 10 个 WETH”,必须提供该账户叶子节点连接到最新
stateRoot的默克尔审计证明(Merkle Proof)。 若用户自身未在本地缓存近期的状态树证明数据,且由于 Blob 剪枝导致全网无历史归档,用户将无法重构出这棵证明树,面临资产无法退出的现实风险。
因此,现代 Rollup 体系的去中心化评估,不能仅看是否使用了以太坊作为 DA,还必须考察其是否有独立的数据可用性委员会(DAC)、长效分布式归档网络(如 IPFS、Arweave、BitTorrent 等协议对历史 Blob 的持久化镜像)以及强制提款机制的设计完整性。
定序器(Sequencer)架构与去中心化演进
在理论模型中,Rollup 的安全性依赖以太坊 L1 验证,但在生产现实中,定序器是用户体验与系统信任假设的核心交汇点。
1. 中心化定序器的权力边界与隐患
目前绝大多数主流 Rollup(包括 Arbitrum One、OP Mainnet、Base、Scroll 等)均采用由官方单一实体运营的中心化定序器集群。理解定序器的权力边界是评估 L2 风险的前提:
| 潜在风险与行为 | 权力界限说明 | 严重级别 |
|---|---|---|
| 单点活性故障 (Liveness Failure) | 定序器服务器物理宕机或网络中断时,L2 停止出块,无法响应用户实时交易。 | 高(业务中断) |
| 交易审查 (Transaction Censorship) | 定序器可故意拒绝将特定地址的交易(如借贷清偿、提取抵押品)打包进批次,导致用户面临被动爆仓。 | 高(经济受损) |
| MEV 提取与重排 (Front-running) | 定序器垄断了交易排序权,能够在生成区块时执行无风险的夹子交易(Sandwich Attack)或套利。 | 中高(价值损耗) |
| 虚假软确认违约 (Soft-confirm Risk) | 定序器向前端返回的“秒级到账”仅是其单方签名的准终局性(Soft Finality),在批量提交至 L1 前仍存在回滚可能。 | 中 |
| 盗取金库代币资产 (Asset Stealing) | 不可能。定序器若伪造恶意提款或无效状态根,OP 欺诈证明会触发没收并回滚,ZK 验证器在代数层面直接拒识。 | 不存在 |
2. 强制包含与 L1 逃生通道(Forced Inclusion / Escape Hatch)
为了对抗中心化定序器的审查风险,成熟的 Rollup 协议必须在 L1 合约中设计强制包含机制:
sequenceDiagram
autonumber
actor User as 受审查用户
participant L1Inbox as L1 延迟收件箱 (Delayed Inbox)
participant Sequencer as L2 定序器
participant L2Chain as L2 状态机
User->>Sequencer: 发送 L2 交易 (遭定序器恶意拒绝/忽略)
User->>L1Inbox: 直接在 L1 调用 forceInclusion(txData)
Note over L1Inbox: 记录交易并开启倒计时强制窗口 (如 24 小时)
alt 定序器在窗口期内履职
Sequencer->>L1Inbox: 主动抓取延迟收件箱交易
Sequencer->>L2Chain: 打包进下一个 Batch 提交
else 定序器超时拒绝打包
User->>L1Inbox: 触发强制入块执行 (Force Enqueue)
Note over L1Inbox, L2Chain: L1 合约强制接管排序流水线,冻结常规提议并强制同步该交易
end
- 延迟收件箱(Delayed Inbox / Enqueue Queue):用户若被 L2 拒绝服务,可直接支付 L1 Gas 在主网合约中提交该交易。
- 强制推进保护:协议设置确定性的宽限窗口(Grace Period,如 24 小时)。若定序器在超时后仍未将收件箱交易纳入最新批次,L1 合约将拒绝接受定序器后续提交的任何常规批次,强制优先结算被审查的交易。
3. 定序器去中心化演进路径
社区目前在探索三种主流的定序器去中心化方案:
A. 共享定序网络(Shared Sequencer Network)
- 代表实现:Espresso、Astria。
- 架构机制:Rollup 不再自行维护定序节点,而是将交易流转发给一个独立的去中心化 PoS 共识网络(通常采用 HotStuff 或 CometBFT 协议)。
- 核心优势:多个不同的 Rollup 可以接入同一套共享定序器,从而在定序层原生实现**跨 Rollup 原子交互(Cross-Rollup Atomicity)**与防套利协同。
B. 基于 L1 排序(Based Rollup / L1-sequenced)
- 架构机制:彻底废除独立的 L2 定序网络,直接将 L2 交易的排序权委托给以太坊 L1 验证者(Proposers/Searchers)。
- 核心优势:完全继承以太坊 L1 的抗审查性、活跃度与去中心化安全,系统无额外信任假设。
- 代价权衡:丧失了毫秒级的 L2 链下软确认体验,交易确认延迟与以太坊 L1 出块节拍(12 秒)高度绑定。
C. 动态验证者轮替(PoS Leader Election)
- 架构机制:在 L2 建立原生的代币质押与领导者选举池,每隔固定周期(Epoch)基于 VRF 伪随机数选举出下一任出块定序器,并对违规行为(如双签、断护)实施罚没(Slashing)。
Rollup 架构安全审计检查清单
- L1 提款验证机制:确认官方桥释放资产严格依赖有效性证明通过断言,或经历了完整的防作恶挑战周期。
- 状态根更新权限与治理穿透:排查
stateRoot提交接口是否存在权限滥用,治理多签升级是否配置了具有充分安全窗口的时间锁(Timelock)。 - 强制退出与抗审查通道:验证在 L2 排序器离线或恶意审查交易时,用户是否能直接在 L1 调用
forcedWithdraw()强制提款。 - Blob 版本化哈希绑定:确认 L1 合约在接收状态根时,严格将
BLOBHASH与批次元数据进行了密码学校验,防止提交空哈希或解耦数据欺骗。 - 数据剪枝灾备机制:评估生态是否存在针对 18 天 Blob 剪枝的去中心化持久归档方案,防止灾难发生时状态重构失败。
参考文献与规范
- EIP-4844: Shard Blob Transactions
- EIP-1559: Fee market change for ETH 1.0 chain
- 以太坊官方文档 · Layer 2 Rollups
- Vitalik Buterin: An Incomplete Guide to Rollups
零知识证明:证明成立,同时隐藏秘密
零知识证明(Zero-Knowledge Proof,ZKP)让证明者向验证者证明一个陈述成立,同时不通过证明过程泄露公开信息和结论之外的秘密信息。它既可以用于保护隐私,也可以配合简洁证明验证复杂计算。
证明的可靠性与零知识性是不同性质。限制伪造成功率不代表保护秘密;直接公开秘密答案的验证过程,即使可靠,也不具备隐藏该秘密的零知识性。
公开输入、见证与证明
一个证明任务通常包含以下部分:
| 概念 | 含义 | 示例 |
|---|---|---|
| 公开输入(Public input) | 验证者已知或需要核对的数据 | 一个公开的哈希值 h |
| 见证(Witness) | 使陈述成立的秘密数据 | 满足 H(s) = h 的秘密 s |
| 约束关系(Relation) | 证明需要满足的规则 | 对 s 计算哈希后,结果等于 h |
| 证明(Proof) | 证明者按协议生成的可验证数据 | 验证者检查的数据 π,而不是 s 本身 |
在这个例子中,公开陈述是“存在秘密 s,使 H(s) 等于 h”。证明者利用 s 生成证明,验证者用 h、证明和系统的公共参数进行验证,不必拿到 s 后自己计算哈希。
常用的知识证明还要求证明者确实掌握相应见证,不能只碰巧构造出可接受的证明。这个要求与不泄露见证的零知识性有关联,但不是同一个性质。
把哈希值直接交给对方,或者说“我知道它的原文”,本身不是零知识证明。还需要具体协议,让验证者能够检查陈述。若公开的 h 对应一个很容易猜测的秘密,攻击者仍可能自行枚举;零知识证明不会消除公开信息本身的泄露。
三个基本性质
完备性(Completeness):陈述为真,证明者有合法见证,双方遵循协议时,验证应当成功。具体系统可能允许可忽略的完备性误差,不能把正常失败率与安全性混为一谈。
可靠性(Soundness):陈述为假时,恶意证明者也很难使验证者接受。协议通过数学结构和安全参数限制错误接受的可能性;一些系统的保证还依赖攻击者的计算能力及密码学假设。
零知识性(Zero-knowledge):证明过程不额外泄露秘密信息。形式上,验证者所见的信息应能在不知道见证的情况下,仅用公开输入模拟出来,或与这样的模拟不可区分。这比“没有直接写出密码”更强。
可靠性回答“能不能骗过验证”,零知识性回答“验证会不会泄密”。增加挑战轮数可能降低某个协议的欺骗概率,却不能把一个原本泄露答案的问答过程变成零知识证明。
交互式证明的秘密门模型
设洞穴有左右两条通道,深处由一道需要密码才能打开的门相连。证明者声称知道密码,验证者想确认这件事,但不想获得密码。
- 验证者在洞外等待,证明者先随机选择一条通道进入,验证者看不到选择。
- 证明者进入后,验证者才随机要求她从左侧或右侧回来。
- 知道密码的人可以穿过门,从指定通道返回;不知道密码的人,只能在要求碰巧与自己所在通道一致时成功。
- 在挑战独立、随机且无法提前预测的条件下,重复 n 次,不知道密码的人全部成功的概率为
2^(-n)。
验证者看到的是她按要求回来,没有看到密码或开门方法。关键在于挑战发生在证明者选择通道之后,而且验证者不能观察隐藏路径;如果这些条件改变,结论也可能改变。
秘密门模型描述挑战顺序与重复试验的关系。具体密码学协议还需要定义消息、模拟器与安全假设,模型中的成功概率不能代替形式化的零知识性证明。
从交互式到非交互式
交互式证明需要证明者与验证者交换挑战和响应。非交互式证明则允许证明者在系统参数确定后生成一份证明,由验证者独立检查,更适合链上合约和异步系统。
非交互式不等于“公布一个很难的问题,再把秘密答案交上去”。验证者收到的仍然是密码学证明,而不是私人经历、密码或见证本身。它的安全性依赖具体构造和公共参数。
部分协议使用 Fiat–Shamir 变换,用对公开输入和协议消息的哈希生成挑战,从而减少交互。它需要满足相应的安全条件,不能简单认为任何问答协议都能通过一次哈希安全地改成非交互式证明。
zk-SNARK 与 zk-STARK
| 类型 | 主要特点 | 需要注意的条件 |
|---|---|---|
| zk-SNARK | 强调简洁、非交互和知识证明,证明较小、验证较高效 | 底层构造多样,部分方案需要可信设置,不能说所有 SNARK 都需要 |
| zk-STARK | 强调可扩展、透明设置,主要依赖哈希等工具 | 通常无需秘密的可信设置;证明大小和验证成本仍取决于具体方案 |
可信设置指某些方案需要产生公共参数,而且某些生成过程中的秘密必须妥善销毁。透明设置避免了这类秘密依赖,但不意味着系统不再需要密码学假设、正确实现和安全参数。
这两类方案的区别涉及证明大小、生成与验证成本、设置方式和安全假设。不能仅用“把真假证明者的答题概率进一步拉开”概括它们,也不能只凭名称判断某个应用是否保护隐私。
应用中的证明对象
证明年龄满足要求
用户可以用受信任机构签发的凭证作为见证,证明凭证签名有效,且其中的出生日期满足公开日期下的年龄门槛,而不公开完整出生日期。
这里需要同时验证凭证来源和日期条件。如果只让用户自行填写出生日期,再证明计算没有错误,就无法证明现实中的年龄。零知识证明保证所选约束成立,外部数据是否可信仍需要凭证、签名或其他机制支持。
验证交易与链下计算
隐私交易可以证明金额守恒、输入资产可用及授权有效,同时隐藏选定的金额或参与者信息。但是否真的隐藏这些信息,还取决于交易格式、公开输入及协议设计。
有效性 Rollup 使用证明来验证一批状态转换是否符合规则,减少基础链重复执行的工作。这强调的是计算正确性和验证效率;若交易数据仍公开发布,使用 ZK 技术也不会自动让交易内容保密。
零知识证明的边界
证明只能保证编码进去的规则。若电路遗漏条件、签名没有绑定用途、公开输入过多,或者验证合约接受了错误参数,即便底层证明系统可靠,应用仍可能出错或泄露信息。
安全参数用于限制伪造的可能性,但不能把所有系统概括为某个固定的“99.99% 正确率”。还应区分协议误差、密码学假设和工程实现错误;实际风险未必来自随机猜测。
零知识性也不隐藏公开结论本身。证明“已满 18 岁”已经透露了一个年龄范围,多次提交不同门槛的证明还可能缩小这个范围。应用应先确定需要公开什么,再选择证明的陈述和参数。
参考资料
- ZKProof Community Reference:术语、性质与安全模型
- ZKProof:关系、见证与零知识证明的定义
- Ethereum:零知识证明及常见证明系统
- Ethereum:ZK Rollup 的有效性证明与数据可用性
区块链通用基础
资料核对:2026-10-05。
区块链是一组参与者按照共同规则验证交易、排列历史并维护状态的系统。学习时先分清授权、执行、共识和数据可用性,再看它们如何组合成支付、交易和跨链应用。
从一笔交易开始
以代币转账为例:用户授权一项操作,节点检查交易,执行环境计算余额变化,共识协议决定接受哪段历史。应用随后读取交易结果,并根据所在链的最终性规则判断是否完成结算。
| 环节 | 解决的问题 | 不能单独保证的事 |
|---|---|---|
| 签名与授权 | 谁能发起操作,允许操作哪些资产 | 签名者理解业务含义、密钥始终安全 |
| 状态与执行 | 操作是否合法,余额和合约数据如何变化 | 代码没有漏洞、输入价格真实 |
| 共识与最终性 | 如何选择历史,何时降低或排除重组风险 | 应用权限正确、资产具备链外保障 |
| 数据可用性 | 验证和重建状态所需的数据能否取得 | 任意附件永久保存、访问始终免费 |
| 应用与治理 | 业务规则、升级和故障处理由谁控制 | 管理员永不作恶、经济模型必然可持续 |
交易的具体字段和生命周期可参阅 Ethereum 交易文档;不同链的授权与执行模型并不相同。
学习顺序
- 区块链基础:节点、哈希、签名、状态转换与最终性。
- 共识机制和密码学:理解一致性与授权成立的条件。
- 应用与技术方向:了解当前应用,以及它们依赖的基础设施。
- 开发工具与数据入口:按任务选择工具,学会阅读指标和官方文档。
进入具体开发时,可沿以太坊账户模型 → ERC-20 → EIP-712 → 签名安全学习。
另一条路径从 Solana 基础进入账户与指令模型。
如何判断一个应用的边界
资产记录要区分余额、控制权和链外权利。代币合约可以改变链上持有关系;现实资产的托管、交付、赎回和争议处理仍需明确的外部安排。NFT 的 tokenId 与元数据也不能独立证明版权归属。
存证要区分数据承诺和数据真实性。哈希用于检验数据是否与承诺一致,无法判断上传者是否伪造了原始数据。供应链、设备测量和价格输入还需要相应的身份、预言机或业务核验流程。
存储要区分内容寻址和持续保留。IPFS 的 pin 用于防止本节点回收指定内容;长期访问还需要在线节点、备份和保留安排,见 IPFS 文件固定文档。
扩容要分别检查执行、证明、数据发布和退出路径。Rollup、侧链和状态通道的安全条件不同,不能仅凭交易便宜或 TPS 高判断其安全性,见 Ethereum 扩容文档。
阅读“最新进展”的方法
协议提案、客户端支持、测试网激活和主网激活是不同阶段。阅读升级消息时,记录日期、网络、版本与原始公告;阅读工具教程时,先检查主版本及依赖兼容性,再执行其中的命令。
动态指标应保留查询入口和统计口径。这里的应用与技术方向给出截至核对日期的协议进展;开发工具与数据入口说明如何查询后续变化。
区块链基础
资料核对:2026-10-05。
区块链通过区块承诺、网络传播和共识规则维护交易历史。哈希链接约束历史数据之间的关系,数字签名表达授权,共识协议决定不同候选历史中哪一条被接受。
分布式账本与节点
节点的角色和数据保留范围并不相同。全节点验证协议规则,归档节点保留更多历史状态,轻客户端依据区块头和证明检查部分信息。开放参与不代表所有节点都持有完整历史数据。
交易被接受需要满足签名、余额、执行和共识规则。比特币 PoW 的链选择依赖累计工作量,以太坊 PoS 的投票依赖质押权重;二者都不是简单按联网节点的人数多数表决。
密码学机制
| 机制 | 作用 | 边界 |
|---|---|---|
| 哈希 | 为数据生成承诺,连接区块和状态 | 数据哈希不证明原始输入真实,也不负责保密 |
| 数字签名 | 验证消息由相应密钥授权 | 密钥泄露或签名业务含义错误仍会造成损失 |
| Merkle 树及其变体 | 用根哈希承诺数据集合,支持包含证明 | 包含证明仍需可信的根和可用的数据 |
ECDSA 是数字签名算法,不是对交易内容进行加密的算法。公开链上的交易和状态通常可被观察;隐私功能需要额外的协议设计。
一笔交易如何改变状态
以以太坊上的普通代币转账为例,用户向代币合约发送调用,合约执行余额检查并更新自己的存储。代币余额属于合约状态;账户支付的 ETH Gas 费用属于另一项状态变化。
flowchart LR
A[构造并签名交易] --> B[提交到节点]
B --> C[进入候选区块]
C --> D[按顺序执行并验证]
D --> E[读取回执与状态]
E --> F[等待所需确认或最终性]
这张图展示一般处理顺序,交易也可能被拒绝、长时间未被打包或被替换。节点收到交易、交易进入区块、执行成功以及所在区块达到最终性,分别表达不同的事实。
在以太坊中,已被包含但执行失败的交易通常仍消耗 Gas,并推进发送者的交易 nonce;失败调用的业务状态变化被回滚。因此,交易哈希不能独立证明转账成功,需要检查回执及相应状态,见 Ethereum 交易文档。
eth_call 常用于读取或模拟执行,它本身不提交交易,也不将模拟结果写入链上。合约事件则是执行产生的日志,分析业务结果时还要核对日志来源和合约状态,见 Ethereum JSON-RPC 文档。
共识与最终性
- PoW:通过计算工作竞争提议权,以累计工作量选择历史。重组风险取决于攻击者算力、确认深度和网络条件。
- PoS:用质押建立参与权重,具体协议另行定义分叉选择、投票、罚没及最终性。
- BFT:在明确的故障数量与网络假设下,通过法定人数证据和跨轮规则维持一致性。
哈希链让历史修改可被检测,但重新计算哈希本身不等于完成有效重组。攻击者还需要满足工作量或投票等共识约束。确认深度下的成功概率与累计工作成本是不同量,不能统一表述为计算成本指数增长。
概率最终性表示重组风险随确认积累下降;确定性或可追责最终性则由协议投票和故障假设定义。不同协议的“确认”不能直接按区块数量等价比较,见共识机制。
执行、网络与扩展
节点通过 P2P 网络传播交易和区块,执行规则定义交易如何改变账本。智能合约扩展了状态转换的表达能力,但程序部署后仍需外部交易或调用触发执行。
Rollup、状态通道和侧链具有不同的数据发布、结算和故障恢复方式。扩容效果与交易类型、数据可用性和安全假设有关,不能仅凭某个 TPS 数值判断整个系统的能力。
以太坊早期的执行分片规划与后续围绕 Rollup、Blob 和数据可用性采样的路线不同。不能把“64 个执行分片”描述为当前已经部署的主网机制。
当前路线可结合 Fusaka 官方说明阅读:该升级已于 2025-12-03 上线主网,其中 PeerDAS 支持 Blob 数据可用性采样。采样和容量扩展并不自动提供永久历史存储;后续升级状态见应用与技术方向。
参与权限与应用边界
公有链通常允许开放发送交易或运行节点;联盟链和私有系统通常限制参与者身份。参与权限、验证权和治理权可以分别设置,分类名称本身不能证明系统的去中心化程度或容错能力。
代币转移、借贷、存证和供应链记录是不同的应用场景。链上记录能证明某份数据在特定规则下被接受,却不能自动验证实物、身份、价格或链外权利。此类信息依赖预言机、身份系统和外部业务流程。
主要约束
- 吞吐量、状态增长、带宽和节点验证成本之间存在权衡。
- 跨链交互引入源链验证、消息重放、桥接权限和数据可用性问题。
- 共识安全不能替代合约权限检查、经济模型分析和密钥管理。
- 治理升级和管理员权限会改变系统行为,属于整体信任模型的一部分。
阅读实例时先记录哪些条件
| 条件 | 需要记录的内容 |
|---|---|
| 网络与协议 | 主网、测试网或本地链,当前激活的协议升级 |
| 执行环境 | 虚拟机或程序模型,编译器与目标版本 |
| 权限与输入 | 签名者、管理员、预言机和外部凭证提供者 |
| 数据与恢复 | 验证所需数据的来源,重组、退出和故障恢复方式 |
这些条件决定示例结论可以适用于哪里。掌握基础后,可从开发工具与数据入口选择本地工具,并对照以太坊账户模型观察一次实际的状态变化。
参考资料
应用与技术方向
资料核对:2026-10-05。本文按应用机制和开发问题组织,协议升级状态以所链接的官方资料为依据。
理解一个方向,可以依次回答三个问题:它改变了什么状态,谁提供可信输入,出现故障后用户如何恢复资产或退出。
支付、稳定币与资产代币化
支付应用需要处理资产发行、转账、费用、到账确认和赎回。以 USDC 合约为学习对象时,应同时查看铸币、暂停、黑名单和升级权限,而不只看 transfer,见本站 USDC 源码分析。
现实资产代币化还需要连接身份、持有资格和转移限制。ERC-3643定义了身份登记、可信凭证签发者和合规检查等接口,可以作为研究受限代币的技术入口。
这些接口表达链上执行规则。资产是否真实存在、持有人能否赎回、发行方承担何种义务,仍需核对具体产品的外部安排;采用某个代币标准不能独立证明这些条件。
学习重点:从普通余额转移开始,再加入授权、身份条件、管理员权限和异常处理。可先读 ERC-20、EIP-2612与 ERC-3643。
DeFi:从交换规则到可扩展池逻辑
交易、借贷和金库分别维护流动性、债务或份额。组合这些协议时,需要核对价格来源、精度、清算条件和调用权限,不能把可组合性当作无条件安全。
Uniswap v4 是现阶段值得补充的协议学习入口。Hooks允许在交换、增减流动性等操作的指定位置执行自定义逻辑;Flash Accounting通过记录操作中的余额差额组织结算。
学习 v4 时,应分析具体池使用的 hook、回调权限、费用逻辑及结算要求。核心协议的审计范围不能自动覆盖任意第三方 hook,官方提供了独立的 安全框架入口。
本站的 Uniswap V2与 V3适合学习既有 AMM 模型;继续研究 v4 时,应按其自己的架构和接口阅读。
扩容:执行、数据与结算分别演进
以太坊的扩容学习路径已经从早期执行分片设想转向理解 L1 执行能力、Rollup、Blob 和数据可用性。Blob 承载的数据与普通合约状态用途不同,也不等于永久文件存储。
| 进展 | 截至 2026-10-05 的状态 | 需要理解的变化 |
|---|---|---|
| Fusaka | 2025-12-03 已在以太坊主网上线 | PeerDAS 改变 Blob 数据分发与可用性检查方式,为数据容量扩展提供基础 |
| Glamsterdam | 官方公告计划于 2026-10-06 在 Sepolia 激活;Hoodi 和主网日期尚未确定 | 重点包括协议内提议者与构建者分离(ePBS)、区块级访问列表(BAL)及 Gas 定价调整 |
状态依据:Fusaka 官方说明与 Glamsterdam 测试网公告。测试网排期不能作为主网功能已经生效的证据。
分析某个 L2 时,要继续检查证明系统是否实际运行、数据发布在哪里、排序器停止后能否提交交易、用户如何退出,以及谁可以升级合约。具体概念见本站 Rollup;项目数据可从 L2BEAT查看。
账户与签名:授权范围成为核心问题
Pectra 引入的 EIP-7702 允许 EOA 通过签名授权设置代码委托,使账户能够使用委托代码提供的能力。批量调用、费用代付等体验还需要相应的代码和服务支持,见 Pectra 公告。
授权委托代码与给某个代币设置 allowance 具有不同的权限范围。理解钱包操作时,要分别识别普通交易、消息签名、代币授权和代码委托,见 EIP-7702 规范。
学习路线可从账户模型、ERC-4337进入,再对照 EIP-712与 ERC-1271理解消息域和合约账户验签。
跨链:消息验证与传递各有职责
跨链操作通常包含来源事件、目标端验证、消息传递和应用执行。中继者传来一份数据,并不意味着接收端已经验证其有效性;源链重组、重复处理、超时和恢复路径也需要单独设计。
当前 IBC-Go 文档同时介绍 IBC Classic 与 IBC v2。自 ibc-go v10 起,两种协议可以存在于同一软件版本中,但连接使用的协议需要明确区分。
IBC v2 的客户端验证模型可采用轻客户端、多签或证明验证器等方案。因此,“使用 IBC”不能直接推出所有连接共享相同信任假设。阅读实现时,应确认客户端类型、更新规则、中继流程与应用的消息处理条件。
隐私与链外数据:先确定证明的命题
零知识证明可以在不公开某些输入的情况下证明指定关系成立,但隐私范围取决于具体协议。公开地址、金额、调用模式或链外日志仍可能暴露信息,见 Ethereum 零知识证明介绍。
将价格、身份、设备数据或 AI 输出写入链上时,链上验证只能覆盖被编码的规则。数据采集、签发者权限、更新频率和纠错流程,仍是应用需要明确处理的部分。
如何观察这些方向
先按业务问题选择指标:支付看转账与结算,DeFi 看流动性和使用情况,扩容看数据与执行成本。避免用单一排行榜替代机制分析。
DeFiLlama Chains主要提供各链的 DeFi 数据,其中 TVL 是锁定资产价值,不是链或代币的市值。价格变化和统计范围都会影响数值,具体口径见 DeFiLlama 数据定义。
下一步可读开发工具与数据入口,从一个本地测试项目开始,再观察对应的链上交易和数据。
共识机制
无中心裁判时,节点要对同一批交易及其顺序达成一致。协议通常拆成两层:
- 抗女巫 / 资源证明:谁有资格提议或投票(算力、质押、身份列表)。
- 决策规则:冲突提案如何收敛(最重链、分叉选择、quorum 投票、随机抽样)。
Nakamoto 系
开放网络、匿名加入。提议权由资源竞赛给出,冲突用「累积最多工作(或等价权重)」的链作为唯一历史。最终性是概率的:确认深度增加通常降低重组成功概率,但不存在协议层的不可逆提交;累计工作成本与攻击成功概率需分别分析。
质押系
用锁仓权益代替算力做抗女巫。PoS 本身不规定如何在冲突链之间做最终确认;具体链还要叠加分叉选择、检查点投票或委托人集合。
- PoS 通论:nothing-at-stake、长程攻击、弱主观性、罚没。
- Gasper:以太坊的 LMD-GHOST + Casper FFG。
- DPoS:票选出块人集合,轮转出块。
- Algorand:VRF 随机抽签委员会 + BA*。
BFT 系
常见等权配置使用 n=3f+1 个验证者和 2f+1 票法定人数。证书相交与跨轮投票、锁定及提交规则共同保证一致性;一般委员会规模或加权投票需要相应计算阈值。
- BFT 模型:故障模型、网络假设、\(3f+1\)、safety / liveness。
- PBFT:三阶段 + view-change,通信 \(O(n^2)\)。
- HotStuff:星型通信、QC、链式流水线,视图切换 \(O(n)\)。
- Tendermint / CometBFT:height/round、锁定规则、单块即时最终性。
- TBFT:Tencent BFT,长安链的 Tendermint 变体,轮转出块 + 非确定性交易剔除。
- Execute-Order-Validate:Fabric 的排序共识,排序层 Raft / SmartBFT,正确性在应用层。
- Streamlet:连续三个 epoch 的相邻公证块终局中间块及祖先。
Raft / Paxos 是崩溃容错(\(n \ge 2f+1\)),不是 BFT,对照见 BFT 模型。
时钟、抽样与 DAG
把「时间 / 数据传播」从「排序投票」里拆出去,或用反复随机抽样代替全员 quorum。
- PoH 与 Tower BFT:迭代哈希时钟 + 指数锁定投票。Solana。
- Avalanche Snow:\(k\)-抽样 metastable 共识。
- DAG-BFT:Narwhal 类数据面与 Bullshark / Mysticeti 排序面。
演进谱系
协议不是孤立的,多数是前一条路线的修补或拆分:
flowchart LR PoW[Nakamoto PoW<br>最重链] --> GHOST[GHOST / 叔块] GHOST --> Gasper[Gasper<br>LMD-GHOST + FFG] PoS[PoS 抗女巫] --> Gasper PoS --> DPoS[DPoS 票选轮转] PoS --> Algorand[Algorand<br>VRF 抽签 + BA*] BFT[PBFT 三阶段] --> HotStuff[HotStuff<br>链式 QC] HotStuff --> Streamlet[Streamlet<br>公证链终局] HotStuff --> DAG[DAG-BFT<br>数据 / 排序分离] BFT --> CometBFT[CometBFT<br>height / round] CometBFT --> TBFT[TBFT<br>长安链] BFT --> EoV[Execute-Order-Validate<br>排序 + 背书验证] PoW --> Snow[Avalanche Snow<br>抽样 metastable] PoS --> PoH[PoH 哈希时钟 + Tower]
这些机制分别处理参与权、历史选择、数据传播和最终确认。图中的关联表示机制关系,不代表不同协议具有相同的安全假设或提交规则。
对照
| 家族 | 提议权 | 决策规则 | 最终性 | 验证者集合 | 通信 |
|---|---|---|---|---|---|
| Nakamoto / PoW | 算力 | 最重链 | 概率 | 开放 | 区块 gossip |
| PoS + Gasper | 质押抽签 | LMD-GHOST + FFG | 约 2 epoch 经济最终 | 开放,有质押门槛 | 证明 gossip |
| DPoS | 票选代表 | 轮转 + 不可逆块 | 快,弱于 BFT | 开放治理、出块人很少 | 代表间传播 |
| PBFT | 主节点 | prepared / committed | 确定 | 许可,\(n=3f+1\) | \(O(n^2)\) |
| HotStuff | 领导者 | 三链 / 两链 QC | 确定 | 有界 | 星型 \(O(n)\) |
| CometBFT | 轮转提议者 | +2/3 prevote/precommit | 单块确定 | 有界 | gossip |
| TBFT | 每 \(N\) 块轮转 | +2/3 prevote/precommit | 单块确定 | 有界,联盟链 | gossip |
| Execute-Order-Validate | 排序层 leader | 只排全序,背书 + 验证 | 单块确定,无分叉 | 有界,联盟链 | 排序 + gossip |
| Streamlet | 每 epoch 选择领导者 | 三个连续 epoch 的相邻公证块,终局中间块 | 假设内确定 | 有界 | 提议、投票与证据广播 |
| Algorand | VRF 抽签 | BA* 两阶段投票 | 一轮概率确定 | 开放,有质押门槛 | 随机委员会门限聚合 |
| PoH + Tower | 日程领导者 | 投票锁定 | 回滚有代价 | 开放,硬件门槛 | 领导者广播 |
| Snow | 反复抽样 | 采样多数收敛 | 概率、收敛快 | 开放 | \(O(k)\) 每轮 |
| DAG-BFT | 验证者并发播批次 | 在 DAG 上提交 | 确定 | 有界 | 数据面与共识面分离 |
PoW(Nakamoto)
工作量证明把提议权交给能展示指定难度哈希原像的节点。Bitcoin 用 SHA-256 双哈希;网络接受累积工作最多的链(最重链,不是单纯最长链)。开放加入,无身份列表。
出块规则
候选块头包含:版本、父哈希、Merkle 根、时间戳、nBits(目标压缩)、nonce。矿工搜索 nonce(及多余 nonce)使得
\[ H(\textit{header}) \le T \]
\(T\) 由难度编码。验证只需一次哈希,搜索期望次数为 \(2^{256}/T\)。
Bitcoin 约每 2016 块(目标两周)调整一次难度:
\[ T_{\textit{new}} = T_{\textit{old}} \cdot \frac{t_{\textit{actual}}}{1209600} \]
\(t_{\textit{actual}}\) 夹在 \([1/4, 4]\) 倍目标区间,防止一次调整过度。出块间隔 \(\gg\) 传播延迟时,诚实矿工的块多数能成为主链延伸。
最重链
分叉选择:在所有合法链中选 累积难度之和最大 的那条。等长但总工作不同时,工作多的赢。重组深度 \(k\) 的成本约为重做这 \(k\) 块的哈希工作。
最终性是概率的:\(k\) 个后续块之后被替换的概率随诚实算力占比指数下降,协议不提供不可逆提交。交易所的「\(N\) 确认」是风险阈值,不是共识原语。
重组概率
设诚实算力占比 \(p\)、攻击者 \(q = 1-p\)。攻击者落后 \(z\) 块时,要在追上期间持续比诚实侧多出块,胜率可用赌徒破产模型估计:\(q < p\) 时
\[ P_{\textit{catch-up}}(z) \approx (q/p)^z \]
\(q \ge p\) 时则为 1(多数算力必胜)。
- 一次成功重组 = 攻击者自落后处连续赢得每一轮的出块竞赛,每一轮的劣势概率相乘。
- 例:\(p=0.9, q=0.1\),等 \(z=6\) 块后重组的概率约 \((1/9)^6 \approx 2\times 10^{-6}\);\(z=10\) 降至约 \(10^{-10}\)。
- 该式是理想化模型:假定攻击者固定落后、不计算自私挖矿的算力复用优势,也不计入网络传播延迟——真实阈值更低,见「安全假设」。
安全假设
诚实算力占比 \(> 1/2\)(对抗自私挖矿等策略时,实际阈值更高)。攻击者持续超过该比例可以:
- 双花:私下挖更重链,待商户确认后再广播。
- 审查:拒绝包含特定交易的块。
- 无限制重组。
无法凭空增发(增发规则在脚本/共识验证里),也不能伪造签名。
自私挖矿:发现合法块后不立即广播,先私下延长自己的链,抢在诚实侧揭示时再批量放出,让诚实矿工在一轮里白做工作。效果是攻击者以低于 1/2 的算力也能取得高于份额的收益,并把重组所需的实际算力阈值抬高。对策是同步(沿用已广播的块)与后续块中奖惩;纯协议层面,诚实算力阈值实际高于 1/2。
抗女巫
一次投票的成本是焦耳,不是身份。无许可:任意硬件可加入。代价是能耗,以及 ASIC 与矿池导致的算力集中——协议仍按哈希计数,不识别「多少台机器」。
变体
| 变体 | 要点 |
|---|---|
| 叔块 / GHOST | 侧枝块仍给奖励、计入难度,降低大矿工优势。以太坊合并前使用。 |
| 内存硬哈希 | Scrypt、Ethash、RandomX,提高 ASIC 门槛。 |
| 合并挖矿 | 同一 PoW 提交给多条链。 |
PoW 只解决提议权与分叉权重。账户、脚本、难度算法都是附加规则。质押系用权益代替哈希,见 PoS。
PoS 通论
权益证明用锁仓代币代替哈希做抗女巫:投票权与质押成正比。PoS 本身不是完整共识,只规定谁有资格提议或投票;冲突如何收敛要叠加分叉选择或 BFT,见 Gasper、DPoS、CometBFT。
资源证明
验证者锁定本币,权重 \(w_i = s_i / \sum s\)。提议者或委员会按权重抽签(RANDAO、VRF、轮转)。作恶导致质押被罚没,使「投票」有成本。早期 Peercoin 用币龄(持有量 × 时间);以太坊等现代实现只按有效余额,不奖励锁得久。
最低质押(如 32 ETH)是协议参数,不是理论必需。Pectra 之后以太坊有效余额上限为 2048 ETH(EIP-7251),允许合并验证者而不改权重语义。
Nothing-at-stake
复制一条 PoS 链的成本接近零:验证者可在所有分叉上签名,期望收益更高。PoW 做同样的事要分裂算力。没有额外约束时,分叉选择失去唯一性。
对策:
- 罚没:对同一高度(或冲突 FFG 投票)的两份签名可提交链上,扣除质押。
- 分叉选择绑定最新消息:LMD-GHOST 每个验证者只计最近一条投票,见 Gasper。
- 确定最终性:委员会 BFT 提交后拒绝回滚,见 CometBFT。
长程攻击与弱主观性
攻击者买(或解锁后取出)创世附近的旧私钥,从很深的检查点另长一条合法链。新节点或长期离线节点无法仅凭协议规则判断哪条是「真」链——两条在密码学上都合法。
PoW 没有这个问题:旧密钥不能补回已经消耗的哈希。PoS 必须引入 弱主观性:节点以近期检查点(最终确定块 + 验证者集合)为信任根,拒绝过深重组。离线超过弱主观性周期后需要从可信源获取检查点。
攻击的完整形态需要两段:第一段制造历史(获取旧私钥 + 在另一条链上累计区块奖励,伪造一条长得过的链),第二段定向投递(对离线节点/交易所同步该链,后者无法区分)。弱主观性挡的是第二段——节点信任根越新,攻击者要伪造的历史越深、成本越高;代价是新节点和恢复节点必须信任链下源(节点发现、交易所检查点)。这与 Gasper 的弱主观性周期、DPoS 的链上代表轮换是同一信任权衡。
罚没条件(抽象)
具体规则因链而异,逻辑形状相同:
| 类型 | 证据 | 效果 |
|---|---|---|
| 双签 / 双提议 | 同一轮次两个不同值的签名 | 罚没 + 驱逐 |
| 环绕投票 | 投票对 \((s_1,t_1)\) 环绕 \((s_2,t_2)\) | 罚没(Casper FFG) |
| 不作为 | 长期不投票 | 通常缓慢泄漏而非一次罚没 |
罚没依赖「证据能上链」。若攻击者控制了最终性所需的投票权,可能同时审查证据。
抽签
- 链上 RNG:RANDAO 混合提议者揭示的随机数,可被最后揭示者轻微偏置;以太坊用 RANDAO + 延迟揭示降低操纵。
- VRF:验证者本地计算可验证随机函数,中签则出示证明(Algorand)。通信更少,偏置面不同。
- 轮转:按权重排列日程,Solana 领导者日程、CometBFT 的
proposer(h,r)。
抽签解决提议权,不解决冲突提交。
验证者经济学
共识的安全阈值要换算成“作恶要花多少钱“:
- 机会成本:质押锁定资本,收益是出块/投票奖励减去交易手续费与罚没期望。收益率低到不足以覆盖锁定成本时,节点数量萎缩,质押集中度上升。
- 罚没 vs 收益:双签一次即被罚没并驱逐,损失远超一次诚实投票的收益——因此“诚实激励“来自罚没的非对称性,不是奖励本身。
- Correlation penalty:同一时期被罚人数越多,每个被罚者扣得越多(Gasper 引入),使联合攻击者的集体成本随规模超线性上升。
MEV 与提案权
出块权意味着对交易排序的定价权。MEV(矿工可提取价值)让提议者/打包者从排序中直接获利,形成对协议规则的偏移:
- 审查:故意排除特定交易(如制裁地址、抢先交易)换取更高收入。
- 排序权拍卖:PBS(Proposer-Builder Separation)把“组块“与“提议“分开——builder 竞争出价,proposer 只卖签名权,削弱提议者对内容的控制,见 Gasper 的 proposer boost 与审查讨论。
- 设计含义:把激励拆给不同角色(提议者 vs 构建者 vs 验证者),让“按协议出块“成为默认无风险选择。
与 BFT 的接法
| 接法 | 结构 | 实例 |
|---|---|---|
| 分叉选择 + 检查点 BFT | 块间 GHOST,epoch 边界 FFG | Gasper |
| 委员会即时 BFT | 质押选出 \(n\) 人,每块 +2/3 | CometBFT |
| 委托轮转 | 票选少数出块人 | DPoS |
Gasper
Gasper 组合 LMD-GHOST 分叉选择与 Casper FFG 检查点最终性。验证者的参与权重来自有效质押余额,提议与证明通过共识层传播,执行层提供交易执行结果。
时间与投票
主网一个 slot 为 12 秒,一个 epoch 为 32 个 slot,即 6.4 分钟。slot 可以没有区块;检查点引用 epoch 边界对应的链上块,缺块时按祖先规则确定。
证明(attestation)包含链头 head、源检查点 source 和目标检查点 target。head 服务于分叉选择,source/target 服务于 FFG。验证者的投票不是只对当前块表达赞成。
正常连续 justification 下,检查点通常在后续 epoch 处理时终局。约两个 epoch 的最终性描述针对检查点推进,具体交易等待时间还受其所在位置和网络状态影响。
LMD-GHOST
LMD 表示每个验证者用于分叉选择的最新有效消息。节点从 justified 检查点开始,在满足 justified/finalized 兼容约束的候选树上,反复选择具有最大后代投票权重的子分支。
权重由有效余额计算;旧消息不与新消息累加。分叉选择描述节点当前选择的链头,不单独保证确定性最终确认,也不替代可罚没行为的检查。
Proposer boost
及时到达的当前 slot 区块可暂时获得 proposer boost,减轻部分近程重组与投票时序问题。以共识规范 v1.6.0 为准,主网配置为 PROPOSER_SCORE_BOOST = 40:
$$ \text{boost}=\left\lfloor\frac{\left\lfloor\text{totalActiveBalance}/32\right\rfloor\times40}{100}\right\rfloor $$
其基准是每 slot 的平均委员会权重,即约为全网有效余额的 0.40/32 = 1.25%,不是全网约 44%。适用区块、到达时间、分支位置及过期条件由 fork-choice 规则约束。
这个临时分叉权重与 FFG 的三分之二投票阈值具有不同作用,不能将二者直接作为相同尺度的提交票数比较。
Casper FFG
FFG 投票连接已 justified 的 source 与 target。一个有效超级多数链接需要至少三分之二活跃有效余额支持;由有效源到目标的链接可以使目标 justified。
若已 justified 的源检查点与该目标属于连续 epoch,源检查点可以 finalized。实际状态转换也包含跨 epoch 的 justification bits 与相应终局规则,不能把“目标获得三分之二票”脱离源检查点条件单独解释。
在固定权重和罚没规则假设下,冲突终局意味着至少三分之一权重违反可罚没规则,属于 accountable safety。三分之一权重拒绝参与可阻止通常的超级多数进展;网络延迟和其他条件也会影响活性。
可罚没行为与离线惩罚
| 行为 | 条件 |
|---|---|
| 双重区块提议 | 同一提议者在同一 slot 签署两个不同区块头 |
| FFG 双投票 | 同一 target epoch 签署两份不同 attestation data |
| 环绕投票 | 两份 FFG 投票满足 s1 < s2 < t2 < t1 |
双投票检查针对完整投票数据及 target epoch,不仅是两个不同 target 根。“同一 slot 改变链头”也不能脱离实际签署的证明数据来定义罚没条件。
离线惩罚与 slashing 不同。最终性长期停滞时,inactivity leak 逐步降低缺少合格参与的余额,以使持续参与一侧重新达到超级多数条件。参数按分叉版本确定,不保证所有网络分区下始终存在同一套固定权重。
弱主观性与轻客户端
长期离线节点需要可信近期检查点,以约束长程历史选择。该信任锚包含区块或状态承诺,不是仅凭某一批签名即可从任意历史启动。
同步委员会提供轻客户端可验证的签名。更新还需要验证签名域、参与情况、slot 关系,以及委员会或终局信息到相应状态根的 Merkle 分支。乐观链头更新与带最终性证明的更新具有不同保证。
轻客户端验证不等同于重放全部状态转换。同步委员会也不是决定 Gasper 最终性的另一套共识委员会。
MEV-Boost 与 PBS
在 MEV-Boost 的外部 PBS 模型中,builder 构造执行载荷,relay 验证并转发报价,proposer 选择报价并签署盲化区块,随后获取完整载荷。该服务不是 Gasper 强制要求的出块路径,提议者也可使用本地构建。
选择最高报价属于构建服务和提议者策略,不是共识有效性条件。报价与支付验证、载荷交付及审查风险依赖具体服务机制,不能统一解释为 builder 必须链外预支付或 PBS 自动消除审查。
参考资料
- 共识规范 v1.6.0:Fork choice
- 共识规范 v1.6.0:Beacon chain
- 共识规范 v1.6.0:主网配置
- Casper FFG 原始论文
- Ethereum:弱主观性
- Flashbots:MEV-Boost
DPoS
委托权益证明:持币人把投票权投给固定数量的出块人(witness / BP / SR),出块人轮转打包。质押仍是抗女巫来源,见 PoS 通论;决策集合被压缩到十几到几十个身份,确认延迟接近许可 BFT,治理结构接近寡头。
角色
- 持币人:按余额(可加锁仓、衰减)投票。可随时改投。
- 出块人:得票最高的 \(N\) 名。EOS \(N=21\),TRON \(N=27\)。
- 备份:排名 \(N\) 之后的候选人,出块人掉线或被撤票时顶上。
出块权不按哈希竞赛,不按每块 VRF 抽签,而按日程表轮转。一个 round 内每人出一块(或固定块数),超时跳过。
不可逆块
多数 DPoS 链用「\(2/3+1\) 个出块人确认」定义 LIB(Last Irreversible Block)。LIB 之前的块视为最终确认;之后仍可能被出块人集合重组。
这不是开放网络的 \(n \ge 3f+1\):分母是 \(N\) 个当选人,不是全网质押者。若 \(>1/3\) 的出块人共谋,LIB 可以停住或在客户端规则下出现冲突——安全阈值是出块人集合,不是代币持有人集合。
以 EOS 的 \(N=21\)、\(2/3+1=15\) 为例:任意两个 15 人集合相交 \(\ge 15+15-21=9\),相交处若有诚实者则不可能两边都形成;因此要造出冲突 LIB,攻击者必须控制同一个 15 人 quorum(\(>2/3\) 出块人合谋),离线容忍为 6 人(\(<1/3\))。这与许可 BFT 的 \(f=6, n=21, 2f+1=15\) 完全同构——差别只在分母是谁:这里是当选人,不是质押者。
投票机制
实现差异大,共同后果:
- 代理:持币人把票委托给中介,中介再投出块人。
- 衰减 / 重投:EOS 等要求持续刷新,防止旧票永久占据席位。
- 自投与互投:出块人用自有或同盟票维持排名。
投票本身通常不罚没。惩罚出块人靠撤票和替换,而不是 Gasper 那种双签证据自动烧币(部分链另加了双签罚没)。
性质
| 项 | 行为 |
|---|---|
| 吞吐 / 延迟 | 出块人少、轮转已知,块间隔可到 0.5 s 级 |
| 最终性 | LIB,快于 Nakamoto,弱于每块 BFT |
| 去中心化 | 开放投票,出块权集中在 \(N\) 人 |
| 故障 | \(<1/3\) 出块人离线仍能推 LIB;审查成本是 \(N/3\) 合谋 |
与 CometBFT 的差别:CometBFT 的验证者集合由链上质押直接构成,每块 +2/3 即最终;DPoS 多一跳委托,出块人集合由链下或链上选举周期性更新。
Algorand(VRF 抽签 + BA*)
Algorand(2017)用 VRF 每轮从质押者中随机抽出提议者与委员会,委员会内跑 BA*(两阶段投票 + 门限签名)达成即时最终。抗女巫仍是质押(见 PoS 通论),但与固定验证者集合的 BFT 不同:每轮的参与者是时变的随机子集。
抽签
- 每轮种子来自上一轮最后一批投票的 VRF 输出,难以提前预知。
- 每个验证者本地计算 \(\mathrm{VRF}_{sk}(\text{round})\),结果低于阈值即中签,概率与质押权重成正比(子单位抽签精确加权),见 VRF。
- 中签者先于所有人知道结果:提案权和投票权在揭示前不可预测,攻击者无法事先锁定“这轮要打谁“。
BA*
- 抽出的提议者广播块;委员会成员对块或空投第一轮票。
- 若块获得足够多第一轮票,进入确认路径;否则委员会重新抽一轮,对最高票块继续。
- 投票用门限签名聚合,验票与传递是常数级。
最终性:诚实质押 \(\ge 2/3\) 时单轮完成(概率极高);失败则重抽重投,不引入视图切换。与 HotStuff 的差别:没有固定领导者与超时推进,靠抽签的不可预测性换 liveness。
安全模型
- 拜占庭节点不知道自己在哪些轮次的委员会中,攻击窗口不可预知;对特定轮次的蓄谋需要同时控制当轮委员会的门限。
- 消息不面向全网传播证明(无 block 传播延迟假设),一轮即终,不存在 PoW 的按确认深度概率化窗口。
- 代价:每轮签名验证多(委员会投票多但聚合),需要 BLS 类聚合签名把验票成本降下来。
对照
| Algorand | Gasper | HotStuff | |
|---|---|---|---|
| 参与者 | 每轮 VRF 随机子集 | 全体质押者 attestation | 固定验证者集合 |
| 随机源 | VRF | RANDAO | 无(轮转) |
| 最终性 | 一轮内概率确定 | 约 2 epoch | 单块 / k-chain 确定 |
| 视图切换 | 无(重抽) | epoch 推进 | 最高 QC / TC |
与 Avalanche Snow 的差别:Algorand 的投票集合是随机委员会 + 门限聚合,Snow 是任意抽样不聚合。
BFT 模型
拜占庭容错允许部分参与者任意偏离协议,包括停机、发送矛盾消息和伪造自身状态。复制状态机要求诚实副本在相同历史上执行相同操作,并在网络与故障假设满足时持续推进。
故障模型与法定人数
| 模型 | 故障行为 | 常见配置 | 法定人数 |
|---|---|---|---|
| CFT | 停机、重启、暂时失联,非恶意协议执行 | n = 2f + 1 | 多数,即 f + 1 |
| BFT | CFT 故障及任意恶意行为 | n = 3f + 1 | 常见阈值 2f + 1 |
这里 n 是参与副本数,f 是容忍故障数。对一般 n,CFT 多数应写为 floor(n/2)+1;不能在 n≥2f+1 时仍把 f+1 一律称为多数。
BFT 的阈值也需匹配委员会规模。两个大小为 q 的投票集合至少相交 2q-n 个节点;若最多 f 个节点作恶,保证交集中有诚实节点需要:
$$ 2q-n>f $$
当 n=3f+1、q=2f+1 时,交集至少 f+1 人。任意 n≥3f+1 的配置不能不加分析地沿用相同 2f+1 门槛。按权益投票的协议使用权重,而非简单节点人数。
CFT:Raft 与 Paxos
Raft 通过任期、领导者选举和日志匹配约束复制日志。持久化规则要求投票及日志等必要状态在响应相关请求前保存;已经提交的日志不会因协议允许的正常崩溃恢复而任意丢失。
未提交日志可能在新领导者下被覆盖。多数复制与提交规则还涉及日志任期条件,因此“收到多数响应”不能脱离协议上下文直接解释为任何条目都已提交。
Paxos 同样依靠法定人数与跨轮承诺维持一致性。二者的 CFT 保证不覆盖恶意参与者谎报、双发或违背持久化约束。应用背书或交易校验不能自动将 CFT 排序转换为 BFT 排序。
Safety 与 Liveness
- Safety(一致性):诚实副本不会最终提交互相冲突的值或历史。
- Liveness(活性):在协议要求的参与和网络条件下,合法输入最终得到处理。
在纯异步网络中,即使仅允许一个崩溃故障,确定性共识也无法无条件保证终止。部分同步协议通常在任意延迟期间维持安全,并在网络稳定后恢复活性;随机化异步协议使用不同的终止条件。
网络模型
| 模型 | 时间假设 | 影响 |
|---|---|---|
| 同步 | 消息延迟有已知上界 | 可按上界设计轮次 |
| 部分同步 | 存在稳定时刻 GST,之后延迟满足上界 | 稳定前可能停滞,稳定后满足活性条件 |
| 异步 | 没有消息延迟上界 | 超时不能证明对方故障;活性通常需随机化等条件 |
身份认证、消息可验证性和委员会变更规则也是安全假设的一部分。不同 BFT 协议不能只按投票数量比较其保证。
证书相交与跨轮安全
同一轮中,若诚实节点对冲突提案最多投一次票,法定人数相交可排除两个冲突证书。但不同轮次的冲突证书可能出现,节点也可能在不同阶段对不同提案投票。
跨轮安全依赖锁定、证书选择、视图切换和提交规则。例如 PBFT 转移准备证据,HotStuff 约束可扩展的证书链,CometBFT 通过锁定及有效轮信息限制投票。单独一个 QC 不等同于最终提交。
Streamlet 根据连续三个 epoch 的相邻已公证块终局中间块及其祖先,不能概括为两轮投票即可提交,见 Streamlet。
协议阶段
- 领导者提出操作或区块,副本验证其内容及父历史。
- 副本按本地证据和投票规则签署消息,形成法定人数证书。
- 达到具体协议的提交条件后,按序应用到状态机。
- 领导者失效时推进轮次,并保留安全所需的证据。
PoW 的链权重与概率确认、PoS 的参与权重,以及 BFT 的证书和提交机制属于不同维度。开放参与系统可以用质押建立委员会,再在委员会内运行 BFT。
参考资料
PBFT
Practical Byzantine Fault Tolerance(Castro & Liskov,1999)。许可集合上的拜占庭复制状态机:三阶段投票 + view-change。模型见 BFT。
系统模型
- \(n = 3f+1\) 副本,至多 \(f\) 个拜占庭。
- 视图 \(v\) 的主节点 \(p = v \bmod n\)。
- 客户端接受 \(f+1\) 条相同 reply(至少 1 条诚实)。
- 消息带签名(或 MAC)。无不可伪造认证则 quorum 相交不成立。
- 部分同步:safety 不依赖计时;liveness 依赖 GST 后超时大于 \(\Delta\)。
不变量:诚实副本对同一序号 \(n\) 提交相同请求 \(m\)。已 committed 的 \(m\) 在后续视图中不得被替换。
正常路径
sequenceDiagram participant C as Client participant P as Primary participant R as Replicas C->>P: REQUEST m P->>R: PRE-PREPARE(v, n, d) R->>R: PREPARE(v, n, d, i) Note over P,R: prepared := PRE-PREPARE + 2f PREPARE R->>R: COMMIT(v, n, d, i) Note over P,R: committed := 2f+1 COMMIT R->>C: REPLY
记 \(d = H(m)\)。
- Pre-prepare。主节点分配序号 \(n\),广播 \(\langle\text{PRE-PREPARE}, v, n, d\rangle_{\sigma_p}\)。备份校验:签名、\(v\) 为当前视图、\(n\) 在水印窗口 \((h, H]\)、同一 \((v,n)\) 未接受过其它 \(d\)。
- Prepare。备份广播 \(\langle\text{PREPARE}, v, n, d, i\rangle\)。副本 \(i\) 在持有匹配 pre-prepare 且收到 \(2f\) 条匹配 prepare 时进入 prepared(合计 \(2f+1\))。prepared 意味着本视图内诚实节点不会再给 \(n\) 绑定另一个 \(m\)。
- Commit。prepared 后广播 \(\langle\text{COMMIT}, v, n, d, i\rangle\)。收到 \(2f+1\) 条匹配 commit 后进入 committed,按序号执行,reply 客户端。
Prepare 把本视图对 \((n,m)\) 的锁定扩散开;Commit 保证该锁定已被足够多副本看见,从而能随 view-change 转移。仅 prepared 未 committed 时,新视图仍可能把该序号改成 no-op。
正常路径每阶段全对全,通信 \(O(n^2)\)。
水印
稳定检查点推进低水位 \(h\),高水位 \(H = h+L\)。\(h\) 之前的日志可丢;超出 \(H\) 的 pre-prepare 被拒绝,防止主节点把序号拉飞。
View-change
备份在当前视图超时未提交则广播:
\[ \langle \text{VIEW-CHANGE}, v+1, n_{\text{last}}, C, P_{\text{set}}, i \rangle \]
- \(n_{\text{last}}\):最近稳定检查点序号。
- \(C\):检查点证明。
- \(P_{\text{set}}\):检查点之后已 prepared 的请求及其 \(2f+1\) prepare 证明。
新主节点 \(p’=(v+1)\bmod n\) 收集 \(2f+1\) 条 view-change 后构造 new-view:
- 取消息中最高稳定检查点为起点。
- 检查点之后每个序号:若 \(P_{\text{set}}\) 中存在该序号的 prepared 证明,必须沿用该 \(m\);否则填 no-op。
- 广播 \(\langle\text{NEW-VIEW}, v+1, V, O\rangle\)。\(V\) 为那 \(2f+1\) 条 view-change,\(O\) 为选定的 pre-prepare 列表。
备份重算 \(O\) 是否符合上述规则,然后从这些 pre-prepare 重新走 prepare/commit。
提交保持:若 \(m\) 已在旧视图 committed,则至少 \(f+1\) 个诚实节点已 prepared。任意 \(2f+1\) 条 view-change 的交集包含这 \(f+1\) 个诚实节点之一,新主节点无法改掉 \(m\)。
View-change 仍是全对全,并携带历史证明,复杂度 \(O(n^2)\) 且消息更大。HotStuff 把切换收成「新领导携带最高 QC」。
活性
超时必须在 GST 之后大于 \(\Delta\)。过短导致持续空切(livelock);过长让坏主节点拖死进度。原论文用指数退避拉长超时。
部署边界
节点身份已知、规模约数十、需要确定最终性的许可系统。公链不能直接用:没有身份就没有 \(n=3f+1\) 的分母;\(n\) 到数百时平方通信先饱和。公链路径是质押选出有界委员会再投票(CometBFT),或线性化视图切换(HotStuff)。
HotStuff
HotStuff(Yin et al.,PODC 2019)是部分同步、领导者驱动的 BFT。副本只与当前领导者通信(星型),投票聚合成 QC,视图切换携带最高 QC 即可,通信从 \(O(n^2)\) 降到 \(O(n)\)。模型见 BFT;对照 PBFT。
系统模型
- \(n \ge 3f+1\),至多 \(f\) 个拜占庭。
- 部分同步:GST 之前任意延迟,之后延迟 \(\le \Delta\)。
- 消息不可伪造(签名)。QC 可用阈值签名压成常数大小。
- 视图 \(v\) 有唯一领导者。Pacemaker 用超时推进视图,不参与 safety 证明。
数据结构
- 提案:视图号 +
node。node含命令、父哈希、父 QC。 - 投票:对某个
node哈希的签名。 - QC(Quorum Certificate):同一
node上 \(2f+1\) 张投票的聚合。QC 证明该节点已获得法定人数认可。 - 锁定:副本在 pre-commit 后把
lockedQC设为该 QC,后续只给其后代投票(除非新领导者出示更高视图的 QC)。
三阶段与三链提交
经典 HotStuff 一轮三个投票阶段。领导者收集 \(2f+1\) 张投票后广播对应 QC:
| 阶段 | 领导者广播 | 副本条件 | 结果 |
|---|---|---|---|
| Prepare | 新 node(须扩展最高 QC) | node 扩展 lockedQC,或带有更高 QC | Prepare QC |
| Pre-commit | Prepare QC | 验证 QC | 更新 lockedQC |
| Commit | Pre-commit QC | 验证 QC | Commit QC,提交祖先 |
三链规则:区块 \(B\) 被提交,当且仅当存在连续三代 \(B \leftarrow B’ \leftarrow B’’ \leftarrow B’‘’\),且后三代各自带 QC。提交 \(B’‘’\) 时一并提交祖先 \(B\)。
第三阶段消除 hidden lock:两阶段时,新领导者可能看不到某个副本的锁定,从而要求它给冲突分支投票,破坏 liveness。Pre-commit 把锁定写入 QC,新领导者用「最高 QC」覆盖。
链式 HotStuff
阶段叠在块上:第 \(k\) 块的 Prepare 同时作为第 \(k-1\) 块的 Pre-commit、第 \(k-2\) 块的 Commit。每出一块只做一轮星型投票,吞吐为每块 \(O(n)\) 投票,确认延迟约三块。
视图切换
Pacemaker 超时后新领导者:
- 向副本收集本地最高 QC。
- 选视图号最高者作为父,提出新
node。 - 副本在提案扩展该 QC(或 QC 高于自己的
lockedQC)时投票。
不携带 PBFT 的 prepared 集合,无需全对全 VIEW-CHANGE。通信 \(O(n)\)。
安全与活性
- Safety:冲突
node不能同时获得 QC(quorum 相交)。诚实副本不给与lockedQC冲突的非后代投票,除非新 QC 视图更高——更高 QC 已与旧锁定相交,不可能冲突。 - Liveness:GST 之后 Pacemaker 给出诚实领导者且超时大于 \(\Delta\),QC 可形成。响应性:同步后进度跟实际延迟走,不绑死最坏 \(\Delta\)。
两链变体
Jolteon / DiemBFT-2chain / AptosBFT 把提交压成两链,用超时证书(TC)处理 hidden lock:视图超时后聚合 \(2f+1\) 条超时消息,新提案必须携带 TC 或更高 QC。LibraBFT 是 HotStuff 的链上实例。数据传播与排序分离见 DAG-BFT。
Tendermint / CometBFT
Tendermint Consensus 按 height 出块,每 height 内用 round 轮转提议者,+2/3 投票后该 height 立即最终确认。2023 年起 Tendermint Core 以 CometBFT 之名由 Informal Systems 维护。Cosmos SDK 链经 ABCI 对接。模型见 BFT。
系统模型
- 验证者集合已知,投票权加权。拜占庭投票权 \(< 1/3\) 时与 \(n \ge 3f+1\) 等价。
- 部分同步。超时推进 round,不改变已提交 height。
- 提议者
proposer(h, r)由集合与 round 确定轮转。 - 状态机按 height 单调执行:一个 height 只提交一块,无运行时分叉选择。
Round 协议
每个 \((h, r)\):
- Propose。提议者广播提案。\(r=0\) 无附加证明;\(r>0\) 须带上一 round 的 +2/3 prevote(polka)或解锁证明。
- Prevote。合法提案投
prevote(block),否则prevote(nil)。 - Precommit。观察到对同一块的 +2/3 prevote(polka)则
precommit(block)并 lock;否则precommit(nil)。 - Commit。观察到对同一块的 +2/3 precommit 则提交,进入 \(h+1\)。否则超时,\(r \leftarrow r+1\)。
Gossip 传播提案与投票,实现上不是 PBFT 式全对全 RPC,规模增大时仍受投票扇出限制。
消息面细节:
- 提案:走 gossip 全网扩散,配合区块同步补齐落后节点。
- 投票:prevote / precommit 通过 gossip 扇出;每节点把投票转发给部分对等节点(而非全对全),靠冗余路径保证可聚合性。
- 聚合:+2/3 由每节点本地聚合并随 gossip 转发(预提交聚合),不需要像 HotStuff 那样先收齐再广播 QC。
- 瓶颈:一个 height 要扩散 \(O(n)\) 张投票消息,扇出系数固定时传播延迟随 \(n\) 上涨,验证者集合到数百时即显吃力;Cosmos 生态通常用分片应用层或子网解决。
锁定
- 对值 \(v\) 发出 precommit 后:
lockedValue = v,lockedRound = r。 - 之后必须
prevote(lockedValue),直到看到更高 round 的 polka 才解锁。 - 与 lock 冲突且无更高 polka 的提案被拒绝。
若某块已获 +2/3 precommit,则超过 2/3 投票权已 lock 它。任意两个 +2/3 集合相交超过 1/3,无法再凑出冲突块的 +2/3 precommit。
超时
| 计时器 | 触发 |
|---|---|
timeoutPropose | 未收到合法提案 |
timeoutPrevote | 未凑齐 +2/3 prevote |
timeoutPrecommit | 未凑齐 +2/3 precommit |
timeoutCommit | 提交后等待慢节点对齐 |
GST 后超时大于传播延迟,诚实提议者的 round 能走完。过短则空转 round。
最终性
单 height 提交即最终确认。拜占庭投票权 \(\ge 1/3\) 时可能停块;冲突投票可作为证据做罚没。客户端未见 +2/3 precommit 之前的 round 不算提交,不存在 PoW / Gasper 那种已播块再重组。
ABCI
共识引擎与应用分离:
CheckTx:mempool 初筛。PrepareProposal/ProcessProposal(ABCI++):组块与校验。FinalizeBlock/Commit:提交后执行。
CometBFT 保证同一 height 的同一字节序列;交易语义由应用决定。
对照
| PBFT | CometBFT | HotStuff | |
|---|---|---|---|
| 进度单位 | 连续序号 | height | 视图 / 链式块 |
| 投票轮 | prepare + commit | prevote + precommit | 三链或两链 + TC |
| 换领导 | \(O(n^2)\) view-change | round++ | 最高 QC,\(O(n)\) |
| 最终性 | 序号 committed | 单 height | \(k\)-chain QC |
| 流水线 | 无 | 无 | 有 |
TBFT
TBFT(Tencent BFT)是腾讯长安链(ChainMaker)的拜占庭容错共识,基于 Tendermint 改造:height / round 驱动、提议者轮转、prevote / precommit 投票。与 CometBFT 同族,针对联盟链场景加了可配置的连续出块与「非确定性交易剔除」。模型见 BFT。
系统模型
- 共识节点数 \(\ge 3n+1\),拜占庭节点数 \(< 1/3\)(即 \(n \ge 3f+1\))。
- 节点身份由证书体系管理(联盟链准入),消息带签名 / 背书(endorsement),无不可伪造认证则 quorum 相交不成立。
- 部分同步:超时推进 round,不改变已提交 height 的结果。
Round 协议
TBFT 的每轮共识分五步,其中 Proposal / Prevote / Precommit 是投票阶段:
- NewRound。重置本轮状态。
- Proposal。leader 打包区块,广播给 follower。
- Prevote。follower 验证提案合法后广播自己的 prevote。
- Precommit。收到 \(> 2/3\) 针对该提案的 prevote 后广播 precommit。
- Commit。收到 \(> 2/3\) precommit 后提交区块到账本。
投票消息携带 height、round、区块哈希;提案携带 pol_round——上一 round 的锁定证明,语义与 Tendermint 的 polka / lock 一致。
与 PBFT / CometBFT 的差异
| PBFT | CometBFT | TBFT | |
|---|---|---|---|
| leader | 固定,view-change | 每 height 轮转 | 每 \(N\) 块轮转(可配置) |
| 投票 | prepare + commit | prevote + precommit | prevote + precommit |
| 锁定 | prepared 集合 | polka / lock | pol_round |
| 出块连续性 | 单 leader | 每 height 换 | 连续 \(N\) 块再换 |
- 公平性:PBFT 用 view-change 换主节点,TBFT 按块数轮转 leader,避免单 leader 长期垄断出块。
- 归属:TBFT 直接借用了 Tendermint 的 prevote / precommit 与锁定语义,与 CometBFT 是同一设计空间;差别在出块节奏(连续 \(N\) 块)和联盟链的确定性保障。
非确定性交易剔除
联盟链合约可能产生非确定性执行(时间戳、随机数),同一交易在不同节点结果不一致。TBFT 通过共识剔除:
- follower 验证提案时比对交易读写集。
- 读写集不一致的交易,节点在 prevote 时附「剔除」投票。
- 收集到 \(f+1\) 个剔除投票后,调用核心引擎删除该交易。
保证进入账本的交易结果可复现,不依赖重放一致性。
与核心引擎的交互
共识引擎与核心引擎通过消息总线(msgbus)对接:ProposalState(本节点当前高度是否 leader,决定是否打包)→ Proposal → Verify(读写集验证)→ VerifyResult → Commit → BlockInfo(已提交高度,进入下一高度)。
配置参数
| 参数 | 含义 |
|---|---|
TBFT_propose_timeout | 提案超时(如 10s、1m) |
TBFT_propose_delta_timeout | 每轮超时递增量 |
TBFT_blocks_per_proposer | 每个节点连续出块数 |
对照
TBFT 是「Tendermint 血缘的联盟链 BFT」:有界验证者集合、即时最终性、无概率化确认窗口,安全阈值与 CometBFT 相同(拜占庭投票权 \(< 1/3\))。长安链的另一套共识 Maxbft 是 HotStuff 流水线的联盟链实现(星型通信 + QC + three-chain)。联盟链不需要抗女巫,见 PoS 通论 与 BFT 模型 的对照。
Execute-Order-Validate
Execute-Order-Validate(执行-排序-验证)是 Hyperledger Fabric 的共识架构:把「共识」拆成**排序(ordering)+ 背书(endorsement)+ 验证(validation)**三段——排序层只给已经过背书的交易排全序,交易合法性由背书策略与 peer 本地验证决定。排序层实现可选 CFT(Raft)或 BFT(SmartBFT,v3.0 起);无论哪种,共识不裁决交易内容。模型对照见 BFT。
三段流水线
- Endorse(执行 + 背书)。客户端提交提案,endorser 按背书策略执行交易,返回读写集(RW set)与背书签名;执行结果不立即写账本。
- Order(排序)。客户端把背书后的交易交给 ordering service;orderer 不执行、不判断内容,只按接收顺序打包成区块并广播。
- Validate(验证 + 提交)。每个 peer 收到区块后本地验证——背书策略是否满足、背书是否一致、读写集是否与最新状态冲突(MVCC);无效交易保留在区块里但标记 invalid,不更新状态。
关键:执行发生在排序之前且多副本各自执行,所以「哪个执行结果合法」不由共识裁决,而由背书策略 + 本地验证决定。区块一旦生成即最终,账本不分叉。
排序层:Raft 与 SmartBFT
| 实现 | 容错 | 节点数 | 状态 |
|---|---|---|---|
| Raft(etcd/raft) | CFT | \(\ge 2f+1\),过半可用 | 生产默认(v1.4.1 起) |
| SmartBFT | BFT | \(\ge 3f+1\),恶意 \(< 1/3\) | v3.0 起可用 |
| Kafka / Solo | — | — | 已弃用 / 移除 |
- Raft:leader 选举 + 日志复制,确定性、无分叉。恶意节点可重排或审查,联盟链信任模型通常靠治理兜底。
- SmartBFT:源自 BFT-SMART(非流水线的 PBFT)。leader 被怀疑故障时轮转更换;客户端把交易发给所有 orderer,以对抗 leader 故意忽略交易(审查)。
- 排序层只解决「全序」,不解决「对错」——即使 orderer 诚实,恶意交易也会在 validate 阶段被 peer 拒绝。
与 BFT 系协议的关系
| Fabric | CometBFT / TBFT | |
|---|---|---|
| 交易执行 | 背书阶段(排序前) | 提案打包(共识中) |
| 排序层容错 | 可选 CFT / BFT | 必须 BFT |
| 验证 | peer 本地(背书策略 + MVCC) | 共识内 |
| 非确定性 | 背书策略约束(endorser 结果须一致) | 共识剔除(TBFT) |
- Fabric 把「确定最终状态」下放到每个 peer:验证独立于排序,故排序层可以退到 CFT 而不破坏账本一致性。
- 代价:执行放大(同一交易被多个 endorser 与 peer 重复执行)、MVCC 冲突导致交易失效需重试、背书策略成为信任集中点。
- SmartBFT 把排序层推到 \(3f+1\),对应「orderer 本身不可信」的场景;但交易正确性仍由背书 + 验证负责,与 BFT 系「共识裁决一切」的分工不同。
对照
Fabric 是「把共识职责最小化」的代表:排序只管全序,正确性放应用层。与之相对,CometBFT / TBFT 把打包与验证绑进共识,Gasper 用分叉选择 + FFG 裁决。联盟链的信任模型决定选哪种:orderer 可信则 Raft,orderer 可能作恶则 SmartBFT。
Streamlet
Streamlet 是 Benjamin Chan 和 Elaine Shi 提出的链式 BFT 协议。系统按 epoch 推进,通过提议、投票、公证和链式终局维护交易历史。它不包含 PBFT 式的独立视图切换消息,但仍依赖轮次推进和证据传播。
模型与数据结构
系统有 n 个参与者,拜占庭数量 f<n/3,提案和投票经过签名认证。原协议使用同步的 epoch,并在网络稳定的部分同步条件下保证进展。
区块包含 epoch、交易和父块引用。收集至少三分之二参与者的不同投票后,区块成为 notarized(已公证)块。在 n=3f+1 配置中,门槛为 2f+1。
提议与投票
- 每个 epoch 选择一个领导者;原协议的分析采用随机领导者。
- 领导者扩展自己已知的最长已公证链,提出当前 epoch 的新区块。
- 参与者只对该 epoch 首个满足条件的提案投票,且提案必须扩展其已知最长已公证链之一。
- 每个诚实参与者在同一 epoch 最多投一次票;公证证据随后传播。
“最长”以链上块数计,而不是最大 epoch 编号。失效轮可以跳过,父块 epoch 与子块 epoch 不必总是连续。
三个连续 epoch 的终局条件
若已公证链中出现三个相邻区块,epoch 分别为 e、e+1、e+2,则中间块及其全部祖先可以终局:
... → B(e) → B(e+1) → B(e+2)
已公证 已公证 已公证
↑
本次终局的链头
规则同时要求链上相邻、epoch 连续和每块已公证。两个连续已公证块不足以触发该规则;三个块的 epoch 若存在缺口也不满足条件。最后一块不会仅因出现在该三块链中就一起终局。
安全性条件
同一 epoch 不会产生两个冲突公证块,因为两个法定人数集合相交于诚实参与者,而诚实参与者不会在该 epoch 双投。
跨 epoch 的安全性还依赖最长已公证链投票规则,以及连续三块建立的证据关系。诚实参与者可能在不同 epoch 给不同分支投票,不能将其简化为永远不投冲突分支,也不能仅凭一次证书相交证明所有未来历史一致。
活性与协议边界
网络稳定后,消息能在 epoch 的时间预算内传播。原论文的活性论证使用连续五个诚实领导者 epoch:前面的轮次用于收敛已有分歧,之后形成满足终局条件的三块链。
因此,三块终局规则与从任意先前状态恢复进展的轮次数量不同。领导者失效、消息延迟和轮次中断影响确认时间;该协议也不能直接视为 HotStuff 两链变体。
参考资料
PoH 与 Tower BFT
Solana 把时间从投票里拆出去:Proof of History 是可验证的顺序时钟,Tower BFT 是叠在时钟上的投票锁定。PoH 不决定分叉赢家,也不是 PoS 通论 意义上的资源证明。领导者日程由质押权重生成。
PoH
领导者对前一个输出做 SHA-256,形成哈希链:
\[ x_{i+1} = \mathrm{SHA256}(x_i) \]
交易(或条目哈希)在指定计数处嵌入链中,成为该事件的顺序证明。生成必须串行;验证可把链切成片段并行重放——这与 Wesolowski 等「证明短、验证快」的 VDF 不同。PoH 常被叫成 VDF,实现上是可分段校验的迭代哈希。
数据单位:
| 单位 | 含义 |
|---|---|
| tick | 固定次数哈希,充当时钟刻度 |
| entry | 一组交易 + 嵌入时的 PoH 计数 |
| shred | entry 纠删编码后的网络分片 |
| slot | 一名领导者的出块窗口,含多个 tick |
验证者按质押抽签得到领导者日程。当前领导者在自己的 slot 内推进 PoH、打包 entry、广播 shred。下一领导者必须从上一 slot 的 PoH 输出接着写,否则哈希链对不上。
Tower BFT
投票对象是某个 PoH 高度上的区块(或该 slot 的银行哈希)。每张票附带 lockout:该票在后续 \(2^{k}\) 个 slot 内不可对冲突分支再投,\(k\) 为该验证者在此分叉上连续投票次数。连续投票使 lockout 指数增长,回滚要等锁定过期或放弃对应质押收益。
- 乐观确认:观察到足够多验证者把票打在某块及其后代上。
- rooted / finalized:lockout 已深到协议认为回滚不经济(实现里用 rooted slot 表示)。
这不是 PBFT 的 quorum 相交提交:没有「\(2f+1\) 张匹配 commit 则不可逆」的证书。安全性来自质押 + 指数锁定,重组在 lockout 未到期时仍可能发生,成本随深度上升。
与分叉选择的关系
PoH 给出全序候选;网络分区或领导者掉线会产生分叉。验证者在分叉上按 Tower 规则投票,选锁定代价更一致的那条。领导者跳过(skip)会在 PoH 上留下空洞,下一领导者从超时后的计数继续。
故障模式:领导者硬件要能持续串行哈希并播 shred,阈值高于普通 PoS 节点。日程内的单点领导者影响活性,不单独破坏已 rooted 的历史。
对照
| PoH + Tower | Gasper | CometBFT | |
|---|---|---|---|
| 时钟 | 哈希链 | slot 壁钟 | height / 超时 |
| 投票 | 指数 lockout | attestation + FFG | +2/3 prevote/precommit |
| 最终性 | rooted,经济上难回滚 | 2 epoch 经济最终 | 单块确定 |
| 出块 | 单领导者 slot | 单提议者 slot | 轮转提议者 |
Avalanche Snow
Snow 家族用反复随机抽样代替全员 quorum。节点对冲突值(颜色、交易、块)做本地决定:每轮向网络抽 \(k\) 个节点问其当前偏好,若某值占比 \(\ge \alpha\) 则把偏好改成该值。metastable:一旦诚实多数偏向一侧,抽样会把剩余节点吸过去。
协议层级
| 名称 | 增加的状态 | 作用 |
|---|---|---|
| Slush | 无计数 | 按抽样翻转偏好,可振荡 |
| Snowflake | 连续成功轮数 \(\beta\) | 连续 \(\beta\) 轮同色则决定 |
| Snowball | 每色置信计数 | 用计数抵抗短暂翻转 |
| Avalanche | DAG 上的 Snowball | 交易互相确认 |
| Snowman | 线性化的 Snowball | 一条链,供合约 VM 用 |
Avalanche L1 的 C-Chain / P-Chain 跑 Snowman。早期 X-Chain 用 DAG Avalanche,后亦线性化。
一轮
参数 \(k,\alpha,\beta\) 为常数,与全网节点数 \(n\) 无关:
- 对当前冲突集,向均匀抽样的 \(k\) 个节点查询其偏好。
- 若某值的回复 \(\ge \alpha\),采纳为本地偏好;对应置信计数加一,其它色清零或递减(视变体)。
- 连续 \(\beta\) 轮(Snowflake)或置信超过阈值(Snowball)则 decide,之后不再翻转。
每轮通信 \(O(k)\)。\(n\) 增大时延迟不按 \(n^2\) 涨,前提是抽样能碰到在线诚实节点。
错误概率
决定是概率的,参数把错误压到可忽略。每轮错误侧要凑够 \(\alpha\) 票才会翻转或维持错误偏好;当 \(\alpha > k/2\) 时,单轮“维持错误“的概率有上界 \(p_e < 1\)。连续 \(\beta\) 轮同色才 decide,因此
\[ P(\text{wrong decide}) \le p_e^{\beta} \]
- 参数与全网规模 \(n\) 无关:\(k\) 越大错误概率越低但每轮开销越大,\(\alpha/k\) 越接近 1 抗翻转越强但收敛越慢,\(\beta\) 直接决定错误概率的量级。
- 论文给出 \(k=20\)、\(\alpha=15\)、\(\beta\approx20\) 一档参数时错误概率已到 \(10^{-3}\) 量级,再增加 \(\beta\) 每几轮再降一个数量级。
- 前提是诚实节点占比超过与 \(\alpha/k\) 相关的阈值;抽样不均匀(如分区内只剩恶意节点)会让 \(p_e\) 失去意义。
安全假设
分析通常要求诚实节点占比超过某个与 \(\alpha/k\) 相关的阈值,且网络允许随机抽样(诚实节点的查询能到达诚实节点)。拜占庭节点可以返回任意颜色,但不能伪造他人身份(需质押或 Sybil 代价,Avalanche 用质押的验证者集合)。
与 BFT quorum 的差别:
- 没有 \(2f+1\) 证书,决定是概率的:存在极小概率在冲突值上 decide。参数把该概率压到可忽略。
- 没有视图切换。冲突靠抽样漂移消掉,而不是换领导者重提。
- 最终性是「decide 之后本地不可逆」,不是全网同时持有同一 QC。收敛后新节点通过抽样也会进入同一状态。
Snowman
把 Snowball 的决策对象换成线性区块:父块必须已 prefer / decide,子块才能被查询。分叉时对同一高度的冲突块做抽样,一侧 decide 后另一侧作废。合约执行需要总序,因此 C-Chain 不用 DAG Avalanche。
吞吐来自分叉上并行 gossip 与小 \(k\) 查询,不是来自单领导者流水线。与 DAG-BFT 都弱化「全员对单块投票」,但 Snow 用随机抽样,Narwhal 用证书 DAG。
DAG-BFT
把数据传播和排序拆开:验证者并发广播交易批次,用证书(或无证书的引用)织成 DAG,共识只在 DAG 上选一刀切齐的提交切面。数据面解决可用性,排序面解决最终性。模型仍是 BFT 的 \(n \ge 3f+1\)。
Narwhal(数据面)
每个验证者跑若干 worker:
- Worker 收集交易,打成 batch,广播。
- 其它验证者存储后回签名。
- \(2f+1\) 个签名形成 certificate,证明该 batch 可用(至少 \(f+1\) 个诚实节点持有)。
- Primary 把证书广播。新证书引用上一轮若干证书,形成 round 索引的 DAG。
诚实验证者每 round 至少一个证书(自己的或超时后的空块)。DAG 边是可用性证明,不是执行顺序。通信与存储随 worker 数水平扩展,共识消息只搬运证书哈希。
排序面
| 协议 | 网络 | 做法 |
|---|---|---|
| Tusk | 异步 | 随机选锚点,递归提交因果前驱 |
| Bullshark | 部分同步 | 固定 round 的锚点,GST 后无额外随机延迟 |
| Jolteon + Quorum Store | 部分同步 | 数据面 Narwhal 化,排序仍用 HotStuff 两链 |
提交规则(Bullshark 形状):在偶数 round 的锚点证书获得足够子引用后,其因果历史一次性 commit。已提交证书不再回滚。应用按确定性拓扑序(作者、round、哈希)把 DAG 线性化成交易流。
形状示例:round 索引的证书互相引用,箭头表示对证书的引用关系:
flowchart LR A1[A1] --> B2[B2] --> A3[A3] B1[B1] --> A2[A2] --> C3[C3] C1[C1] --> C2[C2] --> B3[B3] C1 --> B2 A2 --> B3 B1 --> C2 C2 --> A3
若 \(A3\) 是偶数 round 锚点且获得足够子引用,则 commit 其因果前驱 \(A1,B1,C1\) 及 \(A2,B2\) 等可到达集——排序面只看引用闭合,不看交易内容。
Sui:Mysticeti
Sui 先用 Narwhal-Bullshark,后换 Mysticeti:无证书 DAG,块直接引用 \(2f+1\) 个前驱。少一轮签名聚合,提交延迟降到 2–3 次广播。隐式 commit 规则在 DAG 图案上识别,不跑独立的 HotStuff 视图。对象级因果允许无冲突交易并行提交,这是执行模型,不是共识阈值的放宽。
Aptos:Quorum Store
Aptos 排序仍是 AptosBFT(HotStuff 两链 + 超时证书)。Narwhal 思想用在 Quorum Store:交易批次先散到验证者并收集签名,提案只带批次证明,领导者不再塞满交易体。共识带宽与 mempool 解耦。
对照
| 经典 HotStuff | Narwhal + Bullshark | Mysticeti | |
|---|---|---|---|
| 数据传播 | 跟提案走 | 证书 DAG | 无证书块 DAG |
| 排序 | 领导者 QC 链 | 锚点切面 | DAG 图案隐式 commit |
| 领导者瓶颈 | 出块与带宽 | 仅证书 | 弱化 |
| 最终性 | \(k\)-chain QC | 切面确定 | 切面确定 |
与 Avalanche Snow 的差别:DAG-BFT 仍收集 \(2f+1\) 可用性或引用,提交是确定的;Snow 用 \(k\)-抽样,提交是高概率的。
密码学
数字签名验证消息授权,可验证随机函数提供与密钥及输入绑定的输出和证明。共识协议使用这些机制表达投票、验证身份和选择参与者。
Ed25519:现代数字签名算法的原理与应用(附 Rust 实现)
请务必阅读此小章关于密码学
-
推荐链接 偏通识
-
不太推荐但是放上来的链接 需要丰富的密码学相关经验
1. 什么是 Ed25519?
Ed25519 是一种基于 Edwards-curve Digital Signature Algorithm (EdDSA) 的高效签名方案,使用与 Curve25519 双有理等价的 edwards25519 曲线。其特点包括:
- 128 位经典安全性(不抗量子计算)。
- 确定性签名:签名的临时标量由密钥材料和消息确定;密钥生成仍需要安全随机数。
- 64 字节签名:紧凑且易于处理。
- 高性能:比 RSA 和传统 ECDSA 更快。
2. 核心原理
密钥生成
- 私钥:32 字节随机种子(通常来自 CSPRNG)。
- 公钥:对种子做 SHA-512;按 RFC 8032 修剪前 32 字节并解释为标量
a,计算A = a * B。后 32 字节用作签名的秘密前缀prefix。
签名
- 计算
r = SHA-512(prefix || 消息) mod L。 - 生成临时点
R = r * B。 - 计算
s = (r + (SHA-512(R || A || 消息) mod L) * a) mod L。 - 输出
(R, s)(共 64 字节)。
验证
检查:s * B == R + (SHA-512(R || A || 消息) mod L) * A。
以上为群运算示意。正式验证还包含点与标量编码、范围和余因子等检查,应交给符合 RFC 8032 的库。
3. Rust 实现示例
以下示例固定使用 ed25519-dalek 2.1.1。将步骤 2、3、4 的 Rust 代码按顺序合并到 src/main.rs,依赖放入一个 edition 2021 的 Cargo 项目,再运行 cargo run。
步骤 1:添加依赖
# Cargo.toml
[dependencies]
ed25519-dalek = { version = "=2.1.1", features = ["rand_core"] }
rand_core = { version = "=0.6.4", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
步骤 2:生成密钥对
#![allow(unused)]
fn main() {
use ed25519_dalek::{SigningKey, VerifyingKey, Signer, Verifier, Signature};
use rand_core::OsRng;
fn generate_keypair() -> SigningKey {
// 从安全随机源生成密钥对
let mut csprng = OsRng;
SigningKey::generate(&mut csprng)
}
}
步骤 3:签名与验证
#![allow(unused)]
fn main() {
fn sign_and_verify() {
// 1. 生成密钥对
let keypair = generate_keypair();
let public_key = keypair.verifying_key();
// 2. 签名一条消息(例如代币交易)
let message = b"Transfer 100 tokens to Alice";
let signature: Signature = keypair.sign(message);
// 3. 验证签名
match public_key.verify(message, &signature) {
Ok(_) => println!("Signature is valid!"),
Err(_) => println!("Signature is invalid!"),
}
}
}
步骤 4:完整代币交易示例
#[derive(serde::Serialize)]
struct TokenTransaction {
sender: String,
receiver: String,
amount: u64,
}
impl TokenTransaction {
fn sign(&self, keypair: &SigningKey) -> Signature {
let message = serde_json::to_vec(self).unwrap();
keypair.sign(&message)
}
fn verify(&self, signature: &Signature, public_key: &VerifyingKey) -> bool {
let message = serde_json::to_vec(self).unwrap();
public_key.verify(&message, signature).is_ok()
}
}
fn main() {
let keypair = generate_keypair();
let transaction = TokenTransaction {
sender: "Bob".to_string(),
receiver: "Alice".to_string(),
amount: 100,
};
sign_and_verify();
// 签名交易
let signature = transaction.sign(&keypair);
// 验证交易
let is_valid = transaction.verify(&signature, &keypair.verifying_key());
assert!(is_valid);
let mut changed = transaction;
changed.amount += 1;
assert!(!changed.verify(&signature, &keypair.verifying_key()));
println!("签名通过;修改金额后验证失败");
}
4. 为什么选择 Ed25519?
- 安全性
- 确定性签名减少对每次签名时随机数的依赖,不能消除密钥生成与保管风险。
- 抗侧信道攻击(恒定时间操作)。
- 性能
- 实际性能取决于平台、库、编译参数和任务;不采用无基准条件的固定倍数。
- 签名验证速度极快。
- 标准化
- 被 IETF (RFC 8032)、OpenSSH、Solana 等广泛采用。
5. 注意事项
- 私钥管理:必须安全存储种子(32 字节)。
- 库的选择:优先使用审计过的库(如
ed25519-dalek)。 - 不要自行实现密码学:直接使用标准库。
这个 TokenTransaction 仅用于本地消息签名练习,不是 Solana 或其他链的交易编码。正式支付协议需绑定发送者的公钥、网络、nonce 和有效期,并明确双方共享的序列化规则。
参考:RFC 8032、ed25519-dalek 2.1.1 API。
BLS 签名与聚合
Boneh-Lynn-Shacham 签名:基于配对友好椭圆曲线的短签名,其核心性质是可聚合——任意条签名可压成一个签名、一次验证。共识用它把几千张投票聚合成常数大小的证书(QC、committee attestation)。Rust 侧常见实现:blst(以太坊标准)、bls12-381 系列。
结构
- 双线性配对:\(e: G_1 \times G_2 \to G_T\),满足 \(e(aP, bQ) = e(P, Q)^{ab}\)。
- 密钥:私钥 \(sk\)(有限域标量)、公钥 \(pk = sk \cdot g_2\)。
- 签名:把消息哈希到 \(G_1\) 上 \(H(m)\),签名 \(\sigma = sk \cdot H(m)\)。
\[ e(\sigma, g_2) = e(H(m), pk) \]
成立即验签通过。安全性依赖 co-CDH(在一侧群上求离散对数困难);不抗量子计算。
聚合
对同一消息 \(m\),各签名 \(\sigma_i\) 可相乘:
\[ \sigma = \prod_i \sigma_i \]
验签变为:
\[ e(\sigma, g_2) = e(H(m), \prod_i pk_i) \]
一次配对验证全部签名,消息体只传一份。对不同消息聚合需 \(n+1\) 次配对,收益小得多——所以共识聚合都以“同一值“为单位(同一 QC、同一 target 的 attestation)。
Rogue-key 攻击:若密钥无需证明,攻击者可宣称一个抵消他人公钥的 \(pk\),使他人签名在自己聚合下“消失“。对策:注册公钥时附 proof of possession(对自身公钥的签名),并要求不同用途域分离(消息前缀区分签名目标)。
阈值签名
\(n\) 个分片各自对同一消息签名,凑够 \(f+1\) 或 \(2f+1\) 份即可合成一个完整签名,无需重建私钥。可用于:
- QC:\(2f+1\) 张投票合成一张证书,见 HotStuff 的星型结构。
- 随机 beacon:门限生成公开随机数,任何 \(t\) 个成员参与即出数,抵抗单点偏置。
阈值方案需要一个分布式密钥生成(DKG)阶段,谁参与、谁出私钥分片是信任前提。非门限的多签名聚合(MuSig 系)做链上 multisig,语义是“多个私钥同意“,与“签名压缩“不同。
在共识中的用途
| 场景 | 聚合对象 | 效果 |
|---|---|---|
| Ethereum Gasper | 同 target 的 attestation | 数万验证者的投票压成一条签名,上链成本与委员会数量解耦 |
| HotStuff QC | \(2f+1\) 张投票 | 证书常数大小,视图切换只传最高 QC |
| 随机 beacon | 门限签名 | 公开随机源,见 VRF 的对照 |
与 Schnorr / Ed25519 的差别
| BLS | Schnorr / Ed25519 | |
|---|---|---|
| 聚合 | 任意签名直接相乘 | 需交互式 key aggregation(MuSig)或签名者公开聚合 |
| 验证 | 聚合后一次配对 | 每条签名逐一验证 |
| 数学 | 配对友好曲线 | 一般椭圆曲线 |
| 生态 | 以太坊质押、drand | 比特币 Taproot、Solana |
BLS 验证用配对,曲线族固定(BLS12-381),实现复杂度集中在 hash-to-curve 与 subgroup check;Ed25519 在单签名场景更简单,见 ED25519。
VRF(可验证随机函数)
可验证随机函数(Verifiable Random Function)由密钥持有者本地计算:给定输入 \(x\) 输出伪随机值 \(y\) 与证明 \(\pi\),任何人可用公钥验证 \(y\) 确实由 \((sk, x)\) 决定生成。共识用它做无偏随机抽签:提议者或委员会由随机数选中,且结果在揭示前不可预知。
性质
- 确定性 / 唯一性:对固定的 \((sk, x)\) 只有唯一输出,无法对同一输入给出两个都通过验证的 \(y\)。
- 伪随机性:没有私钥的人无法把 \(y\) 与均匀随机数区分开(即使知道 \(x\) 与所有历史输出)。
- 可验证性:\(\pi\) 证明 \(y = \mathrm{VRF}_{sk}(x)\),第三方验算不需要信任持有者。
与普通哈希的差别:对公开输入做 \(H(x)\) 谁都能算、可随意 grind;VRF 的输出绑定私钥,别人算不出、持有者改不了。
构造
- EC-VRF(RFC 9381):私钥标量乘基点得到哈希点,输出该点坐标的哈希,证明含该点及其标量乘法证据。
- BLS VRF:把输入哈希到 \(G_1\) 后乘私钥,输出即签名本身,用配对验证,见 BLS。
- RSA-VRF:基于 RSA 求模幂,历史上用于 Ouroboros(Cardano)。
验算成本在椭圆曲线或配对侧,远高于裸哈希,但结论是“无偏 + 可证明“,这是抽签的信任前提。
抽签
按权重抽签:验证者 \(i\) 计算 \(v = \mathrm{VRF}_{sk_i}(\text{round})\),若
\[ \frac{v}{2^{256}} < p_i \]
则本轮中签,\(p_i\) 与其质押权重成正比。中签者自证并广播证明;未中签者无需透露结果——这是与“全体轮询“的关键差异,见 Algorand。子单位法把质押细分为最小单元再逐个抽签,可做到精确加权而不泄露细节。
随机 beacon
多个成员的 VRF 输出混合(XOR / 级联)成公开随机源,用于提议者日程、委员会分片。与链上 RANDAO 的对照:
| RANDAO(以太坊) | VRF / 门限 beacon | |
|---|---|---|
| 随机来源 | 提议者自愿揭示的随机值 | 密钥持有者本地计算 |
| 偏置面 | 最后一个揭示者可微调 | 需控制阈值比例的密钥才能偏置 |
| 信任前提 | 无密钥假设,但依赖参与者诚实揭示 | 依赖 DKG / 密钥分片 |
| 用途 | 验证者选择 | Algorand 抽签、drand、Dfinity |
以太坊用 RANDAO + 延迟揭示降低操纵面;Algorand 直接把 VRF 用于每轮抽签,见 PoS 通论 的抽签一节。
开发工具与数据入口
资料核对:2026-10-05。按开发任务选择工具;安装版本、插件兼容性与网络支持范围应结合项目检查。
EVM:先完成编译、测试和交互
| 任务 | 官方入口 | 使用时关注什么 |
|---|---|---|
| 用 Solidity 编写测试 | Foundry | Forge 负责构建和测试,Anvil 提供本地节点,Cast 用于命令行交互,Chisel 用于交互式实验 |
| 组织插件、脚本与部署流程 | Hardhat 3 | 先确认主版本;测试、网络配置和部署插件需与其兼容 |
| 在浏览器中检查小例子 | Remix | 明确编译器、优化设置、部署环境和当前账户 |
| 引用代币及权限模块 | OpenZeppelin Contracts 5.x | 锁定依赖版本;检查编译器要求、构造参数和升级兼容性 |
| 编写链上交互脚本 | ethers v6或 viem | 区分读取与签名发送;检查 chainId、ABI、整数单位与交易回执 |
| 检查静态风险 | Slither | 结合告警位置、调用路径和测试判断,工具输出不等于安全结论 |
Foundry 与 Hardhat 都能覆盖合约开发流程。选型可以从测试语言、现有依赖、插件需求与部署方式开始,无需先建立“哪个性能最好”的笼统排名。
Hardhat 3 官方入门使用 npx hardhat --init 初始化项目,再运行 npx hardhat test。部署能力通常由对应插件提供;阅读旧教程里的 hardhat deploy 时,要先确认它依赖的插件及版本,见 Hardhat 入门说明。
OpenZeppelin 提供标准实现和模块,但应用新增的权限、组合方式、初始化及升级逻辑仍需要验证。官方文档也提示,不同主版本之间应假定存储布局不兼容,不能直接用于可升级合约的跨主版本替换。
在本站开始练习
可先阅读 ERC-20、ERC-721与 EIP-2612。这些文章引用仓库中的同一组 Solidity 示例,覆盖授权、转移、回调和签名边界。
本地项目位于仓库的 examples/ethereum-signatures/,复现步骤见该目录的 README.md。示例固定 Solidity 0.8.24、OpenZeppelin Contracts 5.2.0 与 Cancun 目标环境,便于重复运行;工具官方文档可能对应更新的版本。
Solana:程序、代币与客户端分别选择
| 任务 | 官方入口 | 核对重点 |
|---|---|---|
| 开发和测试程序 | Anchor | 账户约束、PDA、CPI、IDL,以及程序与客户端版本 |
| 创建和操作代币 | Solana 代币文档 | 原始 Token Program 与 Token-2022 的区别,mint、冻结与扩展权限 |
| 编写 RPC 与交易交互 | Solana SDK 指引 | 官方当前以 @solana/kit 为标准 JavaScript SDK,新项目应按其文档选择客户端与插件 |
| 查询账户和交易 | Solana Explorer | 集群、交易签名、指令、账户 owner 与执行日志 |
Solana 官方将 @solana/web3.js v1 标为 legacy;这与 Ethereum 的同名 web3.js 是不同项目,不能混用停更结论。
迁移 SDK 还需要检查上层库。当前 Anchor TypeScript 文档明确列出其客户端与 legacy @solana/web3.js v1 的兼容约束,因此不能把已有 Anchor 项目的依赖直接替换为 Kit。
Cosmos 与 IBC:先确认协议和组件版本
Cosmos 开发文档将 Cosmos SDK、CometBFT 和 IBC 分别作为入口:它们涉及应用框架、共识引擎和链间通信。开发应用链时,需要核对这些组件的兼容版本和升级流程。
IBC-Go 文档已同时涵盖 Classic 和 v2。跟随跨链教程前,先确认它使用哪种协议、哪种客户端验证模型,以及应用如何处理确认与超时。
历史教程中的工具如何处理
| 工具或库 | 已核对的官方信息 | 阅读旧项目的做法 |
|---|---|---|
| Truffle / Ganache | Consensys 在 2023 年宣布退出维护 | 保留旧项目的依赖记录;新学习项目优先使用 Foundry/Anvil 或 Hardhat |
| Ethereum web3.js | ChainSafe 公告归档日期为 2025-03-04 | 对照迁移文档评估 ethers 或 viem;检查数值类型和签名 API 的差异 |
| Solana web3.js v1 | Solana 官方标为 legacy | 结合程序客户端兼容性迁移;不要将 Ethereum 库的公告套用到这里 |
看到旧教程时,先记录依赖主版本、编译器和网络,再判断代码是否适用于当前项目。工具曾经常用不代表其命令、插件或维护状态仍然相同。
数据入口:先看指标口径,再看数值
DeFiLlama Chains用于观察各链 DeFi 数据。它的 TVL 排名不能当作代币市值排名;聚合口径、资产价格与统计开关也会影响比较结果。
| 指标 | 适合观察什么 | 需要避免的误读 |
|---|---|---|
| TVL | 协议中按统计口径计算的锁定资产价值 | 把价格上涨当作等量资金流入,或忽略跨协议重复计算 |
| 交易量 | 指定时间段与市场范围的交易活动 | 用成交总额代替盈利或独立用户数 |
| Fees / Revenue | 用户付费与协议保留收益的不同口径 | 将付给流动性提供者等参与者的费用全部当作协议收入 |
| 活跃地址 | 指定时间段出现链上活动的地址 | 将地址数直接等同于人数 |
TVL 与费用收入的口径参见 DeFiLlama 数据定义。对比时记录日期、时间窗口、链范围、币种与统计开关,让结论可以复核。
L2BEAT可用于查询 L2 的价值统计、活动和风险信息。阅读项目页时重点看状态验证、数据可用性、排序器故障和退出条件;Stage 表达其阶段分类,不能单独当作项目安全评级。
区块浏览器适合追踪一笔具体交易。Etherscan与 Solana Explorer是相应生态的查询入口;先确认网络和合约或程序地址,再分析调用、日志及状态变化。
一次完整练习的检查顺序
- 写明网络、chainId、编译器、库版本与目标执行环境。
- 在本地运行正常路径及权限、余额不足、重复签名等失败路径。
- 对照 ABI 或 IDL 发起交互,记录交易哈希或签名。
- 核对交易回执、执行日志和状态变化,再检查确认与最终性。
- 保存复现命令与版本记录;升级依赖时重新检查受影响的行为。
需要了解这些工具服务于哪些应用,可返回应用与技术方向;需要补充交易与共识概念,可先读区块链基础。
Solana
基础机制
账户模型
#![allow(unused)]
fn main() {
pub struct Account{
pub lamports:u64,
pub data:Vec<u8>,
pub owner:Pubkey,
pub executable:bool,
pub rent_epoch:Epoch,
}
}
结构分析:
| 字段 | 类型 | 作用 |
|---|---|---|
| lamports | u64 | 表示账户的 SOL 余额,以 Lamports 为单位。1 SOL = 1,000,000,000 Lamports(10^9)。用于支付交易费用和账户租金。 |
| data | Vec<u8> | 存储账户的实际数据内容。对于数据账户,存储由 owner program 定义和解析的状态数据;对于程序账户,可能存储可执行字节码或指向程序数据账户的地址。 |
| owner | Pubkey | 表示拥有该账户写权限的程序 ID。owner program 可以修改账户数据或扣减账户 lamports,任意程序可以向可写账户增加 lamports。 |
| executable | bool | 标识该账户是否为可执行账户。当为 `true` 时,表示账户存储的是可执行的智能合约;否则,为普通账户。 |
| rent_epoch | Epoch | 历史遗留字段。早期用于记录账户租金周期;当前重点是账户需要满足 rent-exempt minimum,租金周期字段已不再作为定期扣费依据。 |
关键字说明
• lamports (u64):
• 类型:无符号 64 位整数 (u64)。
• 作用:账户的 SOL 余额,以 Lamports 为单位。用于支付交易费用和账户租金,确保账户持有足够的余额以维持其在区块链上的存在。
• data (Vec<u8>):
• 类型:动态字节向量 (Vec<u8>)。
• 作用:存储账户的实际数据。对于数据账户,可以存储代币余额、NFT 元数据等由程序定义的状态;对于程序账户,可能存储可执行字节码或指向程序数据账户的地址。账户数据的格式由 owner program 定义,普通钱包账户通常不用于存储应用状态。
• owner (Pubkey):
• 类型:公钥 (Pubkey) –solana自定义类型,非rust原生。
• 作用:标识拥有该账户写权限的程序 ID。只有 owner program 可以修改账户的 data 字段或扣减账户中的 lamports;任意程序可以向可写账户增加 lamports。这里的 owner 不是“用户所有者”,而是 Solana runtime 用来判断账户修改权限的程序地址。
• executable (bool):
• 类型:布尔值 (bool)。
• 作用:标识账户是否为可执行账户。当设置为 true 时,表示该账户存储的是可执行的智能合约;否则,为普通账户,用于存储数据和资产。
• rent_epoch (Epoch):
• 类型:Epoch 类型,表示区块链的一个时间周期–solana自定义类型,非rust原生。
• 作用:历史上用于管理账户的存储租金周期。当前租金定期扣除机制已废弃,新账户通常需要满足 rent-exempt minimum,即根据账户数据大小存入最低 lamports 余额以保持账户留存在链上。
rent_epoch :这是从 Solana 有定期从账户扣除 lamports 的机制时遗留下来的一个旧字段。虽然这个字段在 Account 类型中仍然存在,但由于租金收集已被弃用,它不再被使用。
示例
Solana 账户的 Account 结构体实例:
#![allow(unused)]
fn main() {
let account = Account {
lamports: 1_000_000_000, // 1 SOL
data: vec![0, 1, 2, 3], // 存储的数据
owner: Pubkey::new_unique(), // owner program 的地址,此处仅作示例
executable: false, // 普通账户
rent_epoch: u64::MAX, // 历史遗留字段,rent-exempt 账户通常为 u64::MAX
};
}
原生程序(Native Programs)
以下大部分为对solana官方文档的翻译,如有谬误请联系指正!
系统程序:
1.创建链上所有的新账户
2.为账户分配数据空间
3.转移账户所有权到自定义程序中
BPF加载器
全称 Berkeley Packet Filter Loader(伯克利包过滤器加载器)。在 Solana 中,程序会被编译为 sBPF 字节码,并由 loader program 负责部署、升级和执行。 1.已部署程序账户的 owner 通常是某个 loader program
2.处理程序部署和升级
-
系统程序(System Program)
所有在 Solana 上的“钱包”账户都只是由系统程序拥有的账户。这些账户中存储的 lamport 余额代表钱包拥有的 SOL 数量。只有由系统程序拥有的账户才能用作交易费用支付者。 -
BPF Loader
BPF Loader 是 Solana 用来管理链上程序的加载器,不包括其他原生程序。它负责部署、升级和执行自定义程序;当前常见的可升级程序通常由 BPF Loader Upgradeable(loader-v3)管理。
系统变量账户(Sysvar Accounts)
Sysvar 账户是位于预定义地址的特殊账户,提供对集群状态数据的访问。这些账户会动态更新关于网络集群的数据。账户类型见 Sysvars。
自定义程序(Custom Programs)
在 Solana 上,“智能合约”被称为程序。程序是包含可执行代码的账户。程序账户的 executable 标志为 true。
程序被部署到网络中,可以被用户和其他程序调用以执行它们的指令。
程序账户(Program Account)
Loader-v3 的可升级程序涉及以下账户;部署阶段的临时缓冲账户与持续存在的程序账户不同:
- Program:程序入口账户,记录 ProgramData 地址。
- ProgramData:保存程序代码、升级权限等信息。
- 缓冲账户:在程序部署或升级期间创建的临时账户,用于存储程序的执行代码。成功部署后,代码将被移动到程序执行数据账户,缓冲账户将被关闭。
例如,以下是 Token Extensions Program Account 及其对应的 Program Executable Data Account 在 Solana Explorer 中的链接。 指令使用程序账户地址作为 Program ID。Loader-v3 的 Program 账户指向 ProgramData,后者保存程序代码与升级权限;账户关系依赖所使用的加载器版本。
数据账户(Data Account)
Solana 上的程序是“无状态的”,这意味着程序账户仅存储可执行代码,不存储用于读取的数据。 为了维护状态,程序定义指令以创建由程序拥有的单独账户。每个账户都有自己的唯一地址,可以存储程序定义的任何任意数据。 注意,只有系统程序可以创建新的账户。一旦系统程序创建了账户,它就可以将新账户的所有权转让给另一个程序。 换句话说,为自定义程序创建数据账户需要两个步骤:
1.调用系统程序创建账户,然后将其所有权转让给自定义程序
2.调用现在拥有账户的自定义程序,然后初始化程序指令定义的账户数据
这个账户创建过程通常被抽象为单一步骤。
交易和指令 (Transactions and Instructions)
Solana 交易包含一个或多个指令,指令指定目标程序、账户元数据和操作数据。程序定义对应的指令处理逻辑,运行时按交易中的顺序执行。 以下是关于事务处理的关键细节:
- 如果一笔交易包含多个指令,则指令将按照它们被添加到交易中的顺序执行
- 交易是“原子性”的——要么所有指令都成功处理,要么整个交易失败且不进行任何更改 为了简化,可以将交易视为处理一个或多个指令的请求
交易按序执行指令,指令失败时业务状态变化回滚。交易手续费及 durable nonce 的特殊处理与业务回滚分别定义,不能把失败概括为所有状态都没有变化。
关键点
- Solana 交易包括在网络上调用程序的指令请求。
- 交易是原子的 - 如果任何指令失败,整个交易将失败且不会发生任何更改。
- 一笔交易的最大大小为 1232 字节。
- 每条指令需要 3 个信息:
- 被调用程序所在的地址
- 指令将发起读或写操作的目标账户列表
- 任何被指令所需要的附加数据(如函数参数)
基本示例
以下是一个表示单条指令将 SOL 从发送者转移到接收者的交易的图示。
Rust
Rust 通过所有权、借用和生命周期约束管理内存,类型系统在编译期检查多类无效访问。safe Rust 的内存安全保证依赖编译器及所使用 unsafe 实现的正确性,不等同于程序不存在逻辑错误。
泛型、trait、模式匹配和迭代器组织抽象;Cargo 管理项目构建、依赖与测试。具体资源成本由生成代码和运行行为决定,零开销抽象不表示所有操作都没有成本。
官方资源
相关资料
项目实例
rust中基本语句,给声明的变量绑定某个值
个人理解 : rust中变量设定是内存安全的基石之一 1.对于指针概念的隐藏,通过变量声明控制 2.对于指针访问操作的限制:通过变量可修改性的限制
- 变量重绑定:指已经声明过的变量(指针),重新指向另一片内存空间。此内存空间存储的可以是另一种类型的变量。理解部分:变量可不可变,指的是值,不是指针本身
- 赋值与绑定的区别:赋值为修改已经声明的变量的值
- 关键字
let用于声明变量绑定
不可变变量
- 不可变变量:指值在操作过程中不可修改,可理解为C、C++语言中 此变量声明后作为一个指针,指针指向的内存地址数据为只读
fn main(){
let var1 = 1; //声明并绑定不可变变量
println!("{}",var1);
let var1 = 2;//不可变变量重绑定
println!("{}",var1);
//var1 = 3; //此代码将报错,将变量赋值给不可变变量
}
错误示例
fn main(){
let var1 = 1; //声明并绑定不可变变量
println!("{}",var1);
var1 = 3; //此代码将报错,将变量赋值给不可变变量
}
可变变量
- 可变变量:指值在操作过程中可以修改,可以理解为指针指向的数据可以修改!由于rust变量类型限制,仅能赋值同类型变量。
- 关键字
mut用于声明变量可变,不可变则无此声明
fn main(){
let mut var2 = 1;//声明并绑定可变变量
println!("{}",var2);
var2 = 2; //此代码正确,可变变量可赋值
println!("{}",var2);
}
rust库中默认内置的变量
一、标量类型(Scalar Types)
1. 整数类型(Integer)
Rust 的整数类型根据符号和位宽分类,命名格式为 [u|i][位数]:
| 类型 | 位数 | 范围 | 示例值 | 典型场景 |
|---|---|---|---|---|
i8 | 8 | -128 ~ 127 | -5i8 | 小范围有符号整数 |
u8 | 8 | 0 ~ 255 | 255u8 | 字节处理、颜色值 |
i16 | 16 | -32,768 ~ 32,767 | 30000i16 | 中等范围有符号整数 |
u16 | 16 | 0 ~ 65,535 | 65535u16 | Unicode 字符码点(如 '\u{2764}') |
i32 | 32 | -2,147,483,648 ~ 2,147,483,647 | 42(默认推断) | 通用整数,性能最佳 |
u32 | 32 | 0 ~ 4,294,967,295 | 1_000_000u32 | 大范围无符号计数 |
i64 | 64 | -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 | -9_223_372_036i64 | 时间戳、大数值计算 |
u64 | 64 | 0 ~ 18,446,744,073,709,551,615 | 18_446_744u64 | ID 生成、超大计数 |
isize | 平台相关(通常 32/64 位) | 与指针大小相同 | -10isize | 集合索引、内存偏移量 |
usize | 平台相关(通常 32/64 位) | 与指针大小相同 | 0usize | 集合长度、内存地址 |
关键特性:
- 默认类型:未标注时,整数字面量默认推断为
i32。 - 进制表示:
#![allow(unused)] fn main() { let decimal = 98_222; // 十进制:98222 let hex = 0xff; // 十六进制:255 let octal = 0o77; // 八进制:63 let binary = 0b1111_0000; // 二进制:240 let byte = b'A'; // u8 类型字节:65 } - 溢出处理:Debug 模式下溢出会 panic,Release 模式下自动按补码循环(需显式处理用
Wrapping类型)。
2. 浮点类型(Floating-Point)
Rust 提供两种精度浮点数,遵循 IEEE-754 标准:
关于浮点类型是一种以表达式形式存储在内存中的变量
图片来源 :csapp + 个人学习记录
| 类型 | 位数 | 精度 | 范围 | 示例值 | 场景 |
|---|---|---|---|---|---|
f32 | 32 | 6-7 位小数 | ±3.4×10³⁸ ~ ±1.7×10³⁸ | 3.14f32 | 图形计算、嵌入式 |
f64 | 64 | 15-17 位小数 | ±1.7×10³⁰⁸ ~ ±1.1×10³⁰⁸ | 2.718(默认) | 科学计算、通用 |
重要特性:
- 默认类型:未标注时,浮点字面量默认推断为
f64(因现代 CPU 高效支持)。 - 数值运算:
#![allow(unused)] fn main() { let sum = 5.0 + 10.0; // f64 let difference = 95.5 - 4.3; let product = 4.0 * 30.0; let quotient = 56.7 / 32.2; let remainder = 43.0 % 5.0; } - 陷阱:浮点数存在精度误差,避免直接判等:
#![allow(unused)] fn main() { // ❌ 错误写法 assert!(0.1 + 0.2 == 0.3); // ✅ 正确做法 let tolerance = 1e-10; assert!((0.1 + 0.2 - 0.3).abs() < tolerance); }
3. 布尔类型(Boolean)
- 类型名:
bool - 值域:
true或false - 内存占用:1 字节
- 使用场景:条件判断、逻辑运算
#![allow(unused)] fn main() { let is_rust_cool = true; let is_heavy: bool = 10 > 5; // 自动推断为 true }
4. 字符类型(Character)
- 类型名:
char - 内存占用:4 字节(存储 Unicode 标量值)
- 值域:U+0000 ~ U+D7FF 和 U+E000 ~ U+10FFFF
- 表示方式:单引号,支持 Unicode 转义:
#![allow(unused)] fn main() { let c = 'z'; let emoji = '🚀'; // 直接输入 Unicode let heart = '\u{2764}'; // Unicode 转义:❤ }
二、复合类型(Compound Types)
1. 元组(Tuple)
- 定义:固定长度、可包含不同类型的集合。
- 内存布局:元素紧密排列,无额外开销。
- 示例:
#![allow(unused)] fn main() { let tup: (i32, f64, bool) = (500, 6.4, true); // 显式类型标注 let (x, y, z) = tup; // 解构赋值 let first = tup.0; // 索引访问 } - 空元组:
()表示“无返回值”,是函数的默认返回类型。
2. 数组(Array)
- 定义:固定长度、相同类型的集合,栈上分配。
- 适用场景:已知元素数量的数据集(如月份名称)。
- 示例:
#![allow(unused)] fn main() { let months = ["Jan", "Feb", "Mar"]; // 类型推断为 [&str; 3] let arr: [i32; 5] = [1, 2, 3, 4, 5]; // 显式标注 let zeros = [0; 10]; // 初始化 10 个 0:[0, 0, ..., 0] let first = arr[0]; // 索引访问(编译时检查越界) } - 越界访问:运行时 panic(如
arr[5])。
三、类型转换与处理
1. 显式类型转换(as)
#![allow(unused)]
fn main() {
let x = 1000i32;
let y = x as u64; // 安全转换
let z = x as u8; // 可能丢失数据(1000 → 232)
}
2. 处理不同类型运算
需手动统一类型:
#![allow(unused)]
fn main() {
let a = 10i32;
let b = 100u32;
let sum = a as i64 + b as i64; // 统一为 i64 避免溢出
}
3. 类型推断与标注
#![allow(unused)]
fn main() {
let x = 42; // 默认推断为 i32
let y: u8 = 10; // 显式标注为 u8
let z = x + y; // ❌ 错误:类型不匹配
let z = x + y as i32; // ✅ 正确
}
四、最佳实践与常见问题
1. 如何选择整数类型?
- 通用场景:优先使用
i32(性能最优)。 - 内存敏感:
u8、i16(如网络协议)。 - 大集合索引:
usize(与平台指针同宽)。
2. 为什么浮点数比较需要特殊处理?
- 浮点运算存在精度损失,直接判等可能失败。
- 使用误差范围(epsilon)或专用库(如
approx)。
3. 何时使用数组 vs 向量(Vec<T>)?
- 数组:长度固定、栈分配(高效)。
- 向量:动态长度、堆分配(灵活)。
五、代码示例
fn main() {
// 整数操作
let decimal = 98_222; // i32
let hex = 0xff; // i32
let byte = b'A'; // u8
let max_u32 = u32::MAX; // 4,294,967,295
// 浮点运算
let x = 2.0; // f64
let y: f32 = 3.0; // f32
let result = x + (y as f64); // 统一类型
// 布尔逻辑
let is_greater = 10 > 5; // true
// 字符与Unicode
let emoji = '🚀';
let heart = '\u{2764}';
// 元组和数组
let tup = (500, 6.4, true);
let arr = [1, 2, 3, 4, 5];
let first_element = arr[0];
}
Rust Trait:用共享行为连接不同类型
Trait 描述一组类型必须提供的行为,作用接近其他语言的接口,但 Rust 还可以通过默认实现、泛型约束和 trait object 支持静态与动态分发。
1. 定义并实现 Trait
下面的 Describe 要求类型提供 describe 方法。Teacher 和 Student 的字段不同,但都可以被同一个泛型函数使用:
trait Describe {
fn describe(&self) -> String;
}
struct Teacher {
name: String,
subject: String,
}
struct Student {
name: String,
grade: u8,
}
impl Describe for Teacher {
fn describe(&self) -> String {
format!("教师 {},教授 {}", self.name, self.subject)
}
}
impl Describe for Student {
fn describe(&self) -> String {
format!("学生 {},年级 {}", self.name, self.grade)
}
}
fn print_description<T: Describe>(value: &T) {
println!("{}", value.describe());
}
fn main() {
let teacher = Teacher { name: "Lin".into(), subject: "Rust".into() };
let student = Student { name: "Yu".into(), grade: 2 };
print_description(&teacher);
print_description(&student);
}
T: Describe 是编译期约束。编译器会为每种具体类型生成调用路径,通常可以内联,称为静态分发。
2. impl Trait 与 dyn Trait
参数位置可以写成 impl Trait:
#![allow(unused)]
fn main() {
fn print_description(value: &impl Describe) {
println!("{}", value.describe());
}
}
它表达“传入某个实现了 Trait 的具体类型”,仍然是静态分发。等价的泛型写法 fn print_description<T: Describe>(value: &T) 更适合同时约束多个参数为同一种类型。
如果需要在同一个集合中保存不同的实现,使用 trait object:
#![allow(unused)]
fn main() {
let people: Vec<Box<dyn Describe>> = vec![
Box::new(Teacher { name: "Lin".into(), subject: "Rust".into() }),
Box::new(Student { name: "Yu".into(), grade: 2 }),
];
for person in people {
println!("{}", person.describe());
}
}
dyn Describe 通过虚表在运行时选择实现,属于动态分发。Trait 必须满足 object safety 才能作为 trait object;带有返回 Self 或泛型方法的 Trait 通常不能直接用于 dyn Trait。
3. 默认实现与 Trait bound
Trait 可以提供默认实现,具体类型只覆盖真正不同的行为:
#![allow(unused)]
fn main() {
trait Summary {
fn title(&self) -> &str;
fn preview(&self) -> String {
format!("摘要:{}", self.title())
}
}
fn show<T: Summary>(item: &T) {
println!("{}", item.preview());
}
}
多个约束可以使用 + 组合,例如 T: Summary + Display。返回值位置可以写 impl Summary,但一个函数只能返回一种隐藏的具体类型;如果需要返回多种实现,应返回 Box<dyn Summary>。
4. 常见误区
- Trait 不会自动为类型实现行为,必须写出
impl Trait for Type。 impl Trait通常表示一个具体类型;dyn Trait才表示运行时可替换的多种类型。- 方法命名应遵循 Rust 风格,例如
say_age和self_introduction,不要使用Sayage、SelfIntroduction这类大驼峰方法名。 - 只有在确实需要异构集合或运行时插件时才使用
Box<dyn Trait>;普通参数优先使用泛型约束。
泛型
Java 中的泛型 代码复用的工具,
泛型:给不同类型的对象提供相同的实现
- 不同类型的史莱姆实现
struct Slime<T> {
size: T,
weight: T,
}
impl<T: std::fmt::Display + Copy> Slime<T> {
fn new(size: T, weight: T) -> Slime<T> {
Slime { size, weight }
}
fn yell(&self) {
println!(
"as a slim ,my size is {},and my weight is {}",
self.size, self.weight
);
}
}
fn main() {
let a = Slime::new(10, 20); //整形史莱姆
a.yell();
let b = Slime::new("infinite", "2kg"); //字符串史莱姆
b.yell();
}
高级特性
闭包
-
闭包是一种特殊的匿名函数,rust 中声明形式为|x|{x},或者|x|->{x};前段部分|x|为参数部分,代表传入参数 x。后段部分{}代表函数体,示例中为返回 x 的值。
-
可以手动声明闭包的变量类型(推荐),如 |x:i32|{y:f64},当然,编译器的功能也是很强大的,很多情况下会自行推断
以下展示闭包的基本用法
正确用法
fn main() {
let x = |a,b|->i32{return a+b}; //定义闭包,传入参数a,b,返回a+b的值
println!("{:?}",x(1,2));
}
- 当 rust 函数中第一次调用闭包时,此闭包的的参数类型会被确定下来。(当然,配合特征变量可以实现对符合特征的变量做统一操作)
错误用法
fn main() {
let x = |a,b|{return a+b}; //定义闭包,传入参数a,b,返回a+b的值
println!("{}",x(1,2));
println!("{}",x(1.0,2.0));//此处报错
}
这里的闭包被编译器检查后认定是|a:i32,b:i32|->i32,所以无法再接受 f64 类型的参数。而且编译器在此情况下不会将两个参数类型一起考虑,将变量类型归类为特征变量。后续添加特殊实现方法,让闭包可以支持多种类型。
- 与普通函数仅能接收传入的指定参数不同,闭包可以捕获其所在环境中的变量,这些变量可以在闭包内部使用,也可以在闭包外部修改。 闭包能捕获的范围是当前闭包声明函数出现之前的变量
正确示范
fn main(){
let outside = 1;
let x =|a|{println!("{}",outside+a)};
x(1);
}
错误示范
fn main(){
//这里改变声明顺序,然后报错
let x =|a|{println!("{}",outside+a)};
let outside = 1;
x(1);
}
网络OSI模型
OSI(Open Systems Interconnection,开放系统互连)模型是国际标准化组织(ISO)于1984年提出的网络通信框架,旨在标准化网络体系结构,促进不同系统间的互联互通。该参考模型将网络通信划分为七层,每层定义相应功能。可靠传输由具体协议机制提供,不能由分层本身推导。
一、OSI模型概述
OSI模型并非具体协议,而是抽象参考框架,为网络协议设计提供了分层思路。每一层为上一层提供服务,并依赖下一层的支持。通过分层,复杂网络通信被分解为模块化、可管理的部分。实际网络(如TCP/IP)常参考OSI模型,但层级划分可能有所简化。
二、七层结构详解
-
物理层(Physical Layer)
- 职责:负责比特流的物理传输,包括电压、信号、接口标准等。
- 功能:定义硬件规范(如网线、光纤、连接器类型 RJ45)、电气特性(如信号电平)、传输介质(如双绞线、同轴电缆)。
- 示例:以太网的1000BASE-T标准(千兆以太网)通过双绞线传输0和1的比特流。
- 关键点:物理层不关心数据含义,只传输原始比特。
-
数据链路层(Data Link Layer)
- 职责:在相邻节点间提供可靠的数据传输,解决物理层不可靠性问题。
- 功能:将比特封装成帧(Frame),实现错误检测与纠正(如CRC校验)、流量控制和介质访问控制(MAC)。
- 示例:以太网(IEEE 802.3)使用MAC地址标识设备,Wi-Fi(IEEE 802.11)通过CSMA/CA避免冲突。
- 关键点:分为子层——LLC(逻辑链路控制)和MAC(介质访问控制)。
-
网络层(Network Layer)
- 职责:负责数据包在网络间的路由和转发。
- 功能:提供逻辑寻址(如IP地址)、路径选择(路由算法,如OSPF、BGP)和数据包分片/重组。
- 示例:IP协议(IPv4/IPv6)定义地址格式,路由器根据路由表转发数据包。
- 关键点:网络层关注端到端传输,而非单跳链接。
-
传输层(Transport Layer)
- 职责:提供主机间可靠的数据传输服务。
- 功能:实现分段与重组、流量控制、错误重传和连接管理。
- 示例:TCP提供可靠传输(通过序号和确认机制),UDP提供无连接快速传输。
- 关键点:引入端口号(如80、443),区分同一主机上的不同应用。
-
会话层(Session Layer)
- 职责:管理应用程序间的会话,建立、维护和终止通信。
- 功能:支持会话同步(如断点续传)、对话控制(如半双工/全双工)。
- 示例:远程登录协议(如NetBIOS)中,会话层协调客户端与服务器的连接状态。
- 关键点:在实际协议中,常与传输层或应用层合并。
-
表示层(Presentation Layer)
- 职责:处理数据的表示形式,确保应用层能正确解析。
- 功能:负责数据加密/解密(如SSL/TLS)、压缩/解压、格式转换(如JPEG、XML)。
- 示例:HTTPS中,TLS加密由表示层完成;浏览器解析HTML也涉及表示层逻辑。
- 关键点:关注数据语法与语义,而非传输。
-
应用层(Application Layer)
- 职责:为用户或应用程序提供网络服务接口。
- 功能:支持具体应用协议,如文件传输(FTP)、电子邮件(SMTP)、网页浏览(HTTP)。
- 示例:浏览器通过HTTP/HTTPS请求网页,邮件客户端使用IMAP接收邮件。
- 关键点:直接面向用户,是协议的最终体现。
三、OSI模型的工作流程
假设用户通过浏览器访问网页:
- 应用层:HTTP生成请求消息。
- 表示层:数据加密为HTTPS,格式化为HTML。
- 会话层:维持浏览器与服务器的会话。
- 传输层:TCP分段数据并添加端口号(如443)。
- 网络层:IP封装数据包,确定路由路径。
- 数据链路层:以太网帧封装,添加MAC地址。
- 物理层:比特流通过网线或Wi-Fi传输。
接收端按相反顺序解封装,最终呈现网页。
四、OSI模型的意义与局限
-
意义
- 标准化:为网络协议设计提供通用框架,促进设备互操作性。
- 模块化:分层简化开发与调试,便于教育和理解。
- 灵活性:支持多种协议栈(如TCP/IP映射到OSI)。
-
局限
- 理论性强:未直接实现为具体协议,实际网络(如TCP/IP)仅用4-5层。
- 复杂性:七层划分有时过于细化,会话层和表示层功能常被合并。
- 历史背景:1984年提出时,TCP/IP已占主导,OSI普及受限。
五、与TCP/IP模型的对比
TCP/IP模型是实际网络的主流实现,与OSI的对应关系如下:
- 应用层(TCP/IP) ≈ OSI的应用层+表示层+会话层
- 传输层 ≈ OSI的传输层
- 网络层(Internet层) ≈ OSI的网络层
- 网络接口层 ≈ OSI的数据链路层+物理层
TCP/IP更简洁实用,但OSI更适合理论分析。
OSI模型作为网络通信的经典框架,清晰地展示了数据传输的分层逻辑。尽管在现代网络中,TCP/IP占据主导地位,但OSI仍以其模块化思维和标准化理念,持续影响着网络技术的发展。理解OSI模型,不仅是掌握网络基础的第一步,也是深入探索通信技术的钥匙。
TCP/IP模型:互联网通信的基石
TCP/IP(Transmission Control Protocol/Internet Protocol)模型是支撑现代互联网的实际网络协议框架,由美国国防部于20世纪70年代开发,并在1983年成为ARPANET标准。它以简洁实用的分层结构,定义了数据通信的完整流程。以下将详细介绍TCP/IP模型的四层结构、核心功能及其应用。
一、TCP/IP模型概述
TCP/IP模型是为实现异构网络互联而设计的实用协议栈,与理论性的OSI模型不同,它直接对应具体协议(如IP、TCP、HTTP)。它将通信分为四层,每层专注于特定任务,共同完成从数据发送到接收的过程。TCP/IP的成功在于其开放性、灵活性和对互联网的适配性。
二、四层结构详解
-
网络接口层(Network Interface Layer)
- 职责:负责将数据包转化为物理信号,通过硬件传输。
- 功能:包括物理传输(比特流)和数据链路功能(如帧封装、MAC寻址)。
- 协议/技术:以太网(IEEE 802.3)、Wi-Fi(IEEE 802.11)、PPP(点对点协议)。
- 示例:以太网交换机根据MAC地址转发帧,网卡将数据转为电信号。
- 关键点:未严格定义具体协议,依赖底层硬件实现,与OSI的物理层和数据链路层对应。
-
网络层(Internet Layer)
- 职责:提供端到端的逻辑寻址和路由。
- 功能:数据包封装、IP地址分配、路由选择、分片与重组。
- 核心协议:IP(IPv4/IPv6)、ICMP(用于诊断,如Ping)、IGMP(组播管理)。
- 示例:路由器根据IP地址(如192.168.1.1)转发数据包,IPv6支持128位地址扩展。
- 关键点:无连接、无状态,依赖上层协议保证可靠性。
-
传输层(Transport Layer)
- 职责:为主机间通信提供服务,区分不同应用程序。
- 功能:数据分段、流量控制、错误纠正、连接管理。
- 核心协议:
- TCP:面向连接,提供可靠传输(通过序号、确认、重传机制)。
- UDP:无连接,快速但不可靠,适合实时应用。
- 示例:TCP用于网页加载(端口80/443),UDP用于视频流(如RTP)。
- 关键点:引入端口号(如80、53),实现进程间通信。
-
应用层(Application Layer)
- 职责:为用户和应用程序提供网络服务接口。
- 功能:处理具体应用逻辑,包括数据格式化、加密、会话管理。
- 核心协议:HTTP(网页)、FTP(文件传输)、SMTP(邮件发送)、DNS(域名解析)。
- 示例:浏览器通过HTTP请求网页,邮件客户端用IMAP接收邮件。
- 关键点:涵盖OSI的应用层、表示层和会话层功能,注重实用性。
三、TCP/IP模型的工作流程
以访问网页为例:
- 应用层:浏览器生成HTTP请求(如GET codermaybe.github.io)。
- 传输层:TCP将请求分段,添加端口号(如443)和序号。
- 网络层:IP封装数据包,分配源/目标IP地址(如192.168.1.10 → 93.184.216.34)。
- 网络接口层:以太网帧封装数据,添加MAC地址,通过网线传输。
接收端按相反顺序解封装,最终显示网页。
四、TCP/IP模型的特点与优势
-
特点
- 简洁实用:四层结构减少复杂性,便于实现和部署。
- 开放标准:由IETF维护,RFC文档(如RFC 791定义IP)公开透明。
- 异构互联:支持不同硬件和网络类型(如以太网、Wi-Fi、4G/5G)。
-
优势
- 广泛应用:驱动互联网、局域网和移动网络,成为事实标准。
- 可扩展性:IPv6解决了地址枯竭问题,DNS支持动态扩展。
- 模块化:各层独立开发,上层协议可复用下层服务。
五、与OSI模型的对比
TCP/IP模型与OSI模型的对应关系如下:
- 网络接口层 ≈ OSI的物理层+数据链路层
- 网络层 ≈ OSI的网络层
- 传输层 ≈ OSI的传输层
- 应用层 ≈ OSI的应用层+表示层+会话层
区别在于:OSI为理论框架,七层划分细致;TCP/IP为实际协议栈,四层设计更紧凑,应用层功能更集中。
六、TCP/IP的演进与现状
- 历史:1974年由Vinton Cerf和Robert Kahn提出,1983年ARPANET全面采用。
- 演进:IPv4(32位地址)向IPv6(128位地址)过渡,截至2025年3月,IPv6普及率已超50%(根据APNIC数据推测)。
- 现状:支持5G、IoT、云计算等技术,HTTP/3(基于UDP的QUIC)成为2025年应用层新趋势。


