ABI
在以太坊生态系统中,ABI(Application Binary Interface,应用二进制接口) 是连接智能合约与外部应用(如前端 DApp)的关键桥梁。对于刚接触区块链和 Solidity 的初学者来说,理解 ABI 的概念和作用至关重要。
什么是 ABI?
ABI,全称为 Application Binary Interface,即应用二进制接口。它定义了合约中的函数和事件如何与外部系统进行交互。换句话说,ABI 描述了合约的接口,包括函数的名称、参数类型、返回值以及事件的结构等信息。
智能合约部署到以太坊区块链后,其源代码对外不可见,外部应用需要一种方式与合约进行交互。ABI 就是这种桥梁,它让外部应用知道如何调用合约的函数、如何解码合约返回的数据以及如何监听合约的事件。
ABI 的组成
一个标准的 Solidity ABI 是一个 JSON(JavaScript Object Notation)数组,每个数组元素描述了合约中的一个函数、构造函数或事件。每个元素包含以下字段:
type
: 描述类型,如function
、constructor
、event
。name
: 函数或事件的名称。inputs
: 输入参数的详细信息,包括类型和名称。outputs
: (仅限函数)输出参数的详细信息。stateMutability
: 函数的状态可变性,如view
(只读)、nonpayable
(不接受以太币)等。anonymous
: (仅限事件)是否为匿名事件。
ABI 的作用
- 函数调用:ABI 描述了如何编码函数调用的参数,并解码合约返回的数据。外部应用通过 ABI 知道如何与合约的函数进行交互。
- 事件监听:ABI 定义了事件的结构,外部应用可以根据 ABI 监听和解析合约触发的事件。
- 接口兼容性:ABI 使得不同编程语言和工具能够一致地与合约进行交互,确保兼容性和互操作性。
当我们要调用一个函数时,使用 ABI JSON 的规范的要求,进行编码,传给 EVM, 同时在 EVM 层生成的字节数据(如时间日志等),ABI JSON 的规范进行解码。
合约 ABI 示例
假设有以下简单的 Solidity 合约:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract SimpleStorage { uint256 private data; event DataStored(address indexed sender, uint256 data); function storeData(uint256 _data) public { data = _data; emit DataStored(msg.sender, _data); } function getData() public view returns (uint256) { return data; } }
在编写 Solidity 合约后,编译工具(如 Solidity Compiler 或 Remix IDE)会自动生成 ABI。
对应的 ABI 如下:
[ { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "sender", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "data", "type": "uint256" } ], "name": "DataStored", "type": "event" }, { "inputs": [], "name": "getData", "outputs": [ { "internalType": "uint256", "name": "", "type": "uint256" } ], "stateMutability": "view", "type": "function" }, { "inputs": [ { "internalType": "uint256", "name": "_data", "type": "uint256" } ], "name": "storeData", "outputs": [], "stateMutability": "nonpayable", "type": "function" } ]
编码函数
Solidity 提供了一组内置的编码函数,用于将不同类型的数据打包成二进制格式,以便于在合约内部或与外部系统(如前端应用、其他合约)进行交互。
函数 | 编码格式 | 用途 | 优缺点 |
---|---|---|---|
abi.encode(...) | 标准 ABI 格式 | 函数调用、数据传输 | 可避免歧义,适用于大多数场景 |
abi.encodePacked(...) | 紧凑格式 | 哈希生成、紧凑数据存储 | 节省空间,但可能导致参数歧义 |
abi.encodeWithSignature(...) | 函数签名编码 | 低级别函数调用、外部交互 | 自动生成函数选择器,适合简化调用 |
abi.encodeWithSelector(...) | 选择器编码 | 灵活构建低级别调用数据 | 手动指定选择器,适合需要额外控制的场景 |
abi.encode()
功能:按照 Solidity 的标准 ABI 编码规则,将输入的参数编码为紧凑的字节数组(bytes
)。
用途:
- 构建函数调用的数据负载。
- 在合约内部传递复杂的数据结构。
示例:
pragma solidity ^0.8.0; contract EncoderExample { function encodeData(uint256 _num, address _addr) public pure returns (bytes memory) { return abi.encode(_num, _addr); } }
解释:
- 调用
encodeData(123, 0xAbC...)
会返回一个编码后的字节数组,包含123
和地址0xAbC...
的 ABI 编码。
abi.encodePacked(...)
功能:将输入的参数按照紧凑格式(packed)进行编码,减少数据冗余。
用途:
- 生成哈希(如
keccak256
)的输入。 - 创建更紧凑的数据表示。
注意:abi.encodePacked
可能导致参数间的歧义,尤其是在动态类型(如 string
、bytes
)和可变长度的数组中使用时。因此,使用时需谨慎,确保编码后的数据不会导致解析错误。
示例:
pragma solidity ^0.8.0; contract PackedEncoderExample { function encodePackedData(uint256 _num, address _addr) public pure returns (bytes memory) { return abi.encodePacked(_num, _addr); } }
解释:
- 调用
encodePackedData(123, 0xAbC...)
会返回一个更紧凑的字节数组,与abi.encode
相比,减少了填充字节。
abi.encodeWithSignature(...)
功能:根据给定的函数签名,将参数编码为 ABI 格式的字节数组,通常用于构建低级别的函数调用数据。
用途:
- 在合约间进行低级别的函数调用(如通过
call
)。 - 与外部应用程序(如前端、脚本)进行交互,构建函数调用数据。
示例:
pragma solidity ^0.8.0; contract WithSignatureExample { function getData(bytes memory _encoded) public pure returns (bytes32) { return keccak256(_encoded); } }
前端构建调用数据:
const { ethers } = require("ethers"); const abi = [ "function storeData(uint256 _data) public", ]; const iface = new ethers.utils.Interface(abi); const data = iface.encodeFunctionData("storeData", [123]); // data = "0x<function_selector><encoded_parameters>"
解释:
encodeFunctionData
内部使用abi.encodeWithSignature
来生成函数调用的数据。- 生成的数据包括函数选择器和编码后的参数,适用于合约的低级别调用。
abi.encodeWithSelector(...)
功能:根据给定的函数选择器,将参数编码为 ABI 格式的字节数组。
用途:
- 类似于
abi.encodeWithSignature
,但更灵活,可以手动指定函数选择器。 - 在合约内部进行低级别调用时使用。
示例:
pragma solidity ^0.8.0; contract WithSelectorExample { function getData(bytes4 _selector, uint256 _num) public pure returns (bytes memory) { return abi.encodeWithSelector(_selector, _num); } }
解释:
- 调用
getData(bytes4(keccak256("storeData(uint256)")), 123)
会返回编码后的字节数组,包含指定的函数选择器和参数。
实际应用示例
合约间低级别调用
假设有两个合约:Caller
和 Callee
。Caller
需调用 Callee
的 storeData
函数,但使用低级别的 call
。
Callee 合约:
pragma solidity ^0.8.0; contract Callee { uint256 public data; event DataStored(address indexed sender, uint256 data); function storeData(uint256 _data) public { data = _data; emit DataStored(msg.sender, _data); } }
Caller 合约:
pragma solidity ^0.8.0; contract Caller { function callStoreData(address _callee, uint256 _data) public returns (bool, bytes memory) { // 使用 abi.encodeWithSignature bytes memory payload = abi.encodeWithSignature("storeData(uint256)", _data); (bool success, bytes memory returnData) = _callee.call(payload); return (success, returnData); } function callStoreDataWithSelector(address _callee, uint256 _data) public returns (bool, bytes memory) { // 手动构建选择器 bytes4 selector = bytes4(keccak256("storeData(uint256)")); bytes memory payload = abi.encodeWithSelector(selector, _data); (bool success, bytes memory returnData) = _callee.call(payload); return (success, returnData); } }
解释:
Caller
合约通过abi.encodeWithSignature
和abi.encodeWithSelector
构建调用Callee
合约的storeData
函数的数据负载。- 使用低级别的
call
方法执行函数调用,捕获返回值和执行状态。
前端与合约交互
使用 Ethers.js 构建函数调用数据,并通过低级别调用与合约交互。
const { ethers } = require("ethers"); // 合约ABI和地址 const contractABI = [ "function storeData(uint256 _data) public", ]; const contractAddress = "0xC2eF4Beb82626190C6E80605e9f95CD3aC55583B"; // 创建接口 const iface = new ethers.utils.Interface(contractABI); // 编码函数调用数据 const data = iface.encodeFunctionData("storeData", [123]); // 结果: "0x<function_selector><encoded_parameters>" // 发送交易 const provider = new ethers.providers.JsonRpcProvider("https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID"); const signer = provider.getSigner(); const tx = { to: contractAddress, data: data, }; signer.sendTransaction(tx).then((transaction) => { console.log("Transaction sent:", transaction.hash); });
解释:
- 使用 Ethers.js 的
Interface
对象,简化函数调用数据的编码过程。 encodeFunctionData
自动处理函数选择器和参数编码,生成适用于交易的数据字段。
事件与日志
事件概述
事件是智能合约与外部世界交互的重要机制,广泛应用于以下场景:
- 状态变更通知:当合约状态发生变化时,通过事件通知外部应用。例如,代币转账事件。
- 调试与记录:开发者可以使用事件来记录合约执行过程中的关键信息,便于调试和审计。
- 触发前端更新:前端应用可以监听特定事件,以动态更新界面或执行特定操作。
事件与日志的关系
在以太坊生态系统中,事件和日志是紧密相连的概念。**日志(Logs)**是存储在以太坊区块链上的数据结构,用于记录智能合约中触发的事件。在 Solidity 中,事件是通过日志(Logs)来实现的。当合约中的事件被触发时,相应的日志条目会被创建并添加到当前的区块中。日志具有如下几个重要特点:
- 不可修改性:一旦日志被记录到区块链上,它们就变成了不可更改或删除的记录。这保证了事件数据的完整性和可信度。
- 低成本:相比于将数据存储在合约的状态变量中,记录日志的gas成本要低得多。这使得事件成为一种经济高效的数据存储方式,特别是对于不需要直接被合约访问的数据。
- 可查询性:外部应用可以方便地查询和订阅这些日志,从而获取合约中发生的重要事件信息。
- 特殊的数据结构:日志不存储在区块链的全局状态中,而是作为元数据附加在区块上。这种设计使得日志的存储和检索更加高效。
事件的结构与特性
事件的定义和使用涉及几个关键概念:签名、主题和索引参数。理解这些概念对于有效使用事件至关重要。
每个事件都有一个唯一的签名,它包括事件名称和参数类型。例如,一个事件 Transfer(address indexed from, address indexed to, uint256 value)
,它带有 address
类型的 from
和 to
参数,以及 uint256
类型的 value
参数,其签名就是 Transfer(address,address,uint256)
。
事件签名经过 Keccak-256 哈希运算后,得到的哈希值被称为事件的主题(Topic)。这个主题用于在区块链上唯一标识该事件,使得外部应用能够快速定位和过滤特定类型的事件。
Solidity 允许使用 indexed
关键字标记事件参数。这些被标记的参数称为索引参数,它们具有特殊的属性:
- 索引参数允许外部应用基于这些参数值高效地过滤和查询相关事件。
- 每个事件最多可以有三个索引参数。
- 索引参数的值被存储在日志的主题部分,而不是数据部分,这使得它们更容易被检索。
日志的结构可以概括如下:
+-----------------+
| Address |
+-----------------+
| Topics |
| +-------------+ |
| | Topic 0 | | // 事件签名的哈希
| | Topic 1 | | // 第一个 `indexed` 参数
| | Topic 2 | | // 第二个 `indexed` 参数
| +-------------+ |
+-----------------+
| Data | // 未 `indexed` 的参数
+-----------------+
topics[0]
:始终存储事件签名的 Keccak-256 哈希值,用于唯一标识事件类型。topics[1..3]
:存储被标记为indexed
的参数。每个indexed
参数占用一个topic
索引位置,最多支持三个indexed
参数。data
:存储所有未被标记为indexed
的参数,按照 ABI 编码格式打包存储。
匿名事件
匿名事件通过在事件定义中添加 anonymous
关键字实现。默认情况下,事件是非匿名的,即事件签名的哈希值会自动作为第一个主题(topics[0]
)存储在日志中。而将事件标记为 anonymous
后,事件签名的哈希值将 不 被存储在 topics[0]
中。
以下是如何定义一个匿名事件的示例:
pragma solidity ^0.8.0; contract AnonymousEventExample { // 定义一个匿名事件 event AnonymousDataStored(address indexed sender, uint256 data) anonymous; function storeData(uint256 _data) public { emit AnonymousDataStored(msg.sender, _data); } }
匿名事件的特性
- 不存储事件签名哈希:
- 匿名事件不会将事件签名的 Keccak-256 哈希值存储在
topics[0]
中。这意味着外部应用在监听和过滤事件时,无法通过事件名称直接过滤匿名事件。
- 匿名事件不会将事件签名的 Keccak-256 哈希值存储在
- 主题位置:
- 由于事件签名哈希不作为
topics[0]
存储,匿名事件的topics
数组通常从topics[0]
开始存储被标记为indexed
的参数。
- 由于事件签名哈希不作为
- 用途限制:
- 匿名事件适用于那些不需要通过事件名称过滤的场景,如内部逻辑记录或特定的链上操作记录。
- 由于缺少事件签名哈希,匿名事件在被外部应用强制过滤时不如非匿名事件直观和高效。
匿名事件生成的日志结构示例:
+-----------------+
| Address |
+-----------------+
| Topics |
| +-------------+ |
| | Topic 0 | | // 第一个 `indexed` 参数
| | Topic 1 | | // 第二个 `indexed` 参数(如果有)
| +-------------+ |
+-----------------+
| Data | // 未 `indexed` 的参数
+-----------------+
topics[0]
:不包含事件签名哈希,而是直接存储第一个indexed
参数的值。topics[1..n]
:继续存储其他indexed
参数的值。data
:存储所有未被标记为indexed
的参数,按照 ABI 编码格式打包。
使用注意事项
- 事件过滤困难:
- 由于匿名事件不包含事件签名哈希,无法通过事件名称进行过滤。外部应用需要依赖
indexed
参数或其他条件来筛选感兴趣的事件。
- 由于匿名事件不包含事件签名哈希,无法通过事件名称进行过滤。外部应用需要依赖
- 参数过滤:
- 可以通过监听特定的
indexed
参数值来间接过滤事件。例如,监听特定地址发送的匿名事件。
- 可以通过监听特定的
- 限制
indexed
参数数量:- 匿名事件允许声明四个索引参数,而不是三个。
- 用途场景:
- 匿名事件的优点是,它们的部署和调用都比较便宜。
- 适用于不需要基于事件名称过滤事件的内部记录或特定逻辑操作记录。
- 适用于只有一个事件的合约。监听合约中的所有事件是有意义的,因为只有这一个事件将出现在事件日志中。
匿名事件与非匿名事件的比较
特性 | 匿名事件 (anonymous ) | 非匿名事件 |
---|---|---|
事件签名哈希存储 | 不存储在 topics[0] 中 | 存储在 topics[0] 中 |
事件过滤 | 需依赖 indexed 参数或其他条件进行过滤 | 可通过事件名称直接过滤 |
用途 | 内部记录、特定逻辑操作 | 状态变更通知、广泛的事件监听 |
实现方式 | 在事件定义中添加 anonymous 关键字 | 默认事件,无需额外关键字 |
应用案例
下面是一个简单的 Solidity 合约示例,展示如何定义和触发事件,以及如何在函数中使用它们。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract SimpleStorage { // 定义一个名为 DataStored 的事件 event DataStored(address indexed sender, uint256 data); uint256 private data; // 存储数据并触发事件 function storeData(uint256 _data) public { data = _data; emit DataStored(msg.sender, _data); // 触发事件 } // 读取数据 function getData() public view returns (uint256) { return data; } }
在这个合约中,我们定义了一个 DataStored
事件,它有两个参数:一个索引的 sender
地址和一个非索引的 data
值。每当 storeData
函数被调用时,这个事件就会被触发。
当我们调用 storeData(5)
时,会生成如下的日志:
[ { "logIndex": "0x1", "blockNumber": "0x13", "blockHash": "0xe1d6fc3a880e206bd50af0b3dd60f00b6e99a2a20f436c84f7ca717310d941db", "transactionHash": "0xe772082aa1798e9676858d0925fb1a2b3d4ad3c25013acd795613da8f0e475c2", "transactionIndex": "0x0", "address": "0xaE036c65C649172b43ef7156b009c6221B596B8b", "data": "0x0000000000000000000000000000000000000000000000000000000000000005", "topics": [ "0xe42ab83e51dcfb436887e998d12b1585d6eea49b2900b0b3bcd0591dec7c3d19", "0x0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4" ] } ]
日志具体对应关系如下具体对应关系
原始日志字段 | 解释 | Solidity 对应 |
---|---|---|
logIndex | 日志在区块中的索引位置 | storeData 函数触发的第二个日志(如果有多个事件) |
blockNumber | 日志所在区块编号 | 第19个区块 |
blockHash | 日志所在区块的哈希值 | 区块的唯一标识符 |
transactionHash | 触发日志的交易哈希值 | 唯一标识具体触发 storeData 函数调用的交易 |
transactionIndex | 交易在区块中的索引位置 | 交易是该区块中的第一个交易 |
address | 触发事件的合约地址 | SimpleStorage 合约地址 |
data | 事件的非 indexed 参数 data 的值 | uint256 data = 5 ,以 ABI 编码格式存储 |
topics[N] | 事件签名哈希和 indexed 参数 | topics[0] : 事件签名哈希<br /> topics[1] : sender 地址 |
这里的topic数组可以更详细的解释下:
topics[0]
:"0xe42ab83e51dcfb436887e998d12b1585d6eea49b2900b0b3bcd0591dec7c3d19"
- 解释:这是事件签名的 Keccak-256 哈希值,用于唯一标识
DataStored
事件。 - 计算方式:
- 事件签名字符串为
"DataStored(address,uint256)"
。 - 计算其 Keccak-256 哈希值,得到
0xe42ab83e51dcfb436887e998d12b1585d6eea49b2900b0b3bcd0591dec7c3d19
。
- 事件签名字符串为
- 解释:这是事件签名的 Keccak-256 哈希值,用于唯一标识
topics[1]
:"0x0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4"
- 解释:这是第一个
indexed
参数sender
的值。由于 Solidity 中sender
被标记为indexed
,它被存储在topics
中。
- 解释:这是第一个
前端事件监听
在前端应用中,可以使用 Web3.js 或 Ethers.js 等库来监听合约事件。以下是使用 Ethers.js 的简单示例:
function ContractListener() { const [events, setEvents] = useState([]); useEffect(() => { const provider = new ethers.JsonRpcProvider("https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID"); const contract = new ethers.Contract(contractAddress, contractABI, provider); const filter = contract.filters.DataStored(); const handleEvent = (sender, data, event) => { setEvents(prev => [...prev, { sender, data: data.toString(), txHash: event.transactionHash }]); }; // 实时监听新事件 contract.on(filter, handleEvent); // 查询过去的事件 contract.queryFilter(filter, -1000, "latest").then(pastEvents => { pastEvents.forEach(event => { const { sender, data } = event.args; setEvents(prev => [...prev, { sender, data: data.toString(), txHash: event.transactionHash }]); }); }); // 清理函数 return () => { contract.off(filter, handleEvent); }; }, []); // 渲染事件列表的 JSX ... }
- 连接 Provider:通过 Infura 连接到 Sepolia 测试网。
- 创建合约实例:使用合约地址和 ABI 创建 Ethers.js 合约实例。
- 监听事件:
- 使用
contract.on
监听DataStored
事件。 - 当事件触发时,将事件数据添加到
events
状态中。
- 使用
- 查询历史事件:
- 使用
queryFilter
查询过去 1000 个区块内的DataStored
事件,并添加到events
状态中。
- 使用
Call 与 Delegatecall
在 Solidity 中,合约之间的交互不仅可以通过接口和继承实现,还可以利用低级别的函数调用机制,如 call
和 delegatecall
call
:一种低级别的函数调用方式,允许合约调用另一个合约的函数,同时可以发送以太币。调用目标合约的代码在目标合约的上下文中执行,拥有自己的存储、以太币余额和地址。delegatecall
:类似于call
,但在调用过程中保持调用者(当前合约)的上下文。目标合约的代码在调用者的上下文中执行,因此它们共享相同的存储、以太币余额和地址。
Call
call
是 Solidity 提供的低级别函数调用方法,允许一个合约调用另一个合约的函数。它的基本语法如下:
(bool success, bytes memory data) = address(target).call{value: amount}(abi.encodeWithSignature("functionName(type1,type2)", arg1, arg2));
address(target).call{value: amount}(...)
:目标地址执行call
,并可选择发送一定的以太币。abi.encodeWithSignature
:对函数名和参数进行编码,构建调用数据。- 返回值包括:
success
:调用是否成功的布尔值。data
:返回的数据,以字节数组形式存储。
使用示例
假设有两个合约:Caller
和 Callee
。Caller
通过 call
调用 Callee
的 storeData
函数。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Callee { uint256 public data; event DataStored(address indexed sender, uint256 data); function storeData(uint256 _data) public payable { data = _data; emit DataStored(msg.sender, _data); } } contract Caller { event CallResult(bool success, bytes data); function callStoreData(address _callee, uint256 _data) public payable { (bool success, bytes memory returnData) = _callee.call{value: msg.value}( abi.encodeWithSignature("storeData(uint256)", _data) ); emit CallResult(success, returnData); } }
优点
- 灵活性高:可以动态调用任何合约的任何函数,无需预先知道目标合约的具体接口。
- 支持以太币发送:可以在调用过程中发送以太币给目标合约。
缺点
- 缺乏类型检查:由于是低级调用,编译器无法对函数选择器和参数进行类型检查,容易导致调用失败或意外行为。
- 返回数据处理复杂:需要手动解码返回的数据,增加了代码复杂性。
- 安全风险高:未正确处理
call
的返回值和重入攻击等安全问题,可能导致合约被攻击。
Delegatecall
delegatecall
是 Solidity 提供的一个低级别函数,用于在当前合约的上下文中调用另一个合约的代码。与普通的 call
不同,delegatecall
会保持调用者(当前合约)的存储、地址和余额,而目标合约的代码将在调用者的上下文中执行。这使得 delegatecall
成为实现代理合约和可升级合约的重要工具。
当执行 delegatecall
时,以下几点尤为重要:
- 上下文保持:
delegatecall
保持调用者的上下文,包括msg.sender
和msg.value
,而目标合约在调用者的存储中操作。 - 存储访问:目标合约的代码会读取和修改调用者合约的存储,而不是目标合约自己的存储。
- 函数执行:目标合约的函数通过调用者的地址和存储进行执行。
就比如:
(bool success, bytes memory returnData) = implementation.delegatecall(msg.data); require(success, "Delegatecall failed");
implementation
:目标合约的地址。msg.data
:调用数据,包含以下部分:- 函数选择器(Function Selector):前4个字节,用于识别要调用的函数。
- 参数编码(Encoded Parameters):函数参数按 ABI 编码规则打包的字节数据。
使用示例
假设有两个合约:Proxy
和 Implementation
。Proxy
通过 delegatecall
调用 Implementation
的 storeData
函数。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Implementation { uint256 public data; event DataStored(address indexed sender, uint256 data); function storeData(uint256 _data) public { data = _data; emit DataStored(msg.sender, _data); } } contract Proxy { uint256 public data; address public implementation; constructor(address _implementation) { implementation = _implementation; } fallback() external payable { (bool success, ) = implementation.delegatecall(msg.data); require(success, "Delegatecall failed"); } }
调用流程示例
- 部署合约:
- 部署
Implementation
合约,获得其地址,例如0xImplAddress
. - 部署
Proxy
合约,初始化_implementation
为0xImplAddress
, 获得代理合约地址0xProxyAddress
.
- 部署
- 调用代理合约的
storeData
函数:- 前端或用户通过
Proxy
地址调用storeData(uint256)
,例如传入42
。 - 调用数据
msg.data
包含函数选择器0x6d4ce63c
(假设是storeData(uint256)
的选择器)和参数42
的 ABI 编码。
- 前端或用户通过
- 代理合约执行
delegatecall
:Proxy
合约的fallback
函数被触发,执行implementation.delegatecall(msg.data)
.delegatecall
解析msg.data
,找到函数选择器对应的storeData
函数,并在Proxy
的上下文中执行。
- 状态更新:
storeData
函数在Proxy
合约的存储中更新data
变量,而不是Implementation
合约。- 事件
DataStored
也是在Proxy
触发,因为msg.sender
是原始调用者。
- 事件日志:
- 事件日志记录在
Proxy
合约的地址下,data
值为42
。
- 事件日志记录在
优点
- 代码复用:允许多个合约共享相同的实现代码,减少部署成本和代码冗余。
- 升级性:通过更改
implementation
地址,可以轻松升级合约逻辑,而无需迁移存储数据。 - 保持状态一致:相同的存储布局保证了调用者和被调用者共享相同的状态变量。
缺点
- 复杂性高:需要仔细设计存储布局,确保调用者和被调用者的状态变量一致,避免存储冲突。
- 安全风险高:错误的
delegatecall
实现可能导致存储被篡改或合约被攻击,需要做好权限控制。 - 调试困难:由于代码在调用者上下文中执行,追踪和调试问题更加复杂。
存储冲突
存储冲突发生在代理合约和实现合约使用相同的存储槽存储不同的变量或数据时,导致数据被覆盖或混淆。
原因:
- 存储槽重叠:
- 如果 Proxy 合约和 Implementation 合约在相同的存储槽中定义了不同的变量,执行
delegatecall
时会导致变量值被错误地覆盖。
- 如果 Proxy 合约和 Implementation 合约在相同的存储槽中定义了不同的变量,执行
- 不一致的存储布局:
- 代理合约和实现合约的状态变量声明顺序和类型不一致,导致变量在同一存储槽中的映射错误。
具体示例解析
错误示例:存储槽冲突导致的实现地址被覆盖
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Implementation { string public female; string public male; function setFemaleName(string memory _name) public { female = _name; } function setMaleName(string memory _name) public { male = _name; } } contract Proxy { string public male; string public female; address public implementation; constructor(address _implementation) { implementation = _implementation; } fallback() external payable { (bool success, ) = implementation.delegatecall(msg.data); require(success, "Delegatecall failed"); } receive() external payable { } }
问题:
- Proxy 合约:
- 槽0:
string public male;
- 槽1:
string public female;
- 槽2:
address public implementation;
- 槽0:
- Implementation 合约:
- 槽0:
string public female;
- 槽1:
string public male;
- 槽0:
- 执行过程:
- 用户通过 Proxy 调用
setFemaleName("Alice")
。 delegatecall
将调用转发到 Implementation 合约。- Implementation 的
setFemaleName
函数在 Proxy 合约的上下文中执行:female = "Alice"
被写入 Proxy 合约的槽0,而不是槽1。
- 用户通过 Proxy 调用
- 后果:
- Proxy 合约的
male
变量(槽0)被错误地设置为 "Alice"。 - Proxy 合约的
female
变量(槽1)保持不变。 - 状态变量
female
被错误地存储在 Proxy 合约的male
位置,导致数据不一致。
- Proxy 合约的
- 潜在问题:
- 存储布局不匹配导致数据存储位置错误。
- 后续对
male
和female
变量的读取将返回不正确的值。 - 可能引发意外的行为和数据混淆。
比较
特性 | call | delegatecall |
---|---|---|
执行上下文 | 目标合约的上下文(存储、地址、余额) | 调用者的上下文(存储、地址、余额) |
存储访问 | 访问目标合约的存储 | 访问调用者的存储 |
使用场景 | 动态函数调用、发送以太币、跨合约交互 | 代理合约模式、合约升级、代码复用 |
调用结果返回 | 与目标合约相关的返回值和状态 | 通过调用者的存储共享返回值和状态标志 |
安全性 | 较高风险,需谨慎处理返回值和防重入攻击 | 高风险,存储布局一致性和权限控制尤为重要 |
函数签名和参数 | 需要手动编码函数签名和参数,缺乏编译器支持 | 同 call ,但由于共享调用者的存储,参数使用需谨慎 |
Gas 消耗 | 通常稍高,因为涉及函数签名和参数编码 | 类似于 call ,但因共享存储可能增加复杂性 |
代理合约
代理合约 是一种智能合约,其主要职责是将调用转发到另一个逻辑合约(通常称为实现合约)。通过这种方式,代理合约本身保持不变,而实现合约可以根据需要进行升级或修改,实现合约逻辑的可升级性。
为什么需要代理合约?
智能合约一旦部署到区块链上,就无法修改。随着时间的推移,可能会发现合约存在漏洞、需要添加新功能或优化性能。在这种情况下,代理合约提供了一种解决方案,使得开发者可以升级合约逻辑而无需更改合约地址,从而保持与用户和其他合约的兼容性。
代理合约的工作原理
代理合约通过**委托调用(delegatecall)**机制,将函数调用和数据转发到实现合约。delegatecall
允许代理合约在自己的存储空间中执行实现合约的代码,这意味着实现合约的代码可以操作代理合约的状态变量。
透明代理 是最常见的代理模式,由 OpenZeppelin 实现。其主要特点是:
- 权限控制:代理合约有一个管理员账户,仅管理员可以升级实现合约。
- 透明性:对普通用户来说,代理合约看起来与实现合约相同,但管理员与普通用户的接口不同,避免调用冲突。
实现特点:
- 存储实现合约地址的槽(通常使用 EIP-1967 标准)。
fallback
函数中使用delegatecall
转发调用。
以下是透明代理模式的简单实现示例:
实现合约(Logic Contract)
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract LogicV1 { uint256 public data; event DataStored(address indexed sender, uint256 data); function storeData(uint256 _data) public { data = _data; emit DataStored(msg.sender, _data); } }
代理合约(Proxy Contract)
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Proxy { // EIP-1967 Slots bytes32 private constant IMPLEMENTATION_SLOT = keccak256("eip1967.proxy.implementation") - 1; bytes32 private constant ADMIN_SLOT = keccak256("eip1967.proxy.admin") - 1; constructor(address _implementation) { _setAdmin(msg.sender); _setImplementation(_implementation); } modifier onlyAdmin() { require(msg.sender == _getAdmin(), "Proxy: Not admin"); _; } function _getAdmin() internal view returns (address admin) { bytes32 slot = ADMIN_SLOT; assembly { admin := sload(slot) } } function _setAdmin(address _admin) internal { bytes32 slot = ADMIN_SLOT; assembly { sstore(slot, _admin) } } function _getImplementation() internal view returns (address impl) { bytes32 slot = IMPLEMENTATION_SLOT; assembly { impl := sload(slot) } } function _setImplementation(address _impl) internal { bytes32 slot = IMPLEMENTATION_SLOT; assembly { sstore(slot, _impl) } } function upgradeTo(address _newImplementation) external onlyAdmin { _setImplementation(_newImplementation); } fallback() external payable { _delegate(_getImplementation()); } receive() external payable { _delegate(_getImplementation()); } function _delegate(address _impl) internal virtual { assembly { // 将输入复制到内存 calldatacopy(0, 0, calldatasize()) // 执行 delegatecall let result := delegatecall(gas(), _impl, 0, calldatasize(), 0, 0) // 获取返回数据 returndatacopy(0, 0, returndatasize()) // 根据结果决定是否回退 switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } }
部署与升级流程
- 部署 Logic 合约(LogicV1)。
- 部署 Proxy 合约,传入 LogicV1 的地址作为实现合约。
- 通过 Proxy 合约调用
storeData
函数:代理合约转发调用到 LogicV1,存储数据在 Proxy 的存储中。 - 升级逻辑:
- 部署 Logic 合约的新版本(LogicV2)。
- 以管理员身份调用 Proxy 合约的
upgradeTo
函数,更新实现合约地址为 LogicV2。
- 通过 Proxy 合约调用新逻辑:Proxy 现在转发调用到 LogicV2,实现合约逻辑的升级。
可升级合约
**UUPS Proxy(Universal Upgradeable Proxy Standard)**是一种轻量级且高效的代理模式,允许智能合约在部署后进行逻辑升级。与传统的透明代理(Transparent Proxy)不同,UUPS Proxy的升级逻辑由 实现合约 自身管理,而不是由代理合约处理。这种设计减少了代理合约的复杂性和存储需求,提高了灵活性和效率。
为什么选择 UUPS Proxy?
- 节省存储空间:由于升级逻辑在实现合约中,代理合约本身更为简洁,节省了存储空间。
- 降低 Gas 成本:更简洁的代理合约意味着部署和交互时的 Gas 成本更低。
- 灵活性高:升级逻辑集中在实现合约,便于管理和扩展。
UUPS Proxy 的工作流程可以分为三个主要阶段:部署、调用和升级。以下将详细解释每个阶段。
部署阶段
-
部署实现合约(Implementation Contract):
- 实现合约包含实际的业务逻辑。
- 使用 OpenZeppelin 的
UUPSUpgradeable
和OwnableUpgradeable
基类,确保安全的升级机制。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; contract ImplementationV1 is UUPSUpgradeable, OwnableUpgradeable { uint256 public data; event DataStored(address indexed sender, uint256 data); function initialize() initializer public { __Ownable_init(msg.sender); __UUPSUpgradeable_init(); } function storeData(uint256 _data) public { data = _data; emit DataStored(msg.sender, _data); } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} }
-
部署代理合约(Proxy Contract):
- 使用 OpenZeppelin 的
ERC1967Proxy
实现,指向初始实现合约并进行初始化。 - 代理合约负责接收用户调用,并通过
delegatecall
将调用转发给实现合约。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract ProxyUUPS is ERC1967Proxy { uint256 public data; constructor(address _logic, bytes memory _data) ERC1967Proxy(_logic, _data) {} receive() external payable { } }
部署步骤:
- 部署
ImplementationV1
合约。 - 准备初始化数据,调用
initialize
函数(如果需要)。 - 部署
ProxyUUPS
合约,传入ImplementationV1
的地址和初始化数据。
使用 Hardhat 部署示例:
const { ethers, upgrades } = require("hardhat"); async function main() { // 部署 ImplementationV1 const ImplementationV1 = await ethers.getContractFactory("ImplementationV1"); const implementationV1 = await ImplementationV1.deploy(); await implementationV1.deployed(); console.log("ImplementationV1 deployed to:", implementationV1.address); // 部署 ProxyUUPS,并初始化 ImplementationV1 const ProxyUUPS = await ethers.getContractFactory("ProxyUUPS"); const proxy = await ProxyUUPS.deploy(implementationV1.address, "0x"); await proxy.deployed(); console.log("ProxyUUPS deployed to:", proxy.address); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); });
- 使用 OpenZeppelin 的
调用阶段
-
用户与代理合约交互:
- 用户通过代理合约地址与合约交互,调用如
storeData
和getData
等函数。
- 用户通过代理合约地址与合约交互,调用如
-
代理合约转发调用:
- 代理合约的
fallback
函数捕获所有调用,并通过delegatecall
转发给当前实现合约(ImplementationV1
)。
- 代理合约的
-
实现合约执行逻辑:
- 实现合约逻辑在代理合约的存储和上下文中执行,修改代理合约的状态变量。
-
事件日志记录:
- 事件由代理合约触发,记录在代理合约的地址下,
msg.sender
为原始调用者。
调用示例:
使用 Ethers.js 与代理合约交互,调用
storeData
函数:const { ethers } = require("ethers"); // 连接到以太坊节点 const provider = new ethers.providers.JsonRpcProvider("https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID"); const signer = provider.getSigner(); // 代理合约地址和 ABI const proxyAddress = "0xProxyAddress"; const proxyABI = [ "function storeData(uint256 _data)", "function getData() view returns (uint256)", "event DataStored(address indexed sender, uint256 data)" ]; // 创建合约实例 const proxyContract = new ethers.Contract(proxyAddress, proxyABI, signer); // 调用 storeData async function storeDataExample() { const tx = await proxyContract.storeData(42); await tx.wait(); console.log("Data stored successfully"); } storeDataExample();
- 事件由代理合约触发,记录在代理合约的地址下,
升级阶段
-
部署新版本实现合约(Implementation Contract):
- 部署包含新逻辑的实现合约(
ImplementationV2
)。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; contract ImplementationV2 is UUPSUpgradeable, OwnableUpgradeable { uint256 public data; string public message; event DataAndMessageStored(address indexed sender, uint256 data, string message); function initializeV2() reinitializer(2) public { __Ownable_init(msg.sender); __UUPSUpgradeable_init(); } function storeData(uint256 _data, string memory _message) public { data = _data; message = _message; emit DataAndMessageStored(msg.sender, _data, _message); } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} }
- 部署包含新逻辑的实现合约(
-
执行代理合约的升级:
- 作为拥有者,通过调用实现合约中的升级函数,将代理指向新的实现合约。
const { ethers, upgrades } = require("hardhat"); async function upgrade() { const proxyAddress = "0xProxyAddress"; const ImplementationV2 = await ethers.getContractFactory("ImplementationV2"); await upgrades.upgradeProxy(proxyAddress, ImplementationV2); console.log("Proxy upgraded to ImplementationV2"); } upgrade() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); });
-
使用新版本实现合约:
- 用户通过代理合约地址调用新功能,调用将通过
delegatecall
转发到ImplementationV2
。 - 新的函数如
storeData(uint256, string)
被执行,状态变量和事件更新。
调用新功能:
async function storeDataV2Example() { const tx = await proxyContract.storeData(100, "Hello, UUPS!"); await tx.wait(); console.log("Data and message stored successfully"); } storeDataV2Example();
- 用户通过代理合约地址调用新功能,调用将通过
作者:加密鲸拓
版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!