Solidity ABI 编码深度解析:第二部分

本文是Solidity ABI编码系列文章的第二部分,深入探讨了Solidity中复杂数据结构(如结构体、数组和嵌套类型)的ABI编码机制。文章详细解释了静态结构体、动态结构体和嵌套动态类型结构体的编码过程,通过分步骤的示例,展示了如何确定结构体类型、创建头尾布局、编码头部和尾部,以及如何将它们组合起来生成最终的calldata。文章旨在帮助读者掌握Solidity ABI编码中的递归模式。

注意: 在开始之前阅读上一部分内容:

  1. 第 0 篇,
  2. 第 1 篇 ( 必须阅读 )

通过 本系列文章的第一部分,你已经内化了 ABI 编码的基础知识。

我们现在准备更深入地研究 Solidity 开发者每天使用的复杂结构:结构体、数组和深度嵌套类型。

本文的这一部分不仅仅是添加更多类型,而是关于解锁 ABI 编码中的递归模式

在这一部分中,我们将主要关注 solidity 结构体是如何编码的。

为了演示逐步编码,我们将使用 3 种不同类型的结构体示例:

  1. 静态结构体: 仅具有静态类型值的结构体
  2. 动态结构体: 具有静态和动态类型变量的结构体
  3. 超级动态结构体: 其中一个动态类型变量作为动态数组的结构体(更复杂,对吧)

结构体编码入门

在继续结构体类型中复杂的编码机制之前,让我们首先学习一些必要的知识:

Solidity 中的 struct(结构体) 是一种用户定义的类型,它将多个变量组合在一个名称下。结构体中的每个字段可以是不同的类型,结构体的编码完全取决于这些字段的组成。

我们如何知道一个结构体的类型?

正如我们在第 1 部分学到的,在编码时,理解我们正在编码的参数类型至关重要。规则取决于我们编码的类型。

但是如果一个结构体可以同时具有静态和动态类型呢?

ABI 规范 编码过程根据以下内容将类型分为静态动态结构体:

  • 静态结构体: 所有字段都是静态类型的结构体。
  • 动态结构体: 包含至少一个动态类型字段的结构体。

例如:

  • 结构体 ABC 是静态类型,但是
  • 结构体 XYZ 是动态类型(因为它有一个动态类型变量,即 bytes):
struct ABC{
  uint256 a;
  address b;
  bool    c;
}

struct XYZ{
  uint256 x;
  address y;
  bytes   c;
}

注意: 为了 ABI 的目的,结构体总是被视为元组

编码的基本规律将与我们在第 1 部分中讨论的相同,我们所需要做的就是:

  • 确定结构体的类型
  • 创建头-尾模式
  • 编码头和尾
  • 将它们全部连接起来以获得最终的 calldata

让我们现在开始编码。

a. 编码仅具有静态类型的结构体

让我们从一个仅包含静态类型的结构体开始 - 所有字段在编译时都具有固定的大小。

struct StaticStruct {
    uint256 id;
    bool isActive;
    address owner;
}

function encodeStaticStruct(StaticStruct memory s) public pure returns (bytes memory) {
    return msg.data;
}

// Arguments : [10, true, "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"]

我们现在应用 ABI 编码的基本规律,正如我们在第 1 部分中定义的那样。

步骤 1:确定结构体类型并可视化其头-尾布局

我们有一个单一的结构体 s,其所有类型都是静态的。这使得我们的结构体成为一个静态类型变量。

因此,参数元组看起来像:

tuple(s.id, s.isActive, s.owner)

or

tuple(10, true, 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4)

因为所有字段都是静态的,所以没有尾部部分

布局很简单:

encoded = [head(id)][head(isActive)][head(owner)]

没有动态类型 → 没有偏移量 → 没有尾部部分。

步骤 2:开始编码头和尾

让我们现在单独编码每个头。

回想一下,静态类型头的编码是编码值本身。

  1. 对于参数 id = 10:
    1. 大端序 编码(值右对齐)
    2. 左侧填充到 32 字节
000000000000000000000000000000000000000000000000000000000000000a
  1. 对于参数 isActive = true ( 布尔值 ):
    1. 编码为 uint8 → 变为 0x01
    2. 左侧填充到 32 字节
0000000000000000000000000000000000000000000000000000000000000001
  1. 对于参数 owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
    1. address 是 20 字节
    2. 右对齐,填充到 32 字节
0000000000000000000000005B38Da6a701c568545dCfcB03FcB875f56beddC4

步骤 3:组合所有编码后的头

将它们放在一起:

 Function-Signature: 118e7fac
 ------------
 [000]: 000000000000000000000000000000000000000000000000000000000000000a
 [020]: 0000000000000000000000000000000000000000000000000000000000000001
 [040]: 0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4

这很简单,因为 StaticStruct 是一个简单的静态类型。

现在让我们进入复杂的动态结构体世界。

b. 编码具有动态类型的结构体

对于动态结构体,让我们考虑以下示例。

struct DynamicStruct {
    uint256 id;
    string name;
    bytes data;
}

function encodeDynamicStruct(DynamicStruct memory s) public pure returns (bytes memory) {
    return msg.data;
}

// Arguments: [10, "DecipherClub", "0xabcd1234"]

步骤 1:确定结构体类型并可视化其头-尾布局

我们正在传递一个单一的结构体 s,这次,它包含静态动态字段:

  • id → 静态 ( uint256)
  • name → 动态 ( string)
  • data → 动态 ( bytes)

这使得整个结构体成为一个动态类型( 因为 data 是一个动态类型)。

这表明 encodeDynamicStruct 函数具有一个动态类型参数。

回想一下,编码规则表明对于动态类型变量,我们有:

  • 头的编码,它是指向尾部位置的偏移量,
  • 尾部的编码,它是参数实际值的编码。

这意味着我们最初的 头-尾 布局看起来像这样

encoding = [head(s)[tail(s)]

where s is the DynamicStruct type passed as argument

编码 head(s)

正如我们所知,动态类型的头的编码应该简单地指向尾部开始的位置。

由于此函数采用一个动态结构体,因此编码后的 calldata 的前 32 个字节是指向结构体实际编码开始位置的偏移量 - tuple(id, name, data) 布局的开头。

因此,我们有:

offset = 0x20 = 32 bytes

encode(head(s)):
[000]: 0000000000000000000000000000000000000000000000000000000000000020

编码 tail(s)

现在事情变得有趣了。

动态类型的尾部是值本身的编码。

在这种情况下,“s” 的尾部是多个参数的元组(id、name、data)。

这意味着:

  • 我们将 s.ids.names.data 视为常规函数参数
  • 并且我们应用相同的 ABI 规则:
    • 编码所有
    • 编码所有
    • 拼接在一起形成完整的结构体编码

换句话说,结构体的尾部本身就是一个微型的 ABI 编码的 payload,由每个成员的头-尾布局组成。

我们现在讨论的是嵌套编码。编码中的编码。

现在从元组的角度考虑参数,这意味着:

tuple(s.id, s.name, s.data)

or

tuple(10, "DecipherClub",  "0xabcd1234")

头-尾布局看起来像这样:

encoded = [head(id)][head(name)][head(data)][tail(name)][tail(data)]

// Note: no tail for id because id is static type

开始编码头和尾

我们现在单独编码结构体值的头-尾。

让我们计算每个头:

  • head(id) = 值 10 → 左侧填充的 uint256
encode(head(id)):
[020]: 000000000000000000000000000000000000000000000000000000000000000a**
  • head(name) = 指向 tail(name) 的偏移量
    • 请记住,name 是动态类型。
    • 动态类型的头的编码显示了该动态类型的值所在的位置的偏移量。
    • 要计算 tail(name) 的偏移量,请记住:
      • 结构体的头(id、name、data)占据 3 × 32 = 96 字节
      • 并且 name 之前的参数是静态类型 - 因此我们不需要为其尾部保存任何空间(因为静态类型没有任何尾部
      • 因此,tail(name) 从结构体编码开始的 96 字节后开始,即十六进制中的 0x60

编码为:

encode(head(name)):
[040]: 0000000000000000000000000000000000000000000000000000000000000060
  • head(data) = 指向 tail(data) 的偏移量 这意味着,head(data) 将包含 0xa0 作为偏移量。
    • data 也是动态类型。(按照之前的相同步骤
    • head(data) 应该给我们从哪里开始 tail(data) 的偏移量(位置)。
    • 要计算 head(data) 的偏移量,请记住:
      • 结构体的头(id、name、data)占据 3 × 32 = 96 字节。
      • data 之前的参数是:
        • id → 没有尾部。
          • 存储字符串的长度
          • 字符串的编码值
        • 这意味着可以存储 data 的尾部的总空间是:
          • 96 字节(头)+ 64 字节 = 160 字节 = 0xa0

name → 字符串类型,它将占用两个 32 字节的槽:

请记住,动态类型存储它们的长度 + 编码数据。

encode(head(data)):
[060]: 00000000000000000000000000000000000000000000000000000000000000a0

我们现在完成了 head 编码,所以让我们继续进行尾部编码。

  1. 编码 tail(id)
    1. id 是一个静态 uint256 类型
    2. 因此,id 将没有尾部。
  2. 编码 tail(name):
    1. name 是一个字符串,它是一个动态类型。
    2. 动态类型存储它们的长度和编码值。
    3. 作为参数提供的字符串是:"DecipherClub"
      • 长度:12 字节或十六进制的 0xc
      • 编码值:4465636970686572436C7562在此处验证 here )
    4. 因此,tail(name) 的编码将是:
encode(tail(name)):
 [080]: 000000000000000000000000000000000000000000000000000000000000000c
 [0a0]: 4465636970686572436c75620000000000000000000000000000000000000000
  1. 编码 tail(data)
    1. data 是一个字节,它是一个动态类型。
    2. 与 name 类似,这也将存储长度 + 编码值。
    3. 提供的字节参数是:"0xabcd1234"
      1. 长度:4 字节 → 编码为:
      2. 该值已经提供,即 - abcd1234
    4. 因此,tail(data) 的编码将存储为:
encode(tail(name)):
 [0c0]: 0000000000000000000000000000000000000000000000000000000000000004
 [0e0]: abcd123400000000000000000000000000000000000000000000000000000000

最后一步:组合所有内容

markdown
CopyEdit
 Function Selector: 2b01f4b4
 ------------
[000]: 0000000000000000000000000000000000000000000000000000000000000020   ← 顶层偏移量
[020]: 000000000000000000000000000000000000000000000000000000000000000a   ← id
[040]: 0000000000000000000000000000000000000000000000000000000000000060   ← 指向 tail(name) 的偏移量
[060]: 00000000000000000000000000000000000000000000000000000000000000a0   ← 指向 tail(data) 的偏移量
[080]: 000000000000000000000000000000000000000000000000000000000000000c   ← tail(name) 长度
[0a0]: 4465636970686572436c75620000000000000000000000000000000000000000   ← tail(name) 数据
[0c0]: 0000000000000000000000000000000000000000000000000000000000000004   ← tail(data) 长度
[0e0]: abcd123400000000000000000000000000000000000000000000000000000000   ← tail(data) 数据

c. 编码具有嵌套动态类型的结构体

这是我们目前最先进的结构体——它包括了动态类型 内部的 动态数组

但是,我们将遵循类似的规则来编码它。

struct SuperDynamicStruct {
    uint256 id;
    string label;
    address[] signers;
    bytes metadata;
}

function encodeSuperDynamicStruct(SuperDynamicStruct memory s) public pure returns (bytes memory) {
    return msg.data;
}

// Arguments: [99, "DecipherClub", [0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835Cb2], "0xbeefcafebabe"]

步骤 1:确定结构体类型并可视化其头-尾布局

该结构体包含:

  • id → 静态 ( uint256)
  • label → 动态 ( string)
  • signers → 动态 ( address[])
  • metadata → 动态 ( bytes)

→ 因为它包含动态字段,所以该结构体是一个 动态类型

初始的头-尾布局如下所示:

encoding = [head(s)[tail(s)]

where s is the SuperDynamicStruct type passed as argument

遵循我们之前为“DynamicStruct”遵循的类似规则集,我们有

前 32 个字节表示结构体编码开始处的 偏移量

encode(head(s)):
[000]: 0000000000000000000000000000000000000000000000000000000000000020

步骤 2:将结构体的尾部视为一个元组

一旦我们跳转到该偏移量,我们就进入了结构体主体,从概念上讲:

encode(tail(s)) = encode( tuple(s.id, s.label, s.signers, s.metadata) )

or

encode( tuple(99, "DecipherClub", [address, address], 0xbeefcafebabe) )

我们现在再次应用我们的头-尾逻辑:

encoded = [head(id)][head(label)][head(signers)][head(metadata)][tail(label)][tail(signers)][tail(metadata)]

这意味着现在我们需要单独解决所有这些问题才能获得最终的编码值。

但是,嘿,我们之前已经做过了。没大不了的,anon.

步骤 3:编码头

  • head(id) → 99 → 静态 → 左填充 uint256:
[020]: 0000000000000000000000000000000000000000000000000000000000000063
  • head(label) → 应该提供到 tail(label) 的偏移量
    • label 是一个动态类型,它的头部编码应该显示尾部从哪里开始。
    • 为了计算偏移量,我们有:
      • 4 个不同的头,每个头 32 字节 = 4 × 32 字节 = 128 字节
    • 因此,tail(label) 从字节 0x80 (128) 开始

head(label) → 应该提供到 tail(label) 的偏移量

注意:label 之前的参数是静态类型 - 因此我们不需要为其尾部保存任何空间(因为静态类型没有任何尾部

encode(head(label)):
[040]: 0000000000000000000000000000000000000000000000000000000000000080
  • head(signers) → 到 tail(signers) 的偏移量
    • 现在我们有了一个动态数组。
    • 要计算其偏移量,我们有:
      • 4 个不同的头,每个头 32 字节 = 4 × 32 字节 = 128 字节
      • signers 之前的参数是 id 和 label。
      • 虽然 id 是静态的并且没有任何尾部,但是 label 参数确实有一个尾部,它占用两个 32 字节的槽(用于长度和编码值
      • 因此,所需的总空间变为 = *128 字节 + 2 32(用于 label 的尾部)= 192 字节或** 0xc0
    • 这意味着 head(signers) 将包含 0xc0 作为偏移量
encode(head(signers)):
[060]: 00000000000000000000000000000000000000000000000000000000000000c0
  • head(metadata) → 到 tail(metadata) 的偏移量
    • 接下来是 metadata 的头,它再次是一个动态类型。
    • 为了计算偏移量,我们有:
      • 4 个不同的头,每个头 32 字节 = 4 × 32 字节 = 128 字节
      • signers 之前的参数是 id 和 label 以及 signers 数组:
        • id 不占用空间,因为没有尾部。
        • label 占用两个 32 字节的槽用于其长度和值。
        • signers 数组 将占用三个 32 字节的槽,用于以下内容:
          • 存储它的长度
          • 存储 signers[0] 的第一个地址
          • 存储 signers[1] 的第二个地址
      • 因此,所需的总空间变为 = 128 字节 + 64 字节(用于 tail(label))+ 96 字节(用于 tail(signers))
      • 所需的总空间(以字节为单位)= 288 字节或 0x120 (以十六进制表示)
    • 这意味着,head(metadata) 将包含 0x120 作为其偏移量。
[080]: 0000000000000000000000000000000000000000000000000000000000000120

到目前为止,我们已经包含了所有的头部编码,并且我们的整体 calldata 看起来像这样:

Function Selector: d291d14f
----------------------------
[000]: 0000000000000000000000000000000000000000000000000000000000000020   ← head(s)
[020]: 0000000000000000000000000000000000000000000000000000000000000063   ← id
[040]: 0000000000000000000000000000000000000000000000000000000000000080   ← 到 label 的偏移量
[060]: 00000000000000000000000000000000000000000000000000000000000000c0   ← 到 signers 的偏移量
[080]: 0000000000000000000000000000000000000000000000000000000000000120   ← 到 metadata 的偏移量

步骤 4:编码尾部

现在是时候编码所有尾部了。

  1. 编码 tail(id)
    1. id 是 uint256 类型,它是静态的
    2. 因此,id 将没有尾部。
  2. 编码 tail(label)
  • 字符串 = "DecipherClub"
  • UTF-8 = 4465636970686572436C7562(12 字节)
  • 填充 → 总共 32 字节

我们之前已经做过了。

encode(tail(label)):
[0a0]: 000000000000000000000000000000000000000000000000000000000000000c
[0c0]: 4465636970686572436c75620000000000000000000000000000000000000000
  1. tail(signers)
  • 地址数组 = 长度 2
  • 每个地址为 20 字节,右对齐到 32 字节
[0e0]: 0000000000000000000000000000000000000000000000000000000000000002
[100]: 0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4
[120]: 000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb2
  1. 编码 tail(metadata)
    1. 字节 = 0xbeefcafebabe = 6 字节
    2. 该值已经作为 beefcafebabe 提供
encode(tail(metadata)):
[140]: 0000000000000000000000000000000000000000000000000000000000000006
[160]: beefcafebabe0000000000000000000000000000000000000000000000000000

最后一步:组合所有内容

Function Selector: d291d14f
----------------------------
[000]: 0000000000000000000000000000000000000000000000000000000000000020   ← head(s)
[020]: 0000000000000000000000000000000000000000000000000000000000000063   ← id
[040]: 0000000000000000000000000000000000000000000000000000000000000080   ← 到 label 的偏移量
[060]: 00000000000000000000000000000000000000000000000000000000000000c0   ← 到 signers 的偏移量
[080]: 0000000000000000000000000000000000000000000000000000000000000120   ← 到 metadata 的偏移量
[0a0]: 000000000000000000000000000000000000000000000000000000000000000c   ← tail(label) 长度
[0c0]: 4465636970686572436c75620000000000000000000000000000000000000000   ← tail(label) 值
[0e0]: 0000000000000000000000000000000000000000000000000000000000000002   ← tail(signers) 长度
[100]: 0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4   ← signer[0]
[120]: 000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb2   ← signer[1]
[140]: 00000000000000000000000000000000000000000
  • 原文链接: decipherclub.com/solidit...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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