ABI 编码

ABI 是应用二进制接口,ABI是从区块链外部与合约进行交互以及合约与合约间进行交互的一种标准方式。数据会根据其类型进行编码。需要一种特定的概要(schema)来进行解码。

对于一些没有开源的代码,我们可以通过区块链上传入的参数,来反推数据结构,根据方法的结果,来反推内部实现逻辑。经常听到一些没有开源的合约被盗,基本就是被别人通过ABI编码反推来寻找漏洞的。

ABI类型编码

基础类型

  • uint<M>M 位的无符号整数, 0 < M <= 256M % 8 == 0。例如: uint32uint8uint256

  • int<M>:以 2 的补码作为符号的 M 位整数, 0 < M <= 256M % 8 == 0

  • address:除了字面上的意思和语言类型的区别以外,等价于uint160。在计算和 函数选择器(function selector) 中,通常使用 address

  • uintintuint256int256 各自的同义词。在计算和函数选择器(function selector) 中,通常使用 uint256int256

  • bool:等价于 uint8,取值限定为 0 或 1 。在计算和函数选择器(function selector) 中,通常使用 bool

  • fixed<M>x<N>M 位的有符号的固定小数位的十进制数字8 <= M <= 256M % 8 == 0、且 0 < N <= 80。其值 v 即是v / (10 ** N)

  • ufixed<M>x<N>:无符号的 fixed<M>x<N>

  • fixedufixedfixed128x18ufixed128x18各自的同义词。在计算和 函数选择器(function selector) 中,通常使用fixed128x18ufixed128x18

  • bytes<M>M 字节的二进制类型, 0 < M <= 32

  • function:一个地址(20 字节)之后紧跟一个 函数选择器(function selector)(4 字节)。编码之后等价于 bytes24

定长数组类型

  • <type>[M]:有 M 个元素的定长数组, M >= 0,数组元素为给定类型。

    • ⚠️:尽管此 ABI 规范可以表示零个元素的定长数组,但编译器不支持它们。

非定长类型:

  • bytes:动态大小的字节序列。

  • string:动态大小的 unicode 字符串,通常呈现为 UTF-8 编码。

  • <type>[]:元素为给定类型的变长数组。

    • 可以将若干类型放到一对括号中,用逗号分隔开,以此来构成一个 元组(tuple):

  • (T1,T2,...,Tn):由 T1,…, Tnn >= 0 构成的 元组(tuple)。

用 元组(tuple) 构成 元组(tuple)、用 元组(tuple)构成数组等等也是可能的。另外也可以构成”零元组(zero-tuples)”,就是 n = 0 的情况。

不支持 ABI 的 Solidity 类型

Solidity 支持上面介绍的所有同名称的类型,除元组外。 另一方面,一些Solidity 类型不被 ABI 支持。下表在左栏显示了不支持 ABI 的 Solidity 类型,以及在右栏显示可以代表它们的 ABI 类型。

Solidity ABI
address payable address
contract address
enum uint8
user defined value types its underlying value type
struct tuple

⚠️: 在 0.8.0 版本之前,枚举(enums) 可以多余 256 个成员并且可以使用最小可保存的整型来保存他们。

ABI编码的设计准则

我们现在来正式讲述编码,它具有如下属性,如果参数是嵌套的数组,这些属性非常有用:

  1. 读取的次数取决于参数数组结构中的最大深度;也就是说,要取得a_i[k][l][r] 需要读取 4 次。

  2. 变量或数组元素的数据不与其他数据交错,并且它是可以再定位的。它们只会使用相对的”地址”。

编码的形式化说明

我们需要区分静态和动态类型。静态类型会被直接编码,动态类型则会在当前数据块之后单独分配的位置被编码。

定义: 以下类型被称为”动态”:

  • bytes

  • string

  • 任意类型 T 的变长数组 T[]

  • 任意动态类型 T 的定长数组 T[k]k >= 0

  • 由动态的 Ti1 <= i <= k)构成的 元组(tuple) (T1,...,Tk)

所有其他类型都被称为”静态”。

定义: len(a) 是一个二进制字符串 a 的字节长度。 len(a) 的类型被呈现为 uint256

我们把实际的编码 enc 定义为一个由 ABI 类型到二进制字符串的值的映射;因而,当且仅当 X 的类型是动态的, len(enc(X)) (即 X 经编码后的实际长度,译者注)才会依赖于 X 的值。

定义: 对任意 ABI 值 X,我们根据 X 的实际类型递归地定义 enc(X)

  • (T1,...,Tk) 对于 k >= 0 且任意类型 T1 ,…, Tk

    enc(X) = head(X(1)) ... head(X(k)) tail(X(1)) ... tail(X(k))

    这里, X = (X(1), ..., X(k)),并且 当 Ti 为静态类型时, headtail 被定义为

    head(X(i)) = enc(X(i)) and tail(X(i)) = "" (空字符串)

    否则,比如 Ti 是动态类型时,它们被定义为

    head(X(i)) = enc(len(head(X(1)) ... head(X(k-1)) tail(X(1)) ... tail(X(i-1)))) tail(X(i)) = enc(X(i))

    注意,在动态类型的情况下,由于 head 部分的长度仅取决于类型而非值,所以 head(X(i)) 是定义明确的。它的值是从 enc(X) 的开头算起的,tail(X(i)) 的起始位在 enc(X) 中的偏移量。

  • T[k] 对于任意 Tk

    enc(X) = enc((X[0], ..., X[k-1]))

    即是说,它就像是个由相同类型的 k 个元素组成的 元组(tuple) 那样被编码的。

  • T[]Xk 个元素( k 被呈现为类型 uint256):

    enc(X) = enc(k) enc([X[1], ..., X[k]])

    即是说,它就像是个由静态大小 k 的数组那样被编码的,且由元素的个数作为前缀。

  • 具有 k (呈现为类型 uint256)长度的 bytes

    enc(X) = enc(k) pad_right(X),即是说,字节数被编码为 uint256,紧跟着实际的 X 的字节码序列,再在高位(左侧)补上可以使 len(enc(X)) 成为 32 的倍数的最少数量的 0 值字节数据。

  • string

    enc(X) = enc(enc_utf8(X)),即是说, X 被 UFT-8 编码,且在后续编码中将这个值解释为 bytes 类型。注意,在随后的编码中使用的长度是其 UFT-8 编码的字符串的字节数,而不是其字符数。

  • uint<M>enc(X) 是在 X 的大端序编码的高位(左侧)补充若干 0 值字节以使其长度成为 32 字节。

  • address:与 uint160 的情况相同。

  • int<M>enc(X) 是在 X 的大端序的 2 的补码编码的高位(左侧)添加若干字节数据以使其长度成为 32 字节;对于负数,添加值为 0xff (即 8 位全为 1,译者注)的字节数据,对于非负数,添加 0 值(即 8 位全为 0,译者注)字节数据。

  • bool:与 uint8 的情况相同, 1 用来表示 true0 表示 false

  • fixed<M>x<N>enc(X) 就是 enc(X * 10**N),其中 X * 10**N 可以理解为 int256

  • fixed:与 fixed128x18 的情况相同。

  • ufixed<M>x<N>enc(X) 就是 enc(X * 10**N),其中 X * 10**N 可以理解为 uint256

  • ufixed:与 ufixed128x18 的情况相同。

  • bytes<M>enc(X) 就是 X 的字节序列加上为使长度成为 32 字节而添加的若干 0 值字节。

注意,对于任意的 Xlen(enc(X)) 都是 32 的倍数。

函数选择器和参数编码

函数选择器(function selector):以 a_1, ..., a_n 为参数的对 f 函数的调用,会被编码为function_selector(f) enc((a_1, ..., a_n))f 的返回值 v_1, ..., v_k 会被编码为 enc((v_1, ..., v_k)),也就是说,返回值会被组合为一个 元组(tuple) 进行编码。

函数选择器 function selector

这个在 函数的签名 那里已经详细介绍过,之类做一个小总结。

一个函数调用数据的前 4 字节,指定了要调用的函数。这就是某个函数签名的Keccak 哈希的前 4 字节(bytes32类型是从左取值)。

函数签名被定义为基础原型的规范表达,而基础原型是函数名称加上由括号括起来的参数类型列表,参数类型间由一个逗号分隔开,且没有空格。.

⚠️ 注意: 函数的返回类型并不是函数签名的一部分。在 Solidity 的函数重载 中,返回值并没有被考虑。这是为了使对函数调用的解析保持上下文无关。 然而 metadata 的描述中即包含了输入也包含了输出。(参考 JSON ABI)。

参数编码

从第 5 字节开始是被编码的参数。这种编码方式也被用在其他地方,比如,返回值和事件的参数也会被用同样的方式进行编码,而用来指定函数的 4 个字节则不需要再进行编码。

例子

给定一个合约:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract Foo {
  function bar(bytes3[2]) public pure {}
  function baz(uint32 x, bool y) public pure returns (bool r) { r = x > 32 || y; }
  function sam(bytes, bool, uint[]) public pure {}
}

调用 baz

这样,对于我们的例子 Foo,如果我们想用 69true 做参数调用 baz,我们总共需要传送 68 字节,可以分解为:

  • 0xcdcd77c0:方法 ID。这源自 ASCII 格式的 baz(uint32,bool) 签名的 Keccak 哈希的前 4 字节。

  • 0x0000000000000000000000000000000000000000000000000000000000000045:第一个参数,一个被用 0 值字节补充到 32 字节的 uint32 值 69

  • 0x0000000000000000000000000000000000000000000000000000000000000001:第二个参数,一个被用 0 值字节补充到 32 字节的 boolean 值 true

合起来就是:

0xcdcd77c0
0000000000000000000000000000000000000000000000000000000000000045
0000000000000000000000000000000000000000000000000000000000000001

它返回一个 bool。比如它返回 false,那么它的输出将是一个字节数组 0x0000000000000000000000000000000000000000000000000000000000000000,一个 bool 值。

调用 bar

如果我们想用 ["abc", "def"] 做参数调用bar,我们总共需要传送 68 字节,可以分解为:

  • 0xfce353f6:方法 ID。源自 bar(bytes3[2]) 的签名。

  • 0x6162630000000000000000000000000000000000000000000000000000000000:第一个参数的第一部分,一个bytes3"abc" (左对齐)。

  • 0x6465660000000000000000000000000000000000000000000000000000000000:第一个参数的第二部分,一个 bytes3"def" (左对齐)。

合起来就是:

0xfce353f6
6162630000000000000000000000000000000000000000000000000000000000
6465660000000000000000000000000000000000000000000000000000000000

调用 sam

如果我们想用 "dave"true[1,2,3] 作为参数调用sam,我们总共需要传送 292 字节,可以分解为:

  • 0xa5643bf2:方法 ID。源自 sam(bytes,bool,uint256[])的签名。注意, uint 被替换为了它的权威代表 uint256

  • 0x0000000000000000000000000000000000000000000000000000000000000060:第一个参数(动态类型)的数据部分的位置,即从参数编码块开始位置算起的字节数。在这里,是 0x60

  • 0x0000000000000000000000000000000000000000000000000000000000000001:第二个参数:boolean 的 true。

  • 0x00000000000000000000000000000000000000000000000000000000000000a0:第三个参数(动态类型)的数据部分的位置,由字节数计量。在这里,是0xa0

  • 0x0000000000000000000000000000000000000000000000000000000000000004:第一个参数的数据部分,以字节数组的元素个数作为开始,在这里,是 4。

  • 0x6461766500000000000000000000000000000000000000000000000000000000:第一个参数的内容 "dave" 的 UTF-8 编码(在这里等同于 ASCII 编码),并在右侧(低位)用 0 值字节补充到 32 字节。

  • 0x0000000000000000000000000000000000000000000000000000000000000003:第三个参数的数据部分,以数组的元素个数作为开始,在这里,是 3。

  • 0x0000000000000000000000000000000000000000000000000000000000000001:第三个参数的第一个数组元素。

  • 0x0000000000000000000000000000000000000000000000000000000000000002:第三个参数的第二个数组元素。

  • 0x0000000000000000000000000000000000000000000000000000000000000003:第三个参数的第三个数组元素。

合起来就是:

0xa5643bf2
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000004
6461766500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

动态类型的使用

例子1:静态和动态混合

用参数 (0x123, [0x456, 0x789], "1234567890", "Hello, world!") 进行对函数 f(uint,uint32[],bytes10,bytes) 的调用会通过以下方式进行编码:

取得 sha3("f(uint256,uint32[],bytes10,bytes)") 的前 4 字节,也就是 0x8be65246。 然后我们对所有 4 个参数的头部进行编码。对静态类型 uint256bytes10 是可以直接传过去的值;对于动态类型 uint32[]bytes我们使用的字节数偏移量是它们的数据区域的起始位置,由需编码的值的开始位置算起(也就是说,不计算包含了函数签名的前 4 字节),这就是:

基础部分

  • 0x8be65246

  • 0x0000000000000000000000000000000000000000000000000000000000000123 0x123 补充到 32 字节)

  • 0x0000000000000000000000000000000000000000000000000000000000000080(第二个参数的数据部分起始位置的偏移量,4*32 字节,正好是头部的大小)

  • 0x3132333435363738393000000000000000000000000000000000000000000000"1234567890" 从右边补充到 32 字节)

  • 0x00000000000000000000000000000000000000000000000000000000000000e0(第四个参数的数据部分起始位置的偏移量 = 第一个动态参数的数据部分起始位置的偏移量 + 第一个动态参数的数据部分的长度 = 4*32 + 3*32,参考后文)

动态部分

在此之后,跟着第一个动态参数的数据部分 [0x456, 0x789]

  • 0x0000000000000000000000000000000000000000000000000000000000000002(数组元素个数,2)

  • 0x0000000000000000000000000000000000000000000000000000000000000456(第一个数组元素)

  • 0x0000000000000000000000000000000000000000000000000000000000000789(第二个数组元素)

最后,我们将第二个动态参数的数据部分 "Hello, world!" 进行编码:

  • 0x000000000000000000000000000000000000000000000000000000000000000d(元素个数,在这里是字节数:13)

  • 0x48656c6c6f2c20776f726c642100000000000000000000000000000000000000"Hello, world!" 从右边补充到 32 字节)

最后,合并到一起的编码就是(为了清晰,在 函数选择器(function selector) 和每 32 字节之后加了换行):

0x8be65246
0000000000000000000000000000000000000000000000000000000000000123
0000000000000000000000000000000000000000000000000000000000000080
3132333435363738393000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000e0
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000456
0000000000000000000000000000000000000000000000000000000000000789
000000000000000000000000000000000000000000000000000000000000000d
48656c6c6f2c20776f726c642100000000000000000000000000000000000000

例子2: 纯动态参数

让我们使用相同的原理来对一个签名为 g(uint[][],string[]),参数值为([[1, 2], [3]], ["one", "two", "three"])的函数来进行编码;但从最原子的部分开始:

首先我们将第一个根数组 [[1, 2], [3]] 的第一个嵌入的动态数组 [1, 2]的长度和数据进行编码:

  • 0x0000000000000000000000000000000000000000000000000000000000000002(第一个数组中的元素数量 2;元素本身是 12)

  • 0x0000000000000000000000000000000000000000000000000000000000000001(第一个元素)

  • 0x0000000000000000000000000000000000000000000000000000000000000002(第二个元素)

然后我们将第一个根数组 [[1, 2], [3]] 的第二个潜入的动态数组 [3]的长度和数据进行编码:

  • 0x0000000000000000000000000000000000000000000000000000000000000001(第二个数组中的元素数量 1;元素数据是 3)

  • 0x0000000000000000000000000000000000000000000000000000000000000003(第一个元素)

然后我们需要找到动态数组 [1, 2][3]的偏移量。要计算这个偏移量,我们可以来看一下第一个根数组 [[1, 2], [3]]编码后的具体数据:

0 - a                                                                - [1, 2] 的偏移量
1 - b                                                                - [3] 的偏移量
2 - 0000000000000000000000000000000000000000000000000000000000000002 - [1, 2] 的计数
3 - 0000000000000000000000000000000000000000000000000000000000000001 - 1 的编码
4 - 0000000000000000000000000000000000000000000000000000000000000002 - 2 的编码
5 - 0000000000000000000000000000000000000000000000000000000000000001 - [3] 的计数
6 - 0000000000000000000000000000000000000000000000000000000000000003 - 3 的编码

偏移量 a 指向数组 [1, 2] 内容的开始位置,即第 2 行的开始(64字节);所以 a = 0x0000000000000000000000000000000000000000000000000000000000000040

偏移量 b 指向数组 [3] 内容的开始位置,即第 5 行的开始(160字节);所以 b = 0x00000000000000000000000000000000000000000000000000000000000000a0

然后我们对第二个根数组的嵌入字符串进行编码:

  • 0x0000000000000000000000000000000000000000000000000000000000000003(单词 "one" 中的字符个数)

  • 0x6f6e650000000000000000000000000000000000000000000000000000000000(单词 "one" 的 utf8 编码)

  • 0x0000000000000000000000000000000000000000000000000000000000000003(单词 "two" 中的字符个数)

  • 0x74776f0000000000000000000000000000000000000000000000000000000000(单词 "two" 的 utf8 编码)

  • 0x0000000000000000000000000000000000000000000000000000000000000005(单词 "three" 中的字符个数)

  • 0x7468726565000000000000000000000000000000000000000000000000000000(单词 "three" 的 utf8 编码)

作为与第一个根数组的并列,因为字符串也属于动态元素,我们也需要找到它们的偏移量 c, de

0 - c                                                                - "one" 的偏移量
1 - d                                                                - "two" 的偏移量
2 - e                                                                - "three" 的偏移量
3 - 0000000000000000000000000000000000000000000000000000000000000003 - "one" 的字符计数
4 - 6f6e650000000000000000000000000000000000000000000000000000000000 - "one" 的编码
5 - 0000000000000000000000000000000000000000000000000000000000000003 - "two" 的字符计数
6 - 74776f0000000000000000000000000000000000000000000000000000000000 - "two" 的编码
7 - 0000000000000000000000000000000000000000000000000000000000000005 - "three" 的字符计数
8 - 7468726565000000000000000000000000000000000000000000000000000000 - "three" 的编码

偏移量 c 指向字符串 "one" 内容的开始位置,即第 3 行的开始(96字节);所以c = 0x0000000000000000000000000000000000000000000000000000000000000060

偏移量 d 指向字符串 "two" 内容的开始位置,即第 5 行的开始(160字节);所以d = 0x00000000000000000000000000000000000000000000000000000000000000a0

偏移量 e 指向字符串 "three" 内容的开始位置,即第 7 行的开始(224字节);所以e = 0x00000000000000000000000000000000000000000000000000000000000000e0

注意,根数组的嵌入元素的编码并不互相依赖,且具有对于函数签名g(string[],uint[][]) 所相同的编码。

然后我们对第一个根数组的长度进行编码:

  • 0x0000000000000000000000000000000000000000000000000000000000000002(第一个根数组的元素数量 2;这些元素本身是 [1, 2][3])

而后我们对第二个根数组的长度进行编码:

  • 0x0000000000000000000000000000000000000000000000000000000000000003(第二个根数组的元素数量 3;这些字符串本身是 "one""two""three")

最后,我们找到根动态数组元素 [[1, 2], [3]]["one", "two", "three"] 的偏移量 fg。汇编数据的正确顺序如下:

0x2289b18c                                                            - 函数签名
 0 - f                                                                - [[1, 2], [3]] 的偏移量
 1 - g                                                                - 第二个参数的偏移量
 2 - 0000000000000000000000000000000000000000000000000000000000000002 - [[1, 2], [3]] 元素计数
 3 - 0000000000000000000000000000000000000000000000000000000000000040 - [1, 2] 的偏移量
 4 - 00000000000000000000000000000000000000000000000000000000000000a0 - [3] 的偏移量
 5 - 0000000000000000000000000000000000000000000000000000000000000002 - [1, 2] 的元素计数
 6 - 0000000000000000000000000000000000000000000000000000000000000001 - 1 的编码
 7 - 0000000000000000000000000000000000000000000000000000000000000002 - 2 的编码
 8 - 0000000000000000000000000000000000000000000000000000000000000001 - [3] 的元素计数
 9 - 0000000000000000000000000000000000000000000000000000000000000003 - 3 的编码
10 - 0000000000000000000000000000000000000000000000000000000000000003 - 第二个参数元素计数
11 - 0000000000000000000000000000000000000000000000000000000000000060 - "one" 的偏移量
12 - 00000000000000000000000000000000000000000000000000000000000000a0 - "two" 的偏移量
13 - 00000000000000000000000000000000000000000000000000000000000000e0 - "three" 的偏移量
14 - 0000000000000000000000000000000000000000000000000000000000000003 - "one" 的字符计数
15 - 6f6e650000000000000000000000000000000000000000000000000000000000 - "one" 的编码
16 - 0000000000000000000000000000000000000000000000000000000000000003 - "two" 的字符计数
17 - 74776f0000000000000000000000000000000000000000000000000000000000 - "two" 的编码
18 - 0000000000000000000000000000000000000000000000000000000000000005 - "three" 的字符计数
19 - 7468726565000000000000000000000000000000000000000000000000000000 - "three" 的编码

偏移量 f 指向数组 [[1, 2], [3]] 内容的开始位置,即第 2 行的开始(64字节);所以f = 0x0000000000000000000000000000000000000000000000000000000000000040

偏移量 g 指向数组 ["one", "two", "three"] 内容的开始位置,即第 10行的开始(320 字节);所以 g = 0x0000000000000000000000000000000000000000000000000000000000000140

事件

事件是以太坊的日志,事件是监视协议的一个抽象。日志项提供了合约的地址、一系列的indexed(最多 4 项)和一些任意长度的二进制数据。为了使用合适的类型数据结构来演绎这些功能,事件沿用了既存的 ABI 函数。

给定了事件名称和事件参数之后,我们将其分解为两个子集:已索引的和未索引的。已索引的部分,最多有3 个(对于非匿名事件)或 4 个(对于匿名事件),被用来与事件签名的 Keccak哈希一起组成日志项的主题。未索引的部分就组成了事件的字节数组。

这样,一个使用 ABI 的日志项就可以描述为:

  • address:合约地址(由 以太坊 真正提供);

  • topics[0]keccak(EVENT_NAME+"("+EVENT_ARGS.map(canonical_type_of).join(",")+")")

    • canonical_type_of 是一个可以返回给定参数的权威类型的函数,例如,对 uint indexed foo 它会返回 uint256)。

    • 如果事件被声明为 anonymous,那么 topics[0] 不会被生成;

  • topics[n]

    • 如果不是匿名事件,为 abi_encode(EVENT_INDEXED_ARGS[n - 1])

    • 否则则为 abi_encode(EVENT_INDEXED_ARGS[n])EVENT_INDEXED_ARGS 是已索引的 EVENT_ARGS);

  • dataabi_serialise(EVENT_NON_INDEXED_ARGS)

    • EVENT_NON_INDEXED_ARGS 是未索引的 EVENT_ARGSabi_serialise 是一个用来从某个函数返回一系列类型值的 ABI 序列化函数,就像上文所讲的那样)。

对于所有定长的 Solidity 类型, EVENT_INDEXED_ARGS 数组会直接包含 32 字节的编码值。

然而,对于 动态长度的类型 ,包含stringbytes 和数组, EVENT_INDEXED_ARGS 会包含编码值的 Keccak哈希,而不是直接包含编码值。这样就允许应用程序更有效地查询动态长度类型的值(通过把编码值的哈希设定为主题),但也使应用程序不能对它们还没查询过的已索引的值进行解码。

对于动态长度的类型,应用程序开发者面临在对预先设定的值(如果参数已被索引)的快速检索和对任意数据的清晰处理(需要参数不被索引)之间的权衡。

开发者们可以通过定义两个参数(一个已索引、一个未索引)保存同一个值的方式来解决这种权衡,从而既获得高效的检索又能清晰地处理任意数据。

事件索引参数的编码

对于不是值类型的事件索引参数,如:数组和结构,是不直接存储的,而是存储一个 keccak256-hash 编码。这个编码被定义如下:

  • bytesstring 的编码只是字符串的内容,没有任何填充或长度前缀。

  • 结构体的编码是其成员编码的拼接,总是填充为 32 字节的倍数(即便是 bytesstring 类型)。

  • 数组(包含动态和静态大小的数组)的编码是其元素的编码的拼接,总是填充为 32 字节的倍数(即便是 bytesstring 类型),并且没有长度前缀

上面的规范,像往常一样,负数会符号扩展填充,而不是零填充。 bytesNN类型在右边填充,而 uintNN / intNN 在左边填充。

⚠️警告: 如果一个结构体包含一个以上的动态大小的数组,那么其编码会模糊有歧义。正因为如此,要经常重新检查事件数据,不能仅仅依靠索引参数的结果。

错误编码

在合约内部发生错误的情况下,合约可以使用一个特殊的操作码来中止执行,并恢复所有的状态变化。除了这些效果之外,可以返回描述性数据给调用者。这种描述性数据是对错误及其参数的编码,其方式与函数调用的数据相同。

例如,让我们考虑以下合约,其 transfer 功能在出现”余额不足”时,提示自定义错误:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

contract TestToken {
    error InsufficientBalance(uint256 available, uint256 required);
    function transfer(address to, uint amount) public pure {
        revert InsufficientBalance(0, amount);
    }
}

返回错误数据是以函数调用相同的方式编码, InsufficientBalance(0, amount) 与函数 InsufficientBalance(uint256,uint256) 编码一样。 例如为:0xcf479181, uint256(0), uint256(amount).

⚠️注意:错误的选择器 0x000000000xffffffff 被保留将来使用。

⚠️注意:永远不要相信错误数据。默认情况下,错误数据会通过外部调用链向上冒泡,这意味着一个合约可能会收到一个它直接调用的任何合约中没有定义的错误。此外,任何合约都可以通过返回与错误签名相匹配的数据来伪造任何错误,即使该错误没有在任何地方定义。

JSON

合约接口的 JSON 格式是用来描述函数,事件或错误描述的一个数组。

函数的JSON

一个函数的描述是一个有如下字段的 JSON 对象:

  • type"function""constructor""fallback"

  • name:函数名称;

  • inputs:对象数组,每个数组对象会包含:

    • name:参数名称;

    • type:参数的权威类型(详见下文)

    • components:供 元组(tuple) 类型使用(详见下文)

  • outputs:一个类似于 inputs的对象数组,如果函数无返回值时可以被省略;

  • payable:如果函数接受 以太币 ,为 true;缺省为 false

  • stateMutability:为下列值之一: pureviewnonpayablepayable

type 可以被省略,缺省为 "function"

⚠️注意:构造函数 constructor 和 fallback 函数没有 nameoutputs。fallback 函数也没有 inputs

  • 向 non-payable(即不接受 以太币 )的函数发送非零值的以太币 会回退交易。

  • 状态可变性 nonpayable 是默认的,不用显示指定。

事件的JSON

一个事件描述是一个有极其相似字段的 JSON 对象:

  • type:总是 "event"

  • name:事件名称;

  • inputs:对象数组,每个数组对象会包含:

    • name:参数名称;

    • type:参数的权威类型(相见下文);

    • components:供 元组(tuple) 类型使用(详见下文);

    • indexed:如果此字段是日志的一个主题,则为 true;否则为 false

  • anonymous:如果事件被声明为 anonymous,则为 true

错误的JSON

错误这是一下类似的形式:

  • type: 为 "error"

  • name: 错误的名称。

  • inputs: 对象数组,每个元素包含:

    • name: 参数名称。

    • type: 参数的规范类型(更多详细内容见下文)。

    • components: 用于元组类型 (更多详细内容见下文).

⚠️注意:在 JSON 数组中可能有多个名称相同、甚至签名相同的错误。例如,如果错误来自智能合约中的不同文件,或引用自另一个智能合约。

对于 ABI 来说,它仅取决于错误的名称,而不是它的定义位置。

例子演示

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

contract Test {
    bytes32 b;

    constructor() {
        b = "0x12";
    }

    event Event(uint256 indexed a, bytes32 b);
    error InsufficientBalance(uint256 available, uint256 required);

    function foo(uint256 a) public {
        emit Event(a, b);
    }
}

可由如下 JSON 来表示:

[
  {
    "inputs": [],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [
      { "internalType": "uint256", "name": "available","type": "uint256"},
      { "internalType": "uint256", "name": "required", "type": "uint256"}
    ],
    "name": "InsufficientBalance",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      { "indexed": true, "internalType": "uint256", "name": "a", "type": "uint256"},
      { "indexed": false, "internalType": "bytes32", "name": "b", "type": "bytes32"}
    ],
    "name": "Event",
    "type": "event"
  },
  {
    "inputs": [
      { "internalType": "uint256", "name": "a", "type": "uint256"}
    ],
    "name": "foo",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

处理 元组(tuple) 类型

尽管名称被有意地不作为 ABI 编码的一部分,但将它们包含进 JSON 来显示给最终用户是非常合理的。其结构会按下列方式进行嵌套:

一个拥有 nametype 和潜在的 components成员的对象描述了某种类型的变量。 直至到达一个 元组(tuple)类型且到那点的存储在 type 属性中的字符串以 tuple为前缀,也就是说,在 tuple 之后紧跟一个 [] 或有整数 k[k],才能确定一个 元组(tuple)。 元组(tuple) 的组件元素会被存储在成员components中,它是一个数组类型,且与顶级对象具有同样的结构,只是在这里不允许已索引的(indexed)数组元素。

作为例子,代码

pragma solidity >=0.7.5 <0.9.0;
pragma abicoder v2;

contract Test {
  struct S { uint a; uint[] b; T[] c; }
  struct T { uint x; uint y; }
  function f(S memory, T memory, uint) public pure { }
  function g() public pure returns (S memory, T memoryt, uint) {}
}

可由如下 JSON 来表示:

[
  {
    "name": "f",
    "type": "function",
    "inputs": [
      {
        "name": "s",
        "type": "tuple",
        "components": [
          {
            "name": "a",
            "type": "uint256"
          },
          {
            "name": "b",
            "type": "uint256[]"
          },
          {
            "name": "c",
            "type": "tuple[]",
            "components": [
              {
                "name": "x",
                "type": "uint256"
              },
              {
                "name": "y",
                "type": "uint256"
              }
            ]
          }
        ]
      },
      {
        "name": "t",
        "type": "tuple",
        "components": [
          {
            "name": "x",
            "type": "uint256"
          },
          {
            "name": "y",
            "type": "uint256"
          }
        ]
      },
      {
        "name": "a",
        "type": "uint256"
      }
    ],
    "outputs": []
  }
]

严格编码模式

严格的编码模式与上述正式规范中定义的编码完全相同,但使偏移量必须尽可能小,同时不能在数据区域产生重叠,也不允许有间隙。

通常,ABI 解码器是以直接的方式编写的,只是遵循偏移量指针,但有些解码器可能强制执行严格模式。Solidity ABI 解码器目前并不强制执行严格模式,但编码器总是以严格模式创建数据。

非标准打包模式

Non-standard Packed Mode 被称为非标准打包模式,通过 abi.encodePacked(), Solidity 支持一种非标准打包模式处理以下情形:

  • 长度低于 32 字节的类型,会直接拼接,既不会进行补 0 操作,也不会进行符号扩展

  • 动态类型会直接进行编码,并且不包含长度信息。

  • 数组元素会填充,但仍旧会就地编码。

例如,对 int1, bytes1, uint16, string 用数值-1, 0x42, 0x2424, "Hello, world!" 进行编码将生成如下结果 ::

0xff42242448656c6c6f2c20776f726c6421
  ^^                                 int1(-1)
    ^^                               bytes1(0x42)
      ^^^^                           uint16(0x2424)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^ string("Hello, world!") without a length field

更具体地说:

  • 在编码过程中,所有内容均是就地编码,因此在编码中,没有头和尾的区别,而且数组的长度也不会被编码。

  • abi.encodePacked 的参数以不填充的方式编码,只要它们不是数组(或stringbytes)。

  • 数组的编码是由其元素的编码及其填充(padding)的拼接

  • 动态大小的类型如 string, bytesuint[] 在编码时,不包含长度字段

  • stringbytes 的编码不会在末尾进行填充(padding),除非它是一个数组或结构的一部分(此时会填充为 32 个自己的整数倍 )

一般来说,只要有两个动态大小的元素,因为缺少长度字段,编码就会模糊有歧义。

如果需要填充,可以使用明确的类型转换:abi.encodePacked(uint16(0x12)) == hex"0012".

由于在调用函数时没有使用打包模式编码,所以没有特别支持预留函数选择器。由于编码是模糊有歧义的,所以也没有解码方法。

警告:如果你使用 keccak256(abi.encodePacked(a, b)) 并且 ab 都是动态类型, 很容易通过把 a 的一部分移到 b中,从而发生哈希碰撞,反之亦然。

更具体地说, abi.encodePacked("a", "bc") == abi.encodePacked("ab", "c") 。如果你使用 abi.encodePacked 进行签名,认证或数据完整性检验,请确保总是使用相同的类型并且其中只有最多一个动态类型。除非有令人信服的理由,否则应首选 abi.encode

实战应用

问答题

  • 不支持 ABI 的 Solidity 类型

    • 下表在左栏显示了不支持 ABI 的 Solidity 类型,以及在右栏显示可以代表它们的 ABI 类型。

Solidity ABI
address payable address
contract address
enum uint8
user defined value types its underlying value type
struct tuple
  • ABI编码的设计准则

    1. 读取的次数取决于参数数组结构中的最大深度;也就是说,要取得a_i[k][l][r] 需要读取 4 次。

    2. 变量或数组元素的数据不与其他数据交错,并且它是可以再定位的。它们只会使用相对的”地址”。

  • 函数选择器 function selector 编码原则

    • 函数签名被定义为基础原型的规范表达,而基础原型是函数名称加上由括号括起来的参数类型列表,参数类型间由一个逗号分隔开,且没有空格。.

    • 函数的返回类型并不是函数签名的一部分。在 Solidity 的函数重载 中,返回值并没有被考虑。这是为了使对函数调用的解析保持上下文无关。 然而 metadata 的描述中即包含了输入也包含了输出。(参考 JSON ABI)。

  • 参数由静态和动态混合时的编码

    • 用参数 (0x123, [0x456, 0x789], "1234567890", "Hello, world!") 进行对函数 f(uint,uint32[],bytes10,bytes) 的调用会通过以下方式进行编码:取得 sha3("f(uint256,uint32[],bytes10,bytes)") 的前 4 字节,也就是 0x8be65246。 然后我们对所有 4 个参数的头部进行编码。对静态类型 uint256bytes10 是可以直接传过去的值;对于动态类型 uint32[]bytes我们使用的字节数偏移量是它们的数据区域的起始位置,由需编码的值的开始位置算起(也就是说,不计算包含了函数签名的前 4 字节)

  • 事件的ABI

    • 事件是以太坊的日志,事件是监视协议的一个抽象。日志项提供了合约的地址、一系列的indexed(最多 4 项)和一些任意长度的二进制数据。为了使用合适的类型数据结构来演绎这些功能,事件沿用了既存的 ABI 函数。

    • 给定了事件名称和事件参数之后,我们将其分解为两个子集:已索引的和未索引的。已索引的部分,最多有3 个(对于非匿名事件)或 4 个(对于匿名事件),被用来与事件签名的 Keccak哈希一起组成日志项的主题。未索引的部分就组成了事件的字节数组。

  • 错误ABI

    • 错误数据是以函数调用相同的方式编码, InsufficientBalance(0, amount) 与函数 InsufficientBalance(uint256,uint256) 编码一样。 例如为:0xcf479181, uint256(0), uint256(amount).

    • 错误的选择器 0x000000000xffffffff 被保留将来使用。

    • 永远不要相信错误数据。

  • ABI编码有哪些模式?

    • 标准模式

    • 严格编码模式

      • 严格的编码模式与上述正式规范中定义的编码完全相同,但使偏移量必须尽可能小,同时不能在数据区域产生重叠,也不允许有间隙。

    • 非严格打包模式

      • 通过 abi.encodePacked(), Solidity 支持一种非标准打包模式处理以下情形:

      • 长度低于 32 字节的类型,会直接拼接,既不会进行补 0 操作,也不会进行符号扩展

      • 动态类型会直接进行编码,并且不包含长度信息。

      • 数组元素会填充,但仍旧会就地编码。