ABI 编码深入解析

  • ljmanini
  • 发布于 2023-03-18 16:11
  • 阅读 28

本文深入探讨了Solidity中的ABI编码机制,详细解释了函数选择器和参数编码的原理,特别是静态类型和动态类型的编码方式,并通过一个实际的调用数据解析示例展示了如何手动解码ABI编码的数据。

GM fam,欢迎来到我的第一篇 Medium 博客文章。

昨晚我看到 chad z0age 的一条推文,意识到自己对 ABI 编码的工作原理了解不够,因此在阅读了 solidity docs 后,这是我个人的消化总结。

z0age 的 nerd snipe

正如你可能已经知道的,在 Solidity 中,ABI 用于编码函数调用和数据结构,以便在与智能合约交互时进行通信。ABI 指定了函数、错误和事件参数的编码和解码方式。

调用智能合约函数时,我们必须指定两个重要的事项:

  1. 我们要调用的函数,由 function selector 指定
  2. 我们选择传递的 arguments

在本文中,我们将快速浏览前者,重点关注后者。

Function Selector

函数调用的前四个字节的 calldata 指定要调用的函数:这些是该函数签名的 keccak256 的前 4 个字节。

签名定义为包含函数名称的字符串,后面是按逗号分隔的参数类型列表,用括号括起来。

例如:

1. "transferFrom(address,address,uint256)" 是臭名昭著的 ERC20 方法的签名。

2. "addressProcessBundle((uint256[2],address[],(uint256,(uint256,address,bytes)[])[]))" 是使用由固定大小数组、动态大小数组和结构体组成的动态大小数组的签名的一个例子,该结构体包含一个数字和一个包含数字、地址及动态大小数组的另一个结构体。

对上述字符串进行哈希并提取左侧的前 4 个字节将得到它们的选择器:

cast sig == gud tool

Argument Encoding

从 calldata 的第五个字节开始,编码的参数紧随其后。

staticdynamic 类型之间有一个重要的区别:static 类型是就地编码的,而 dynamic 类型是编码在当前“块”的参数之后的进一步位置。

动态类型包括:bytesstringT[]T[k](对于任何动态的 Tk >= 0)以及 (T1, .., Tk),如果对于某些 1 <= i <= kTi 是动态的。

所有其他类型都是静态的!

在深入编码的正式规范之前,我们定义:

  • len(a) 表示二进制字符串 a 的字节数
  • enc,实际编码,作为 ABI 值与二进制字符串的映射,使得 len(enc(a)) 仅在 a 是动态类型时依赖于 a 的值

注意,根据 enc 的定义,如果 a 是静态类型,则其编码不依赖于其值(反之亦然)!

Formal Specification of the Encoding

深呼吸,我们将深入探讨。

对于任何 ABI 值 X,定义递归地 enc(X),基于其类型。

  • 对于结构体 (T1, .., Tk),其中 k >= 0 和任何类型 T1, .., Tk

编码由 k 个“头”元素和 k 个“尾”元素组成,定义为 enc(X) = head(X(1)) .. head(X(k)) tail(X(1)) .. tail(X(k)),其中 X = (X(1), .. , X(k))headtailTi 定义为:

- 对于 Ti 静态:head(X(i)) = enc(X(i))tail(X(i)) = "" (静态类型就地编码,请记住?)

- 对于 Ti 动态:head(X(i)) = enc(len(head(X(1)) .. head(X(k)) tail(X(1)) .. tail(X(i-1))))tail(X(i)) = enc(X(i)) (这复杂的表达方式意味着,在查找 X 的编码时,如果 X 是静态的,则会找到一个从结构体基准点的 offset,在这里找到实际编码。)

我们将在最后的例子中回来讨论这个

  • 对于固定大小数组 T[k](任何 Tk):

enc(X) = enc((X[0], .., X[k-1])) 即作为具有 k 个同一类型元素的元组编码

  • 对于动态大小数组 T[]k :

enc(X) = enc(k) enc((X[0], .. , X[k-1])) 即作为具有 k 个同一类型元素的元组,前缀为元素的数量!

  • 长度为 kbytes :

enc(X) = enc(k) pad_right(X) 即作为字节数随后跟随实际字节序列,用 32 的倍数进行右填充

  • string :

enc(X) = enc(enc_utf8(X))X 为 UTF-8,并被视为 bytes

  • uint<M> :

enc(X)X 的大端编码,左侧填充使得 len(enc(X)) == 32

  • int<M> :

enc(X)X 的大端二补数编码,左侧填充 0xff 如果 X 为负,并用零字节填充,如果 X为非负,使得 len(enc(X)) == 32

  • bool : 编码为 uint8,其中 1 用于 true0 用于 false
  • bytes<M>:

enc(X) 是字节序列,尾部填充零以使得 len(enc(X)) == 32

我还可以展示像 fixedufixed 这样的类型的编码,但不会,因为它们在 Solidity v0.8.19 中仍然没有完全支持。

A practical example

现在,我想带你了解如何读取原始 calldata 并手动解码它(如果你想挑战一下,可以尝试手动编码)。

让我们以对 Balancer’s Vault 的调用的数据作为例,特别是对它的 swap 函数的调用,因为它需要两个结构体和两个 uint256 作为参数。

这里 是我们将要分析的交易。

首先,让我们抓取 Etherscan 显示的 calldata(它非常好地为我们分块 32 字节的 calldata):

Function: swap((bytes32,uint8,address,address,uint256,bytes), (address,bool,address,bool), uint256, uint256)

MethodID: 0x52bbbe29                                               从参数编码块开始的偏移量
  00000000000000000000000000000000000000000000000000000000000000e0 0x0000
  0000000000000000000000008d7e58c0ebf988dbb31a993696286106964dd4f4 0x0020
  0000000000000000000000000000000000000000000000000000000000000000 0x0040
  0000000000000000000000008d7e58c0ebf988dbb31a993696286106964dd4f4 0x0060
  0000000000000000000000000000000000000000000000000000000000000000 0x0080
  0000000000000000000000000000000000000000000b3a7f984c82f6ffa3d428 0x00a0
  ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 0x00c0
  929a9b6d40e4723f690db77a7ebb65d3254be1e00002000000000000000004d0 0x00e0
  0000000000000000000000000000000000000000000000000000000000000000 0x0100
  0000000000000000000000000000000000000000000000000000000000000000 0x0120
  000000000000000000000000677d4fbbcdd9093d725b0042081ab0b67c63d121 0x0140
  00000000000000000000000000000000000000000000000006f05b59d3b20000 0x0160
  00000000000000000000000000000000000000000000000000000000000000c0 0x0180
  0000000000000000000000000000000000000000000000000000000000000000 0x01a0

让我们从上到下逐个处理每个 32 字节字:

  • 从字节 0x00 到 0x1f,我们找到了 0xe0,这应该是第一个参数(一个结构体)的头。记住这意味着什么吗?这意味着结构体的至少一个字段是动态的!事实上,第一个结构体有一个 bytes 成员。
  • 在字节 0x20 到 0x3f 中,我们应该找到第二个结构体的头,但我们找到的看起来像一个 address。这确实是第二个结构体的第一个成员;在接下来的位置,直到 0x9f,你可以看到所有其他成员。
  • 在字节 0xa0 到 0xbf 之间,我们找到的十六进制数字是 0x0b3a7f984c82f6ffa3d428,在十进制中是 13574434982555110814766120:第三个函数参数。
  • 在字节 0xc0 到 0xdf 的地方,都是 0xff 的字节:这意味着第四个参数被设置为 type(uint256).max

到目前为止,我们知道了:

我们找到了头部

现在我们找到 4 个参数中的 3 个,最后一个参数没多少地方可以隐藏:读取第一个结构体的头作为一个偏移量,我们指向第八个字,从顶部开始,这是第一个结构体的第一个成员,一个 bytes32 元素。

之后,在每个字中,我们可以找到所有后续结构体成员,直到找到一个 0xc0,最后应该是一个 bytes 成员。

起初,这可能不太有意义,因为在从 0xc0 开始的字中放置了第二个 uint256,所以这是怎么回事呢?

解决这个混淆的是要理解,这个偏移量不是从参数编码的 0x00 字节解释,而是从 第一结构体成员列出的位置 的偏移量,所以是 0xe0

那么这个 bytes 成员在哪里呢?在以 0xe0 + 0xc0 = 0x01a0 开始的字中!鉴于这是一个空的 bytes 数组,这个槽编码为 0 而没有列出后续数据。

完整的图景是这样的:

calldata 的分解

结论

希望这对你来说是一次有趣的阅读体验,并且你像我一样学到了新东西。

如果你想继续尝试更多奇特的类型组合(例如,结构体的动态大小数组,其中有包含 bytes 成员的结构体的数组),我推荐你从 foundry 工具链中使用 cast:构建一些随机签名,用这些疯狂的类型并通过 cast abi-encode 传递它们以及你喜欢的任何数据,尝试完成我们今天所做的练习。

下次见,匿名者。

  • 原文链接: medium.com/@ljmanini/abi...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
ljmanini
ljmanini
江湖只有他的大名,没有他的介绍。