对于 Solidity 开发者来说,ABI 编码 / 解码是必须掌握的主题。
这是将你与其他人区分开来的主题之一 —— 让你成为一个 10 倍的 solidity 开发者。
另一方面,这是一个可能很难掌握的主题。它是 Solidity 官方文档中最复杂的部分之一。 (相信我,我尝试过“解读”它。)本系列文章的目标是简化每个 Solidity 开发者的 ABI 编码。
现在,让我们开始吧。
EVM 中的 应用程序二进制接口 (ABI) 定义了如何编码和解码数据结构和函数调用,以便在合约和外部参与者(如 web3 客户端或链下代码)之间进行通信。
它充当高级 Solidity 代码和低级 EVM 操作之间的翻译层。
ABI 编码在所有 EVM 兼容链上都是严格定义、确定性和一致的,这使得以太坊工具(如 ethers.js、web3.js、Foundry 和 Remix)能够以可预测的方式与智能合约交互,而根本无需理解你的 Solidity 源代码。
如果你曾经调用过这样的函数:
myContract.call(abi.encodeWithSignature("transfer(address,uint256)", recipient, amount));
或者像这样解码事件数据:
abi.decode(data, (uint256, address));
你已经接触过 ABI 编码了。
你已经看到了对函数调用进行编码后产生的十六进制 blob,如下所示:
0xa9059cbb0000000000000000000000005B38Da6a701c568545dCfcB03FcB875f56beddC40000000000000000000000000000000000000000000000000000000000000064
这是一个 ABI 编码的函数调用,也是 EVM 在执行你的代码时所理解的内容。本文正是关于我们如何获得这个编码的十六进制值的。
我编写此 ABI 编码系列文章的目标是:
我将带你了解 ABI 编码数据的精确结构,并帮助你掌握 编码的基本定律,这些定律控制着任何输入(无论其复杂性如何)如何转换为 EVM 可以执行的原始字节。
在本指南结束时,你将能够查看任何函数签名和参数,并逐字节手动重建精确的 ABI 编码的调用数据 — 并且可以通过解码来做到相反。
但首先,你必须理解一些先决条件。
例如,考虑十进制数 2,984
:
这个概念也适用于字节和位。
让我们以整数 0x12345678
(十进制为 305419896)为例,它占用 4 个字节(32 位):
0x12 | 0x78 | |
0x34 | 0x56 | |
0x56 | 0x34 | |
0x78 | 0x12 |
端序 是指多字节值在内存中的布局方式:
例如,假设一个 uint32
的十进制值为 305419896
。在十六进制中,它是 0x12345678
现在,大端序编码 将像这样存储它:
内存布局(大端序):[12][34][56][78]
因此,MSB 0x12
在最前面。
另一方面,小端序编码 将像这样存储它:
内存布局(小端序):[78][56][34][12]
这里,LSB 0x78
在最前面 — 一切都被颠倒了。
Solidity ABI 编码 始终使用大端序,因为它已针对以太坊虚拟机 (EVM) 通信进行了标准化。
在 ABI 编码中,填充是指添加额外的零字节,以使值与 32 字节(256 位)边界对齐。
在 Solidity ABI 中,大多数值都编码为 32 字节(256 位)。
如果你的数据较小(如 1 字节的布尔值或 20 字节的地址),则必须对其进行填充(通常用零)才能变为 32 字节。
你将会注意到 2 种类型的填充:
0x00
填充0x0000000000000000000000000000000000000000000000000000000000000001
用于 bytes<M>(例如 bytes3)和字符串 / 字节
0x00
填充// "abc" in hex - 616263
0x6162630000000000000000000000000000000000000000000000000000000000
这是 右填充的,并且 数据在 32 字节块中左对齐。
这使我们能够区分 Solidity 中的类型 ( 这比你想象的更有用 )
Solidity 中有两种主要类型:
以下是一个快速表格,其中列出了属于每个类别的不同类型:
静态 | uint256 bool 、address 、bytes32 、uint[2] 、固定大小的元组 |
动态 | string bytes 、uint[] 、address[] 、动态元组 |
确定类型非常重要,因为 ABI 编码机制以及填充会根据你要编码的类型而有所不同。
在 Solidity 中,calldata 是传递给函数调用的只读、不可修改的输入数据。它包括从外部源(钱包、脚本或合约)发送到函数以调用它的所有内容。
从 ABI 编码的角度来看,calldata 是存储完整 ABI 编码的有效负载 的位置。
此有效负载包括:
每次从外部调用合约函数时(无论是从 Remix、Ethers.js 还是 Foundry),都会使用 ABI 编码规则构造 calldata,并将其发送到 EVM 以执行。
这是一个简化的结构:
calldata = [函数选择器][参数编码]
了解如何在 calldata 中对参数进行编码是 ABI 编码的全部目的。解码 calldata 后,你可以重建调用了哪个函数以及传递了哪些值。
函数选择器 是 calldata 的前 4 个字节。它唯一地标识应在合约上调用的函数。
快速浏览一下它的工作原理:
例如:
function transfer(address to, uint256 amount)
它的签名字符串是:
"transfer(address,uint256)"
// 它的 keccak256() 哈希值为:0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b
前 4 个字节 (a9059cbb
) 是 函数选择器,它成为 calldata 的前缀。
每个 ABI 编码的 calldata 都必须以正确的函数选择器开头,以便 EVM 知道要将哪个函数分派到合约中。
现在你已经具备了理解 ABI 编码内部工作原理所需的基础知识和先决条件。
让我们从编码定律开始。
对于任何给定的函数和参数:
这些是基本定律,并且永远不会改变。
如果你不知道这一切意味着什么,请不要担心。允许我定义它们,然后用示例解释。请耐心等待。
现在,让我们首先定义这些术语:
编码:
你可以将编码视为从人类可读的值转换为 EVM 可读的二进制文件。
HEAD:
TAIL:
这些是在 EVM 中编码任何给定值集的最基本规则集。它们将始终为真。
注意: 在一些复杂的例子中,我们可能会看到一些额外的注意事项。
例如:
a. 动态类型的编码需要理解偏移量。(我们稍后会介绍)
b. 动态嵌套数组或结构体内部数组的编码等将具有新的相对偏移量 术语。
我们将在后续部分中介绍此类示例。
但同样,基本原理将始终成立。
为了直观地理解 HEAD-TAIL 的概念,让我们举两个非常简单的例子:
带有 2 个静态类型参数的函数 staticType
function staticType(uint256 a, uint256 b) public pure returns (bytes memory){
return msg.data;
}
带有 1 个动态类型参数的函数 dynamicType
function dynamicType(string memory s) public pure returns(bytes memory ){
return msg.data;
}
当你执行这些函数时,它们将返回 calldata,calldata 是函数选择器以及传递的参数 的 ABI 编码值。
让我们快速看一下它们的每个编码的 calldata:
staticType 函数的 Calldata:
函数选择器:74b4a150
------------
[000]: 0000000000000000000000000000000000000000000000000000000000000001
[020]: 0000000000000000000000000000000000000000000000000000000000000002
其可视化表示形式可以看作是:
dynamicType 函数的 Calldata:
函数选择器:46641647
------------
[000]: 0000000000000000000000000000000000000000000000000000000000000020
[020]: 000000000000000000000000000000000000000000000000000000000000000c
[040]: 4465636970686572436c75620000000000000000000000000000000000000000
其可视化表示形式可以看作是:
注意: 这仅是为了显示 head-tail 的排列方式。我们将在下一个详细示例中探讨编码机制
现在,我们的下一个任务是应用这些规则并查看它们是否有效。
专家提示:你用下面解释的例子迭代得越多,你就越能理解 ABI 编码的工作原理。
你现在已经对理解 ABI 编码的工作原理所需的所有概念有了理论上的理解。
你还拥有一套永远不会改变的规则。
我们现在需要做的就是应用这些规则并验证它们是否按预期工作。
我们将把编码规则应用于 3 种不同类型的函数:
首先,我们将完全专注于静态类型,主要理解以下内容:
现在,甚至不用担心动态类型。
这不仅仅是为了获得正确的答案。
而是为了建立肌肉记忆,了解如何分析任何函数并每次都使用相同的心智模型来获得正确的 calldata。
让我们从一个只有静态类型参数的简单例子开始:
// 参数 ( a=1, b=2, c=3 )
function encodeFirst(uint256 a, uint256 b, uint256 c) public pure returns (bytes memory) {
return msg.data;
}
第 1 步:以元组思考
回顾第一条规则,将所有函数参数分组到一个具有头尾形式的元组中。
请记住,solidity 始终将所有参数视为它们是一个组合的元组。
因此我们像这样思考输入:
这个元组有 3 个元素:a
、b
和 c
。
tuple(a, b, c)
// 以头尾形式形成它
encoded = [head(a)] [head(b)] [head(c)] [tail(a)] [tail(b)] [tail(c)]
第 2 步:可视化头尾布局
现在,回顾一下 静态类型 值的头和尾的通用规则:
由于所有类型都是静态的:
head(x)
= x
的编码值(填充为 32 字节)tail(x)
= 空(因为静态类型没有尾)encoded = [head(a)] [head(b)] [head(c)] [tail(a)] [tail(b)] [tail(c)]
所以它看起来像这样:
[ head(a) ] → a 的 32 字节
[ head(b) ] → b 的 32 字节
[ head(c) ] → c 的 32 字节
[ tail(a) ] →(空)
[ tail(b) ] →(空)
[ tail(c) ] →(空)
即使尾部为空,我们仍然在精神上放置它们以加强结构 — 这将使我们以后能够无缝地处理动态情况。
第 4 步:确定参数类型并编码每个值
我们有 3 个参数,并且都是静态类型 (uint256
)。
现在让我们编码参数。在我们的例子中,参数是:(a=1, b=2, c=3)
为了编码,回顾一下静态类型的编码规则:
head(a): 0000000000000000000000000000000000000000000000000000000000000001
head(b): 0000000000000000000000000000000000000000000000000000000000000002
head(c): 0000000000000000000000000000000000000000000000000000000000000003
tail(a): "" // 空
tail(b): "" // 空
tail(c): "" // 空
第 5 步:组合 HEAD 和 TAIL
我们现在已经编码了此函数中所有参数的头和尾。
如果我们将它们全部组合在一起,我们应该得到发送到合约的所有参数的编码值。
应该预先考虑函数选择器:
我们不能忘记函数选择器。
对 Solidity 中函数的每次调用都以一个 4 字节的函数选择器 开始。
要计算函数选择器,你可以使用来自 foundry 的 cast CLI 工具并运行此命令:
cast sig "encodeFirst(uint256,uint256,uint256)"
// 这基本上是 keccak256("encodeFirst(uint256,uint256,uint256)")[:4]
// 即函数签名的 keccak256 哈希的前 4 个字节
这将返回 0xb2bc9513
作为 encodeFirst()
函数的函数选择器。
现在将选择器添加到编码参数:
final calldata =
0xb2bc9513 // 函数选择器
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
编码 仅静态类型 的值非常容易。
真正的挑战始于动态类型。让我们采用一个简单的 字符串 ( 一个动态类型 )** 并应用我们的规则,看看我们是否可以正确编码。
另一个有用的 cast CLI 命令是:cast pretty-calldata <calldata>
这提供了 calldata 的可读的漂亮版本。
再次回顾这些规则。
对于动态类型:
对于静态值,我们只是简单地编码了值本身并将其放在 head 部分中。(而 tail 为空。)
动态值有所不同。head 必须包含指向 tail 所在位置的偏移量 / 位置。
但是,我们如何计算给定动态值或一组动态值作为函数参数的偏移量?
动态类型值的偏移量计算规则
当参数是动态的时,其 head 必须 指向其尾开始的位置,并且此值必须 编码为 uint256
。
该偏移量是从整个参数编码块的开始处开始测量的(即,紧接在 4 字节函数选择器之后),而不是从 calldata 的开始处开始测量的。
对于位置 i
处的动态参数:
32 * 参数数量
+位置 i 处参数之前 tail 的总大小
换句话说:
32 × 参数数量
)示例分解
对于这样的函数:
function f(string a, string b)
or
encoded = [head(a)][head(b)][tail(a)][tail(b)]
head(a)
= 0x40:
head(a) 表示字符串 a 的 head,它是第一个参数。(此参数之前没有 tail。所以)
所以,我们只关心参数的总值,即 2。(参数 a 和 b)
按照规则:32 * 2 得到 64,也就是 0x40(因为第一个 tail 在两个 32 字节的 head 之后开始,它们一起占据 64 字节。)
因此,head(a) 为 0x40,表示可以从位置 0x40 找到 tail(字符串 a 的实际值)。head(b)
= 0x80
head(b) 表示字符串 b 的 head,它是第二个参数。前 2 个参数(a 和 b)都是动态类型,因此它们的 head 占据了前 32 * 2 = 64 字节
→ 这是十六进制的 0x40。(所以 tail(a)
从偏移量 0x40 开始,我们已经知道了)
现在,对于参数 b,我们必须首先考虑以下事项:两个参数 a 和 b 的 head 占据了 64 个字节。需要以下空间:tail(a)
即:a 是一种字符串类型,这意味着它需要两个 32 字节的插槽来存储其长度,然后是其值。总空间: 64 字节 + 32 字节(用于 tail(a)
)= 128 或 十六进制的 0x80 因此,head(b)
= 0x80
—— 表示实际值 字符串 b
从参数块开始处偏移 128 字节处开始。
我们来看一下编码后的值,看看规则是否准确。
Method: 0189679b ← 函数选择器 function selector for f(string,string)
------------
[000]: 0000000000000000000000000000000000000000000000000000000000000040 ← head(a): 指向 tail(a) 的偏移量,tail(a) 从字节 0x40 开始
[020]: 0000000000000000000000000000000000000000000000000000000000000080 ← head(b): 指向 tail(b) 的偏移量,tail(b) 从字节 0x80 开始
[040]: 000000000000000000000000000000000000000000000000000000000000000c ← tail(a) 长度:12 字节
[060]: 4465636970686572436c75620000000000000000000000000000000000000000 ← tail(a) 数据:UTF-8 编码的“DecipherClub”,右填充为 32 字节
[080]: 000000000000000000000000000000000000000000000000000000000000000c ← tail(b) 长度:12 字节
[0a0]: 4465636970686572436c75620000000000000000000000000000000000000000 ← tail(b) 数据:UTF-8 编码的“DecipherClub”,右填充为 32 字节
现在你已经理解了这一点,现在是时候玩一玩动态类型参数的编码了。
我们现在转向具有动态参数的函数,这就是头尾模型发挥作用的地方。
让我们来看一下这个函数:
// argument - s = "DecipherClub"
function encodeSecond(string memory s) public pure returns(bytes memory ){
return msg.data;
}
第 1 步:可视化头尾布局
让我们使用我们的标准模型写出逻辑结构:
encoded = [head(s)] [tail(s)]
因为只有一个参数,所以首先是它的 head,然后是它的 tail。
第 3 步:确定参数类型并编码值
参数 s
的类型为 string
,它是 ABI 规范中的 动态类型。
动态类型是指大小 在编译时未知 的任何类型。在这种情况下,字符串
"DecipherClub"
的长度可能会有所不同,因此编译器无法提前确定其大小。
因为它是动态的,所以它的编码将遵循 头尾结构:
现在我们将它分成两个部分:编码 head 和编码 tail。
第 3.a 步:编码 HEAD
按照 ABI 编码的基本规则,我们知道以下内容:
因此,你现在的任务是创建该偏移量:
回顾规则:
偏移量是从参数编码块的开始处开始测量的,而不是从完整 calldata 的开始处开始测量的。
在这种情况下:
32 字节 = 0x20
(十六进制)该值本身必须编码为 uint256
,这意味着我们将 0x20
表示为一个左填充的 32 字节字:
head(s) = 0x0000000000000000000000000000000000000000000000000000000000000020
到目前为止,ABI 编码的值如下所示:
[head(s)] → 0x0000000000000000000000000000000000000000000000000000000000000020
这告诉 EVM:
“嘿,s 的实际数据从参数块开始后的 32 字节处开始。”
第 3.b 步:编码 TAIL
动态类型的 tail 包括两个组件:
tail(s) = [长度][值][填充]
我们现在的任务是为我们给定的字符串值创建 tail,即 “DecipherClub”
首先,让我们获取长度和编码
// 786 的编码值(第一个参数)
[000]: 0000000000000000000000000000000000000000000000000000000000000312
// 地址的编码值(第二个参数)
[020]: 0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4
// 字符串 "DecipherClub" 的偏移量编码值(偏移量第三个参数)
[040]: 0000000000000000000000000000000000000000000000000000000000000060
步骤 3.c: 编码 TAIL
现在我们编码字符串 "DecipherClub"
的实际值。这与我们之前所做的完全相似。
0x4465636970686572436c7562
12
,或十六进制的 0xc
。tail(c) = [length][value][padding]
// 长度为 0xc
000000000000000000000000000000000000000000000000000000000000000c
// 编码值为
4465636970686572436c75620000000000000000000000000000000000000000
最终的 Tail 部分变为
[060]: 000000000000000000000000000000000000000000000000000000000000000c
[080]: 4465636970686572436c75620000000000000000000000000000000000000000
步骤 4:组合所有内容以形成 ABI 编码的 Payload
现在我们组合:
[head(a)]
[head(b)]
[head(c)]
[tail(c)]
// 最终结果
函数签名:0a4bef11
------------
[000]: 0000000000000000000000000000000000000000000000000000000000000312
[020]: 0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4
[040]: 0000000000000000000000000000000000000000000000000000000000000060
[060]: 000000000000000000000000000000000000000000000000000000000000000c
[080]: 4465636970686572436c75620000000000000000000000000000000000000000
这是一个可视化心智模型,用于使用上述规则对函数中的任何给定参数集进行编码。
考虑一个具有混合类型参数的简单函数:
function test( uint256 a, string b ) public {}
// arguments: a=2, b="DecipherClub"
现在,要按照规则编码这个函数,我们应该遵循这个流程:
在本系列的第一部分中,我们将 ABI 编码过程分解为其最精细的位和字节。我们还尝试了不同类型的编码的混合,包括两种类型。
这应该给你一个强大的心智模型来理解 ABI 编码的完整机制。阅读两次以更好地理解。
到目前为止,你应该能够自如地逐字节读取 ABI 编码的 payload,并理解编码后的数据如何映射回原始函数及其参数。更好的是,你应该能够在调用函数之前预测编码输出应该是什么样子。
在本系列的下一部分中,我们将更深入地研究,探索高级模式,例如结构体、数组、嵌套和复杂动态类型的编码。
原文链接:https://www.decipherclub.com/solidity-abi-encoding-part-1 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
登链社区是一个 Web3 开发者社区,通过构建高质量技术内容平台和线下空间,助力开发者成为更好的 Web3 Builder。
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。