智能合约笔记

智能合约

前言

以太坊是一个基于区块链技术的开源平台,它允许开发者构建和部署智能合约以及分散式应用程序(DApp)

以太坊的区块链提供了一个去中心化的环境,使得交易和数据可以在整个网络上进行验证和记录,而不需要信任中心化的机构。

合约是以太坊应用程序的基本构建块,所有变量和函数都属于合约。

智能合约是以太坊的重要特性之一,它们是一种自动执行的合约代码,通过以太坊网络上的节点来执行,并且具有在没有第三方干预的情况下执行交易和合约的能力。

此行之后的以太坊可能指代以太坊区块链以太坊网络以太坊开发平台,有时需加以区分。

而区块链一般仅指以太坊区块链

如果在此对以太坊没有概念,可以粗略将以太坊理解为一个世界计算机,你可以在编写智能合约代码在这个世界计算机中运行。

但是需要注意的是,这个世界计算机所有人都可以使用、维护和参与其中,所以它是去中心化的,所有在这上面的交互(交易)、程序(智能合约代码)和状态都可以被所有人查看。

同时由于这个世界计算机由所有人参与其中,所以与其进行交互时需要给予一定的使用费(gas),这同时也是以太坊中确保安全性和有效性的关键之一。

一个公开的计算机需要付费使用,费用的一部分是给予维护者的奖励,这样将鼓励更多人参与对计算机进行维护。

有关区块链、以太坊等相关知识这里不再赘述。

正如在你的计算机中运行 Java 代码需要使用 Java 虚拟机(JVM)一样,以太坊这个世界计算机想要运行智能合约代码时,需要使用以太坊虚拟机(EVM)

但需要注意的是,以太坊虚拟机是基于栈的,这与 Python 的运行机制是类似的。

如果不理解什么是基于栈的语言,什么是基于寄存器的语言,可以忽略。

EVM 与 JVM 类似,其直接运行的是字节码(bytecode),但人类手编虚拟机字节码显然是过于夸张了,所以与 Java 类似的,一般的智能合约程序是使用智能合约语言进行编写,再由编译器进行编译成虚拟机可识别的字节码。

其中有两大智能合约高级语言:Vyper 和 Solidity。

Vyper 是 Pythonic 的智能合约语言,在这里强烈建议智能合约开发初学者优先学习,因为其语法简单易懂(前提是有一定 Python 和 C 基础),并且有优秀的教程和文档。

优秀的 Vyper 宝可梦教程:https://learn.vyperlang.org/#/lessons.html

优秀的 Vyper 官方文档:https://docs.vyperlang.org/en/latest/index.html

唯一的缺点可能只有暂时没有中文文档。

在学习了 Vyper 后,最好学习了解 Solidity,因为这是以太坊开放平台的核心语言,由大部分以太坊开发人员参与维护,可以从 Solidity 的设计中窥见部分以太坊设计理念,对以太坊有更深刻的理解。

需要注意的是,Solidity 受到 C 和 C++ 语法的深刻影响。为了实现更多的功能,开发人员在 Solidity 中引入了许多未经合理设计的语法,并且其中一部分语法与 EVM 运行机理有一定联系,学习起来可能较为困难。

个人认为,Solidity 的语法设计过于繁杂,导致代码的可读性较低。可以将 Solidity 理解为普通计算机语言中的 C++,因为它们都遵循了零成本抽象的理念。而 Vyper 则更类似于普通计算机语言中的 Python。

随着 Vyper 的发展成熟,预计其在应用方面会比 Solidity 更加广泛。

不过,Solidity 与 Vyper 在很大程度上有着强烈的相似之处。掌握了 Vyper 后,学习 Solidity 的难度会降低。

与 C 类似,Solidity 进行优化时通常是在汇编层面进行的。为了能够对代码进行优化,Solidity 引入了一种中间语言,即 Yul。Yul 可以被看作是 Solidity 与 EVM 字节码之间的桥梁,因此可以被称为 EVM 的汇编语言。

同时 Solidity 是支持代码中内联汇编的,语法与 C 内联汇编类似。

Yul 相较于 Solidity 是一种更加低级的语言,它内置了许多功能,这也是 Solidity 的优势之一。通过内联汇编的方式,Solidity 可以利用 Yul 实现更丰富的功能,从而提高了代码的灵活性和可扩展性。

所以如果碰到 Solidity 无法实现的功能,可以考虑学习 Yul。

但是 Yul 并不是十分优化的,因为它内置了许多功能,可以参考文章 https://blog.chain.link/solidity-vs-vyper/,可以考虑学习 Huff 来进一步优化代码。

编写和部署智能合约代码时,通常会使用 Remix IDE 进行开发和测试。

而在与区块链交互时,通常会使用 Typescript 的 web3js 框架。

虽然有 Python 版本的 web3py,但开发不完善,没能很好的用上 Python 的异步功能。

Remix 支持注入脚本运行 web3js 代码,脚本代码框架如下

1
2
3
(async () => {
// to do something
})()

在注释处编写代码,Remix 内置对象 web3,它将绑定 DEPLOY & RUN TRANSACTIONS 插件的部分信息,无需手动绑定,便于测试和交互。

开发环境

个人而言用得更顺手的是 Remix,但 Remix 注重合约开发、合约部署和合约间的交互,在面对一些外部与合约的交互时在测试上有些鸡肋。

故除了在线环境 Remix 外,还推荐本地环境 Hardhat。

Hardhat

安装

新建文件夹,初始化 node 环境

1
2
3
mkdir hardhat
cd hardhat
npm init

安装 Hardhat 和搭配使用的插件与包

1
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-web3-v4 @nomiclabs/hardhat-vyper web3

随后进行 Hardhat 环境的初始化

1
npx hardhat init

推荐选择 Create a TypeScript project,即

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ npx hardhat init
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888

👷 Welcome to Hardhat v2.20.1 👷‍

? What do you want to do? …
Create a JavaScript project
❯ Create a TypeScript project
Create a TypeScript project (with Viem)
Create an empty hardhat.config.js
Quit

其余选项默认即可。

在初始化结束后,会得到 hardhat.config.ts 和目录若干。

hardhat.config.ts 相当于 vue 中的 main.ts,是主入口,需要填写一些代码内容,可能的内容如下

1
2
3
4
5
6
7
8
9
10
11
12
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-web3-v4";
import "@nomiclabs/hardhat-vyper";

// Specify the compiler version, which needs to be changed in real time
const config: HardhatUserConfig = {
solidity: "0.8.24",
vyper: "0.3.8"
};

export default config;

目录下的初始文件删去即可。

默认生成 contracts/Lock.solscripts/deploy.tstest/Lock.ts

个人喜好的开发环境是以 Vyper 作为智能合约编译器,主要使用 Web3js 与以太坊进行交互。

入门

Hardhat 的文件结构如下

hardhat可能的目录结构

其中

  • artifacts
    • 包含的是合约编译后的结果,在 artifacts/contracts/name.vy/name.json 中,其中 name.vy 与合约文件同名。
    • json 中主要包含
      • contractName - 带有合约名称的字符串
      • abi - 合约的 ABI JSON
      • bytecode - 合约未部署的字节码(即包含构造函数),如果编译失败结果为 "0x"
      • deployedBytecode - 合约部署后的字节码(即不包含构造函数),如果编译失败结果为 "0x"
      • linkReferences - 未部署字节码的引用对象,由 solc 提供
      • deployedLinkReferences - 部署字节码的引用对象,由 solc 提供
  • contracts
    • 存放合约代码,在这个文件夹下的代码都会被 Hardhat 自动编译(运行任务时)
  • scripts
    • 规范性的存放 ts 脚本的文件夹
  • test
    • 存放测试脚本的文件夹,主要由 Mocha 代码,内置任务 test 会自动运行其中的所有测试

Hardhat 以任务驱动为主,运行方式是 npx hardhat <task name>

compile 是一个内置的任务,用于编译合约,一个可能的运行结果为

1
2
3
4
$ npx hardhat compile
Nothing to compile
No need to generate any newer typings.
Vyper compilation finished successfully

test 同样是一个内置的任务,用于自动化测试,(如果可以的话)会自动运行存放在 test 文件夹下的所有脚本。

run 用于直接运行脚本,与任务不同,任务会预先导入一些必要环境,而 run 不会,需要手动导入甚至需要手动编写某些复杂的部分。

可以通过 npx hardhat 的命令获取所有可运行的任务。

任务

其中 Hardhat 最重要的功能在于自定义任务,使得合约测试流程简化。

一般任务在 hardhat.config.ts 中编写,从 hardhat/config 导入 task,一个最简单的任务是

1
task("example")

这样就定义了一个名字叫 example 的任务,此时通过 npx hardhat 可以看到可运行的任务中出现了 example

但别的任务都有描述,所以我们也需要一个描述

1
task("example", "这是一个示例")

有了描述后,我们想让任务做些什么,这时候需要一个动作(Action)

1
2
3
4
task("example", "这是一个示例").setAction(async (_, { web3 }) => {
const [deployer, ] = await web3.eth.getAccounts()
console.log(deployer)
})

这样一个最简单的任务就编写完成了,更多的可以参考官方文档

陷阱

隐私性

所谓private

EVM 是面向合约的,其中的合约可以被视为另一种意义上的对象。

对于一个已部署的合约,其状态在以太坊网络的存储是可见的,类似于计算机内存中对象的数据可见性。

然而,需要注意的是,以太坊中的智能合约存储的可见性仅针对于 EVM 外部。在 EVM 内部,有严格的执行要求阻止合约之间访问其他合约的内部属性。

特别地,Vyper 和 Solidity 针对于 public 属性会默认编译其 getter 方法便于外部访问。

比如说 Vyper 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
#pragma version ^0.3.0

qsdz: uint256

@external
def __init__():
self.qsdz = 123456

@external
@pure
def hello() -> String[32]:
return "world"

使用 Remix 部署合约,可以看见 qsdz 属性是没有 getter 方法的。

private_member

需要注意的是,不知道是否是 Remix 的原因,需要与合约进行过一次交易后才可以获取到存储。

但是我们可以通过 web3js 框架在外部与区块链交互,获取合约的存储。

在这里演示使用 Remix 注入脚本的方式执行

1
2
3
4
5
6
(async () => {
// 这里的address是合约地址
const address = '0xf8e81D47203A594245E36C48e151709F0C19fBe8'
const slot = await web3.eth.getStorageAt(address, 0)
console.log("slot:", slot)
})()

可以获得回显

1
2
slot:
0x01e240

请不要在区块链上存储敏感信息。

存储插槽

在 EVM 中,每个合约都有专属于自己的存储(storage),合约存储被分为 \(2^{256}\) 个插槽(slot),每个插槽 32 字节(256位,相当于 uint256),插槽是连续分布的,可以由索引引用。

需要注意的是,虽然理论上如此,但实际上编译时会对合约变量进行空间上的优化,使得存储成本更小。

无论对于 Solidity 还是 Vyper,constant 都将作为编译期常量。

Solidity

规则

官方说明:https://docs.soliditylang.org/zh/v0.8.20/internals/layout_in_storage.html

合约的状态变量以一种紧凑的方式存储, 这样多个值有时会使用同一个存储槽。 除了动态大小的数组和映射之外, 数据是被逐项存储的,从第一个状态变量开始, 它被存储在槽 0 中。

对于每个变量, 根据它的类型确定一个字节的大小。如果可能的话,需要少于32字节的多个连续项目被打包到一个存储槽中, 根据以下规则:

  • 存储插槽的第一项会以低位对齐(即右对齐)的方式储存。
  • 值类型只使用存储它们所需的字节数。
  • 如果一个值类型不适合一个存储槽的剩余部分,它将被存储在下一个存储槽。
  • 结构和数组数据总是从一个新的存储槽开始,它们的项根据这些规则被紧密地打包。
  • 结构或数组数据之后的变量总是开辟一个新的存储槽。
存储紧缩

可以尝试以下测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
uint128 private qsdz;
uint128 private yyds;

constructor() {
qsdz = 0x000102030405;
yyds = 0x101112131415;
}

function hello() external pure returns (string memory) {
return "world";
}
}

获取索引为 0 的插槽得到回显

1
2
slot:
0x10111213141500000000000000000000000102030405
定长数组类型

对于定长数组类型,相当于连续存储的值类型。

可以尝试以下测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
uint128 private qsdz;
uint128 private yyds;

constructor() {
qsdz = 0x000102030405;
yyds = 0x101112131415;
}

function hello() external pure returns (string memory) {
return "world";
}
}

分别获取索引为 0、1、2 的插槽,得到结果

1
2
3
4
5
6
slot:
0x0200000000000000000000000000000000
slot:
0x0600000000000000000000000000000004
slot:
0x08
不定长数组类型

对于不定长数组类型,插槽存储数组大小,但数组数据从 keccak256(position) 开始,其中 position 是插槽索引,排列顺序与定长数组类型一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
uint128[] private qsdz;

constructor() {
qsdz = new uint128[](5);
for (uint128 i = 0; i < qsdz.length; i++) {
qsdz[i] = 2 * i;
}
}

function hello() external pure returns (string memory) {
return "world";
}
}

一个可能的 ts 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(async () => {
// 这里的address是合约地址
const address = '0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8'
// 数组变量所在的插槽索引
const slotNumber = 0
// 这是数组的偏移量
const offset = BigInt(1)
const length = await web3.eth.getStorageAt(address, slotNumber)
console.log("length:", length)
// 计算得到的数组数据插槽索引
const idx = BigInt(web3.utils.soliditySha3({type: "uint", value: slotNumber})) + offset
// 插槽内容
const arraySlot = await web3.eth.getStorageAt(address, "0x" + idx.toString(16))
console.log(arraySlot)
})()
映射类型

对于映射类型,插槽本身没有内容,对于每一个键 key,其值存储在 keccak256(h(key)+position),其中 position 是插槽索引,+ 表拼接,h 是与键的类型有关的映射函数。

  • 对于值类型, 函数 h 将与在内存中存储值的相同方式(encodePacked)来将值填充为32字节。
  • 对于字符串和字节数组, h(k) 只是未填充的数据。

实际上仅需使用 web3.utils.soliditySha3 函数即可获取插槽索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
mapping (uint256 => bytes32) private qsdz;

constructor() {
qsdz[12345] = "yyds";
}

function hello() external pure returns (string memory) {
return "world";
}
}

一个可能的 ts 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
(async () => {
// 这里的address是合约地址
const address = '0xd9145CCE52D386f254917e481eB44e9943F39138'
// 这是映射键的值
const key = 12345
// 映射变量所在的插槽索引
const slotNumber = 0
// 计算映射值的插槽索引
const idx = web3.utils.soliditySha3({type: "uint256", value: key},
{type: "uint", value:slotNumber})
const slot = await web3.eth.getStorageAt(address, idx)
console.log(slot)
})()
bytes 和 string

bytes 类型用来存储任意字节序列,不做编码假设。在存储时,它们以原始的字节序列形式存储。

string 类型用来存储 UTF-8 编码的 Unicode 字符序列。在存储时,字符串会被转换为 UTF-8 编码的字节序列,并以这种形式存储。

所以本质上它们并无不同,仅仅和编译解析有关。

如果数据最多只有 31 字节长, 元素被存储在高阶字节中(左对齐),最低阶字节存储值 length * 2。 对于存储数据长度为 32 或更多字节的字节数,插槽存储 length * 2 + 1, 数据照常存储在 keccak256(position)

这意味着可以通过检查最低位是否被设置来区分短数组和长数组: 短数组(未设置)和长数组(设置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
bytes private qsdz;
bytes private yyds;

constructor() {
qsdz = "01234567890123456789";
yyds = "0123456789012345678901234567890123456789";
}

function hello() external pure returns (string memory) {
return "world";
}
}

一个可能的 ts 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
(async () => {
// 这里的address是合约地址
const address = '0xd9145CCE52D386f254917e481eB44e9943F39138'
// 变量所在的插槽索引
const slotNumber = 1
const slot: string = await web3.eth.getStorageAt(address, slotNumber)
const slotValue = BigInt(slot)
const lengthMask = Number(slotValue & BigInt(0xff))
let value: string = ""
if (lengthMask % 2 == 0) {
// 短数组处理
const data = slot.slice(0, lengthMask + 2)
value = web3.utils.hexToString(data)
} else {
// 长数组处理
let length = (lengthMask - 1) / 2
let idx = web3.utils.soliditySha3({type: "uint", value: slotNumber})
// 按定长数组方式提取数据
while (length > 0) {
const data = await web3.eth.getStorageAt(address, idx)
console.log(data)
value += web3.utils.hexToString(data)
length -= 32
// 迭代插槽索引
idx = BigInt(idx)
++idx
idx = "0x" + idx.toString(16)
console.log(idx)
}
}
console.log(value)
})()

Vyper

与 Solidity 不同的是,Vyper 暂时还未做出存储紧缩的优化,一个值至少占用一个槽。

即使是 struct 也不会进行存储紧缩。

定长数组类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma version ^0.3.0

ARRAY_LENGTH: constant(uint256) = 5
qsdz: uint128[ARRAY_LENGTH]

@external
def __init__():
for i in range(ARRAY_LENGTH):
self.qsdz[i] = convert(i * 2 + 1, uint128)

@external
@pure
def hello() -> String[32]:
return "world"

一个可能的 ts 代码如下

1
2
3
4
5
6
7
(async () => {
// 这里的address是合约地址
const address = '0xd9145CCE52D386f254917e481eB44e9943F39138'
const slot: string = await web3.eth.getStorageAt(address, 0)
console.log("slot")
console.log(slot)
})()

在这里本人出现了一点错误,如果索引值为 0 的插槽值为 0 会导致 getStorageAt 阻塞,暂时未了解原因。

Bytes 和 String

特别地,在 Vyper 里,Bytes 和 String 必须是定长数组,存储至少需要两个插槽,前一个插槽存储字符串(字节流)大小,之后存储字符串(字节流)。

1
2
3
4
5
6
7
8
9
10
11
12
#pragma version ^0.3.0

qsdz: String[32]

@external
def __init__():
self.qsdz = "yyds"

@external
@pure
def hello() -> String[32]:
return "world"

一个可能的 ts 代码如下

1
2
3
4
5
6
7
8
9
10
(async () => {
// 这里的address是合约地址
const address = '0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8'
const length = BigInt(await web3.eth.getStorageAt(address, 0))
console.log("length:", length)
const content = await web3.eth.getStorageAt(address, 1)
console.log(content)
// const value = web3.utils.hexToString(content)
// console.log(value)
})()
动态数组

Vyper 的动态数组的实现是通过定长数组实现的,数据排布格式与定长数组是类似的。

映射类型

在 Vyper 的映射类型为 HashMap

1
2
3
4
5
6
7
8
9
10
11
12
#pragma version ^0.3.0

qsdz: HashMap[uint256, String[32]]

@external
def __init__():
self.qsdz[12345] = "yyds"

@external
@pure
def hello() -> String[32]:
return "world"

对于映射值的索引计算与 Solidity 是相反的,公式为 keccak256(position+h(key))

一个可能的 ts 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
(async () => {
// 这里的address是合约地址
const address = '0xd9145CCE52D386f254917e481eB44e9943F39138'
// 这是映射键的值
const key = 12345
// 映射变量所在的插槽索引
const slotNumber = 0
// 计算映射值的插槽索引
const idx = web3.utils.soliditySha3({type: "uint", value:slotNumber},
{type: "uint256", value: key})
const slot = await web3.eth.getStorageAt(address, idx)
console.log(slot)
})()

随机性

原理

随机数在以太坊中是十分重要的,因为其中大部分智能合约都可以归类为游戏,而游戏往往依赖于随机性,例如最简单的猜硬币正反面。

由于以太坊是一个确定性的图灵机,不涉及固有的随机性,即在合约代码运行之前,以太坊中的状态都是既定的,可预测的。

同时,对于区块链而言,大多数矿工在评估交易时必须获得相同的结果才能达成共识,共识也是区块链技术的支柱之一,随机性意味着所有节点之间不可能达成一致。

另外,对于合约而言,合约的内部状态以及区块链的整个历史都是公共可见的。

最容易想到的随机数来源是区块时间戳(block.timestamp),但问题是区块时间戳是受到矿工控制的,使得随机数可预测。

Vyper 全局变量

变量名 类型
block.coinbase address 挖出当前区块的矿工地址
block.difficulty uint256 当前块的难度(EVM < Paris),与PoW有关系
block.prevrandao uint256 当前区块所依赖的前一个区块的随机数,与PoS有关系
block.number uint256 当前区块号
block.prevhash bytes32 等价于 blockhash(block.number - 1)
block.timestamp uint256 自 unix epoch 起始到当前区块以秒计的时间戳
chain.id uint256 当前链的ID
msg.data Bytes 完整的 calldata
msg.gas uint256 剩余的 gas
msg.sender address 消息发送者(当前调用)
msg.value uint256 随消息发送的 wei 的数量
tx.origin address 交易发起者(完全的调用链)
tx.gasprice uint256 随消息发送的 wei 的数量

Solidity 全局变量

变量名 类型
block.coinbase address payable 挖出当前区块的矿工地址
block.basefee uint256 当前区块的基本费用 ( EIP-3198EIP-1559
block.difficulty uint256 当前块的难度(EVM < Paris),与PoW有关系
block.prevrandao uint256 当前区块所依赖的前一个区块的随机数,与PoS有关系
block.number uint256 当前区块号
block.prevhash bytes32 等价于 blockhash(block.number - 1)
block.gaslimit uint256 当前区块 gas 限额
block.timestamp uint256 自 unix epoch 起始到当前区块以秒计的时间戳
block.chainid uint256 当前链的ID
msg.data bytes calldata 完整的 calldata
msg.sig bytes4 calldata 的前 4 字节(也就是函数标识符)
gasleft() uint256 剩余的 gas
msg.sender address 消息发送者(当前调用)
msg.value uint256 随消息发送的 wei 的数量
tx.origin address 交易发起者(完全的调用链)
tx.gasprice uint256 随消息发送的 wei 的数量

在 Solidity 中,block.difficultyblock.prevrandao 是一个变量,以太坊1.0是PoW,以太坊2.0是PoS,difficulty 命名被重用。EIP-4399

示例

一个比较经典的合约随机数生成算法如下

1
2
3
4
5
6
#pragma version ^0.3.0

@external
@view
def getRandomNumber() -> bytes32:
return keccak256(_abi_encode(block.prevhash, block.timestamp))

可以在本地电脑中安装 vyper 生成 interface:

vyper -f external_interface example.vy

1
2
3
# External Interfaces
interface Example:
def getRandomNumber() -> bytes32: view

我们可以写出以下攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
#pragma version ^0.3.0

# External Interfaces
interface Example:
def getRandomNumber() -> bytes32: view

@external
@view
def guess(c: address) -> bool:
number: bytes32 = Example(c).getRandomNumber()
myNumber: bytes32 = keccak256(_abi_encode(block.prevhash, block.timestamp))
return number == myNumber

这是因为同一个交易下的区块状态一定是相同的。

对于 Solidity 的随机数合约是一样的

1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
function getRandomNumber() external view returns (bytes32) {
return keccak256(abi.encode(blockhash(block.number - 1), block.timestamp));
}
}

攻击合约一致。

Chainlink VRF(Verifiable Random Function) 是一种可证明公平且可验证的随机数生成器(RNG)。

通过以太坊的预言机(Oracle)模式可以接入 Chainlink VRF 以获取相对安全的随机数。Chainlink VRF 提供了一种可验证的随机数生成机制,可以在智能合约中获取随机数,同时保证随机数的公正性和不可预测性。

可以参考链接:https://docs.chain.link/vrf/v2/direct-funding/examples/test-locally

重入

在一个合约 A 与另一个合约 B 进行交互时,此时任何形式的以太币转移都会将 EVM 控制权交给合约 B。

任何形式的以太币转义包括但不限于转账(sendtransfer)和函数调用。

比如说一个拥有存款、取款的合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma version ^0.3.0

balances: public(HashMap[address, uint256])

@external
@payable
def deposit():
self.balances[msg.sender] += msg.value

@external
def withdraw():
assert self.balances[msg.sender] > 0, "No balance"
send(msg.sender, self.balances[msg.sender])
self.balances[msg.sender] = 0

很简单的一份代码,使用一个 HashMap 保存每个人账户的余额,使用 deposit 进行存款,使用 withdraw 进行取款。

但需要注意的是,self.balances[msg.sender] = 0send 前后会出现很大区别,因为如果 msg.sender 是合约,send 函数会去寻找该合约的 fallback 函数(在 Vyper 中是 __defalut__)进行处理,这意味着在金额转移时,控制权交给了另一个合约

此时如果重新调用 withdraw 函数,断言验证是通过的,意味着我们重入(Re-entrancy)了。

假设我们写下如下攻击代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#pragma version ^0.3.0

# External Interfaces
interface Example:
def deposit(): payable
def withdraw(): nonpayable
def balances(arg0: address) -> uint256: view

victim: address

@external
def __init__(_victim: address):
# Init victim contract address
self.victim = _victim

@external
@payable
def attack():
# Need some initial funds
assert msg.value > 0, "No value"
# First deposit some funds
Example(self.victim).deposit(value=msg.value)
# Then withdraw to active recursion
Example(self.victim).withdraw()

@external
def withdraw():
# Get the all ethers
send(msg.sender, self.balance)

@external
@payable
def __default__():
# if victim contract has remain ehters, GET!
if self.victim.balance >= msg.value:
Example(self.victim).withdraw()

在攻击开始,我们首先要使用该合约在银行(Example)中存储余额,才可以进行 withdraw

通过调用 withdraw 函数,使得其触发该合约的 __default__ 函数,此时合约再次调用银行(Example)的 withdraw 函数,在 self.balances[msg.sender] = 0 没有执行前,断言都将成立,故此时递归式获取到所有目标合约的余额,完成攻击。

这里需要注意,msg 相关的值式与调用者绑定的,即在这里的 msg.senderAttacker 合约本身。

不过,当你进行尝试时,会发现产生报错,这是由于 gas 限制导致的!

为了防止重入攻击,以太坊官方对 send 做了限额,且不继承交易的 gas,仅可使用 2300 gas(津贴形式),这使得几乎除了输出日志外的其他操作都将失败(会产生revert)。

但是这是十分不实际的,比如说一个自动化交易、投资的智能合约(DeFi)需要在退款(或投资失败)时进行二次投资,此时合约需要大量的 gas 来做操作,send 的限制会阻碍到合约的扩展性。

在 Solidity 遇到这种情况会建议使用 call 直接发送 ether,而 Vyper 为 send 开放了 gas 参数,可以为 send 附加额外的 gas 以便于智能合约执行必要操作。

Vyper 也有类似于 callraw_call 函数。

例如一个存在重入漏洞的代码是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma version ^0.3.0

balances: public(HashMap[address, uint256])

@external
@payable
def deposit():
self.balances[msg.sender] += msg.value

@external
def withdraw():
assert self.balances[msg.sender] > 0, "No balance"
send(msg.sender, self.balances[msg.sender], gas=msg.gas)
# raw_call(msg.sender, b"", value=self.balances[msg.sender])
self.balances[msg.sender] = 0

Solidity 在开发层面上需要开发者做额外操作,第一是建议使用检查-生效-交互(Checks-Effects-Interactions)模式,即 self.balances[msg.sender] = 0 写在 send 之后。

第二是建议使用哨兵模式,即写入存储一个 guard,类似于互斥锁的逻辑,保证不会被二次调用。

一般来说建议二者都是用, 因为可能还会存在跨函数重入的情况。

Vyper 与 Solidity 不同,为开发者提供了哨兵模式在语言层面的支持,使用装饰器 nonreentrant 就可以为一个函数上锁,例如

1
2
3
4
5
6
@external
@nonreentrant("withdraw")
def withdraw():
assert self.balances[msg.sender] > 0, "No balance"
send(msg.sender, self.balances[msg.sender], gas=msg.gas)
self.balances[msg.sender] = 0

这样就避免了重入。

需要注意的是,在 ^0.3.0 的版本中需要给 nonreentrant 提供一个唯一的 key,但在 ^0.4.0 后不需要。

在重入时需要注意,重入的初始资金(initial funds)不能太小,否则在没有满足退出递归的条件 self.victim.balance < msg.value 之前会耗尽 gas(或者超出了区块的 gas 限制,或者超出了 EVM 的 1024 大小的栈),使得重入失败。不过可以通过修改退出递归的条件提前结束递归。

重入最大的问题在于,攻击者仅需付出少量的资金,就可以得到大量的收获。攻击者可以通过部署多个合约的方式掏空合约资金,这是十分危险的。

Solidity 的漏洞合约可能为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Example {
mapping (address => uint256) public balances;

function deposit() external payable {
balances[msg.sender] += msg.value;
}

function withdraw() external {
require(balances[msg.sender] > 0, "No balance");
(bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
require(success);
balances[msg.sender] = 0;
}
}

如果取款函数是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma version ^0.3.0

balances: public(HashMap[address, uint256])

@external
@payable
def deposit():
self.balances[msg.sender] += msg.value

@external
def withdraw(amount: uint256):
assert self.balances[msg.sender] >= amount, "No balance"
send(msg.sender, amount, gas=msg.gas)
self.balances[msg.sender] -= amount

由于 self.balances[msg.sender] -= amount 的问题,在 Vyper 和新版本的 Solidity 中都会检查算数溢出,所以还需结合算术溢出漏洞进行攻击。

算数溢出

EVM 的栈元素只有 256 位,这意味着如果在进行算数时如果算数结果超过 256 位,那么结果存入栈时将会被截断,产生意料之外的结果。这种情况被称为算术溢出

在 Solidity 0.8.0 版本之前,所有算术溢出结果将不会被检查;在 0.8.0 以后,所有算术溢出结果将抛出 Panic 异常;在 Vyper 中所有算数溢出都将 revert。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity <0.8.0;

contract Example {
uint256 public locktime;
function assure() external payable {
require(locktime == 0, "Already assured some funds");
require(msg.value > 0, "Need some funds to assure");
locktime = block.timestamp + 1 days;
}

function delay(uint256 day) external {
locktime += day * 1 days;
}

function withdraw() external {
require(locktime < block.timestamp, "It's not time yet");
locktime = 0;
payable(msg.sender).transfer(address(this).balance);
}
}

这是一个简单的担保合同,保证资金锁定一天以上,如果需要延保还可以调用 delay 函数延迟时间。

部署合约,在这里我们可以尝试担保 1 ether

overflow

获取到时间戳 1709363247,使用 Python 计算 delay = (2 ** 256 - locktime) // oneday + 1,得到需要 delay 的天数为 1340186218024493002587627141304258192746180378074543565271499814906382180

overflow

可以发现,locktime 发生上溢,此时我们可以提前拿出担保资金,破坏了合约本来的功能。

身份验证

永远不要使用 tx.origin 做身份验证,比如说有这么一个钱包合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#pragma version ^0.3.0

owner: address

@external
def __init__():
self.owner = msg.sender

@external
def transferTo(dest: address, amount: uint256):
assert tx.origin == self.owner, "No permission"
raw_call(dest, b"", value=amount)

@external
@payable
def __default__():
pass

使用 raw_call 是为了能够使得钱包给合约转账。

如果说你向恶意合约发送了交易,恶意合约可以将交易跳转回 transferTo 函数,同时能保证断言通过。

比如说伪造一个钱包合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#pragma version ^0.3.0

# External Interfaces
interface UserWallet:
def transferTo(dest: address, amount: uint256): nonpayable
def __default__(): payable

owner: address

@external
def __init__():
self.owner = msg.sender

@external
@payable
def __default__():
UserWallet(msg.sender).transferTo(self.owner, msg.sender.balance)

当你向这个钱包合约进行转账时,它将偷偷转移你钱包里所有的余额。

清除映射

无论是 Vyper 的 HashMap 还是 Solidity 的 mapping,它们都是一种依赖于以太坊存储的数据结构,二者默认均不提供遍历功能,即并不会帮助记录键的内容。

所以在开发中需要注意,HashMap 或者 mapping 的内容将不会因为变量自身存储槽位的覆盖而清除远在他方的键值变量。

详细可以参考隐私性中的映射类型

由于 Vyper 暂时不支持用 HashMap 作为 DynArray 的类型,故这里以 Solidity 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT
pragma solidity <0.8.0;

contract Example {
mapping (string => uint256)[] statusList;

function push() external {
// 在数组末尾新增一个mapping
statusList.push();
}

function pop() external {
// 删除一个数组末尾的mapping
statusList.pop();
}

function getStatus(uint256 idx, string memory key) external view returns (uint256) {
// 根据索引和key获取状态
require(idx < statusList.length, "Index should less than length");
return statusList[idx][key];
}

function setStatus(uint256 idx, string memory key, uint256 status) external {
// 根据索引和key设置状态
require(idx < statusList.length, "Index should less than length");
statusList[idx][key] = status;
}
}

我们首先 push,得到一个新的 mapping,然后 setStatus,例如参数为 0,"qsdz",12345,此时可以通过 getStatus 查看到键值对成功写入存储。

然后 poppush,这在普通计算机平台上理应键值对就被清除了,但实际上在以太坊中,映射类型相当于永久存储,此时再次通过 getStatus 还是可以获取到刚才写入存储的键值对。

为了防止映射值被重复利用,建议在删除映射的同时清除映射,不过这需要额外的存储保存映射的所有键。

Solidity 在 GitHub 上收录了一些比较实用的 library,其中就包括了 IterableMapping,也可以自定义一个更适配的映射来清除映射。

位数据隐匿

没有完全占满 32 字节的类型可能会包含脏高位,尤其是存在于 msg.data 中的数据,既可以使用 0xff000001 也可以使用 0x00000001 作为 msg.data 提供给函数 f(uint8 x)

作为 calldata 数据,在处理后得到的 x 值将是相同的,但是这在 msg.data 中是不同的。

所以尽量避免直接操作 msg.data,例如说 keccak256(msg.data),这可能会有一定的风险存在。

如果使用 ABI 编码器的 v2 版本将会进行一定的健全性检查,防止脏高位的产生,但是可能需要额外支出一定的 gas。

Solidity 0.8.0 以后版本和 Vyper 默认使用 v2 版本的 ABI 编码器。

委托调用

calldelegatecall 都是 Solidity 的低级函数,在 EVM 中有直接对应操作的字节码。

call 用于合约与合约进行交互,正常调用合约代码时就会被翻译成 call 函数,通常用于直接调用其他合约的 fallback 函数;

delegatecall 用于合约复用其他合约代码,msg.sendermsg.valuestorage 都将继承自当前合约,正如其名委托调用,被调用者也会被称作代理(Proxy)。

二者的 revert 都不会冒泡;类型检查将被绕过;函数存在性检查将被忽略。

所以为安全起见不建议直接使用这两个低级函数调用合约函数。

相比较 call 的危险性,delegatecall 的危险性更高,最直接的就是 delegatecall 会继承 msg.sender,使得身份验证 require(msg.sender, owner) 失效。

为了提高代码复用性,增强代码可升级性和节省 gas,故通常使用 delegatecall 利用代理合约(Proxy Contract)的代码,比如说这么一个钱包代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract UserWalletLibrary {
address owner;

function initWallet() public {
owner = msg.sender;
}
}

contract UserWallet {
address public owner;
UserWalletLibrary lib;

constructor(UserWalletLibrary _lib) {
lib = _lib;
(bool success, ) = address(lib).delegatecall(abi.encodeWithSignature("initWallet()"));
require(success);
}

receive() external payable { }

fallback() external payable {
(bool success, ) = address(lib).delegatecall(msg.data);
require(success);
}
}

这样通过 UserWalletLibrarydelegatecall 调用以达到合约代码的复用性,同时在需要更新功能的时候,仅需更新并部署 UserWalletLibrary 合约即可,新的钱包直接重设 lib 变量可以达到与热更新类似的效果。

在这里需要保证 UserWalletLibraryUserWallet 的存储结构保持一致,否则可能会导致插槽访问错误。

故对于复杂存储结构的合约可能会有更高的库合约维护难度。

一切看起来都十分美好,但是在这里错误将 initWallet 变得可复用,导致外部合约可以重复调用该函数,使得钱包易主,这也是著名的 Parity Hack 攻击。

在这里需要有一个良好的开发习惯,不可复用的代码不应该写在库合约(代理合约)中。

我们可以写出攻击代码

1
2
3
4
5
6
7
8
9
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Attacker {
function attack(address victim) external {
(bool success, ) = victim.call(abi.encodeWithSignature("initWallet()"));
require(success);
}
}

将钱包合约地址填入进行攻击,钱包便直接易主。

所以开发时不要滥用 delegatecall,如果无可避免,那么在库合约中禁止出现不可复用的代码

延续上文,delegatecall 还可能产生一个开发错误,就是上下文合约中的存储结构不一致的问题。

因为这是合约而不是库,合约是会被编译成字节码的,部署后将不会发生改变,那么合约访问状态变量是被硬编码为插槽位置写死在代码里的。

那没办法嘛,代码是这样写的

当存储结构不一致时,使用 delegatecall 后的合约代码只知道自己要访问哪个插槽,而不知道自己要访问哪个变量。

比如说可能会有以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract UserWalletLibrary {
uint256 status;

function saveStatus(uint256 _status) public {
if (_status != 0) {
status = _status;
}
}
}

contract UserWallet {
address public owner;
uint256 status;
address lib;

constructor(address _lib) {
lib = _lib;
owner = msg.sender;
}

function saveStatus(uint256 _status) external {
(bool success, ) = lib.delegatecall(abi.encodeWithSignature("saveStatus(uint256)", _status));
require(success);
}

receive() external payable { }
}

由于 UserWalletLibrarystatus 在插槽索引 0 处,而 UserWalletowner 也在插槽索引 0 处,故 UserWallet.saveStatus 将修改 owner 而不是 status

故这种编程方式是不推荐的,更推荐用结构体来保证上下文存储结构的一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

struct UserWalletData {
address lib;
address owner;
uint256 status;
}

contract UserWalletLibrary {
UserWalletData data;

function saveStatus(uint256 _status) public {
if (_status != 0) {
data.status = _status;
}
}
}

contract UserWallet {
UserWalletData public data;

constructor(address _lib) {
data.lib = _lib;
data.owner = msg.sender;
}

function saveStatus(uint256 _status) external {
(bool success, ) = data.lib.delegatecall(abi.encodeWithSignature("saveStatus(uint256)", _status));
require(success);
}

receive() external payable { }
}

而使用结构体的好处就在于,此时可以使用库进行编程来优化代码,这也是官方推荐的做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

struct UserWalletData {
address owner;
uint256 status;
}

library UserWalletLibrary {
function saveStatus(UserWalletData storage self, uint256 _status) public {
if (_status != 0) {
self.status = _status;
}
}
}

contract UserWallet {
UserWalletData public data;

constructor() {
data.owner = msg.sender;
}

function saveStatus(uint256 _status) external {
// 不需要库的特定实例就可以调用库函数,
// 因为当前合约就是 “instance”。
UserWalletLibrary.saveStatus(data, _status);
}

receive() external payable { }
}

使用时必须记住两件事:

  1. delegatecall 保留上下文(存储、调用者等)
  2. delegatecall 调用合约和被调用合约的存储结构必须一致

天降横财

一个没有 receivefallback 和其他 payable 函数的合约可能收到 ether 吗?

答案是可能的。

以太坊开发组认为开发者不能盲目相信 balance 的不变性,它实际上极易发生改变,如果需要,最好通过一个状态变量保存合约应该收到了多少 ether。

selfdestruct

selfdestruct 是被设计用于销毁当前合约,为以太坊节省存储空间,防止合约被重复利用的低级函数,它将直接触发 EVM 的 SELFDESTRUCT 字节码。

在 Solidity 的 0.5.0 版本之前是 suicide

极其重要的特点是它会转移资金但不会触发接收函数receivefallback)。

不过需要注意,合约只有在交易结束并被区块链接受后才真正被销毁,revert 会恢复其未被销毁的状态。

EIP-6780 更改了操作码 SELFDESTRUCT 的功能,只有在创建合约的同一个交易中调用时才会销毁合约代码,否则只有转移资金的操作。

selfdestruct 的确是危险的,因为它可以随意操作其他合约的余额。

例如说一个运气赌博游戏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#pragma version ^0.3.0

target: constant(uint256) = as_wei_value(7, "ether")
winner: public(address)

@external
@pure
def target() -> uint256:
return target

@external
@payable
def play():
assert msg.value == as_wei_value(1, "ether"), "Only send 1 ether"
assert self.balance <= target, "Game over"
if self.balance == target:
self.winner = msg.sender

@external
def reward():
assert msg.sender == self.winner, "Not winner"
self.winner = empty(address)
send(msg.sender, self.balance, gas=msg.gas)

规则是每个人每次可以发送 1 ether 给合约,第七个给合约发送 ether 的人将成为胜利者,拿取其他人的 ether。

这很好,以小博大,但是如果此时有恶意破坏者,不想游戏进行下去,它可以通过 selfdestruct 发送非 1 ether 的整数倍的余额到合约中,例如 1 wei,这将使得游戏无法正确进行,始终没有赢家。

一个简单的攻击合约

1
2
3
4
5
6
7
#pragma version ^0.3.0

@external
@payable
def attack(victim: address):
assert msg.value > 0, "No value"
selfdestruct(victim)

预导入

也许未来 selfdestruct 将发生改变,上述破坏行为不可使用,那么还有一种方式就是预先计算合约的地址,随后在部署合约前向该地址发送 ether,当合约真正被创建时,合约就会有一个非零的 ether 余额。

针对于默认的合约创建模式(create 字节码),其合约地址的计算代码如下,addresstx.originnonce 是创建者总共的交易次数。

以下是 TypeScript 的计算代码

1
2
3
4
5
6
7
import RLP from "rlp";
import Web3 from "web3";

const address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
const nonce = 0
const contractAddress = "0x" + Web3.utils.sha3(RLP.encode([address, nonce])).substring(26)
console.log(contractAddress)

以下是 Python 的计算代码

1
2
3
4
5
6
7
8
9
from Crypto.Hash import keccak
import rlp

address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
nonce = 0
address = int(address, 16).to_bytes(20)
encoded = rlp.encode([address, nonce])
contract_address = "0x" + keccak.new(data=encoded, digest_bytes=32).hexdigest()[-40:]
print(contract_address)

有关 create2 字节码的地址计算可以参考网络。

看不见的代码

智能合约可能由不同的编程语言编写而成。为了确保合约之间能够互相交互,接口(Interface)是至关重要的。

只要合约满足接口的规范,无论它们是由哪种编程语言编写,并且无论编译出的字节码的大小如何,它们就可以交互。

如果是以太坊外部需要与合约交互,这个接口是 ABI(Application Binary Interface)。

如果说开发者需要从外部获取地址,再通过接口调用地址上的代码,那么很有可能得到的地址是满足接口的恶意合约地址。

比如说一个简单的猜数游戏,需要从外部(预言机)获取随机数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#pragma version ^0.3.0

# External Interfaces
interface Lib:
def getRandomNumber() -> uint256: nonpayable

target: uint256

@external
def start(addr: address):
assert self.target == 0, "Game has started"
self.target = Lib(addr).getRandomNumber()

@external
def guess(num: uint256) -> bool:
assert self.target != 0, "Game does not start"
if num == self.target:
return True
else:
return False

如果说我编写恶意的,满足接口的合约

1
2
3
4
5
#pragma version ^0.3.0

@external
def getRandomNumber() -> uint256:
return 5

部署后将合约地址传递给 start 函数,那么每次游戏我仅需要猜 5 即可获胜。

恶意代码在主合约中是不可见的,你根本不知道主合约调用了恶意代码。

所以一般建议非信任地址不要轻易调用,建议在初始化时便定死被调用合约地址,在修改时加上身份验证。

蜜罐

利用上文看不见的代码,我们可以制作蜜罐陷阱欺骗攻击者,使得攻击者损失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#pragma version ^0.3.0

# External Interfaces
interface Logger:
def logging(beneficiary: address, amount: uint256): nonpayable

logger: Logger
balances: public(HashMap[address, uint256])

@external
def __init__(logger: address):
self.logger = Logger(logger)

@external
@payable
def deposit():
self.balances[msg.sender] += msg.value

@external
def withdraw():
amount: uint256 = self.balances[msg.sender]
assert amount > 0, "No balance"
send(msg.sender, amount, gas=msg.gas)
self.balances[msg.sender] = 0
self.logger.logging(msg.sender, amount)

在上面的代码中,似乎 logger 只是简单的日志记录,但实际上我们可以实现同一接口但与常理不同的功能,例如

1
2
3
4
5
6
7
8
9
#pragma version ^0.3.0

event WithdrawLog:
beneficiary: address
amount: uint256

@external
def logging(beneficiary: address, amount: uint256):
assert False, "Ahh! Honey Pot!"

这样使得攻击者蒙受损失。

合约代码大小

拿到一个合约地址,如何判断其是否是账户地址(EOA)还是合约地址?

在 Solidity 中,常用的是 Yul 汇编函数 extcodesize 获取地址存储的代码大小。

一般情况下,如果代码大小大于 0,我们就认为这个地址应该是合约地址。

在 Vyper 中,一个 address 有以下几个属性

属性 类型
balance uint256 地址余额
codehash bytes32 地址代码的keccak256哈希值,如果合约未部署,则是默认值 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470(EIP-1052)
codesize uint256 已部署合约代码字节大小
is_contract bool 是否是一个已部署的合约地址
code Bytes 合约字节码

不过通过合约代码大小判断是否为合约地址的方法有一个漏洞,就是当合约在构造函数结束之前,合约仍未部署,此时地址并没有存储合约代码,但是合约地址已被计算。

包括未调用函数前,我们都是可以计算出合约地址(参考预导入),与地址进行交互的。

不过仅限于转账(这是基于地址的),由于合约代码不存在将无法与合约进行交互。

向空地址转账很有可能导致 ether 烧毁(burning),因为没有人能够使用或取回这些资金。

比如说一个账户管理合约

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma version ^0.3.0

struct User:
name: String[32]

accounts: public(HashMap[address, User])

@external
def register(name: String[32]):
assert msg.sender.codesize == 0, "Only user account"
assert len(name) > 0, "No name"
assert len(self.accounts[msg.sender].name) == 0, "Exsit"
self.accounts[msg.sender] = User({name: name})

accounts 使用 HashMap 保存用户信息,register 要求 msg.sender 的必须为 EOA,参数 name 必须有长度,且之前并没有注册过信息。

但是使用 msg.sender.codesize == 0 来判断是否是账户地址是不靠谱的,可以有以下攻击合约

1
2
3
4
5
6
7
8
9
#pragma version ^0.3.0

# External Interfaces
interface Example:
def register(name: String[32]): nonpayable

@external
def __init__(victim: address):
Example(victim).register("hacker")

使得合约成功注册。

如果想要保证 msg.sender 是 EOA,可以用更严格的断言:tx.origin == msg.sender

因为当 msg.sender 不等于 tx.origin 时,则一定是合约发起的消息调用而不是外部账户。

接口

ERC20

ERC20 是以太坊生态系统中最早创建的代币标准之一,也是目前使用最广泛的代币标准之一。

首先我们为什么需要代币?代币(Token)是数字化的资产,可以代表各种形式的价值或权益,例如货币、股票、房地产等。

代币可以是可互换的(fungible),这意味着一个单位的代币可以被另一个单位的代币替代,就像现实世界中的货币一样。在区块链上,代币可以被创建、转移和交易,它们通常是可分割的,允许进行小额交易。

除此之外还可能引出一个新的概念,非同质化代币(NFT,Non-Fungible Token)。

NFT 是一种特殊类型的代币,每个 NFT 都是独一无二的,具有唯一性和不可替代性。与普通代币不同,NFT 代表的是独特的数字资产,如数字艺术品、游戏中的道具、虚拟地产等。每个 NFT 都有独特的标识符,使其在区块链上不可互换。这使得 NFT 在数字艺术、收藏品、游戏和虚拟现实等领域有着广泛的应用。

区块链是开放的,任何人都可以定义、发行代币,比如说比特币,狗狗币等。

以太坊为了满足大家对不同代币之间的可互换性的规范要求,提出了 ERC20 同质化代币标准,它们具有一种属性,使得每个代币都与另一个代币(在类型和价值上)完全相同。 例如,一个 ERC-20 代币就像以太币一样,意味着一个代币会并永远会与其他代币一样。

其中标准定义了以下方法

1
2
3
4
5
6
7
8
9
function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
function totalSupply() public view returns (uint256)
function balanceOf(address _owner) public view returns (uint256 balance)
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)

定义了以下事件

1
2
event Transfer(address indexed _from, address indexed _to, uint256 _value)
event Approval(address indexed _owner, address indexed _spender, uint256 _value)

具体细节可以参考 ERC-20

为了快速生成代币代码,可以复用 OpenZeppelin 的代码,不过需要注意其增加了一些额外的安全性措施。

1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract DZCToken is ERC20 {
constructor(uint256 initialSupply) ERC20("DZCToken", "DZC") {
_mint(msg.sender, initialSupply);
}
}

当然,也可以自己实现。

Vyper 官方提供 ERC20 的接口,可以通过 from vyper.interfaces import ERC20 的方式导入接口。

ERC721

非同质化代币(NFT)用于以唯一的方式标识某人或者某物。与普通代币不同,NFT 代表的是独特的数字资产,如数字艺术品、游戏中的道具、虚拟地产等。每个 NFT 都有独特的标识符,使其在区块链上不可互换。这使得 NFT 在数字艺术、收藏品、游戏和虚拟现实等领域有着广泛的应用。

ERC721 是为 NFT 设计的非同质化代币标准,可以参考 ERC-721

其中标准定义了以下方法

1
2
3
4
5
6
7
8
9
function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
function setApprovalForAll(address _operator, bool _approved) external;
function getApproved(uint256 _tokenId) external view returns (address);
function isApprovedForAll(address _owner, address _operator) external view returns (bool);

定义了以下事件

1
2
3
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

与同质化代币不同的是,ERC721 智能合约需要为每一个代币设置独一无二的 NFT 标识符(uint256 tokenId),这时 (contract address, tokenId) 这一对将成为以太坊链上特定资产的全局唯一和完全限定的标识符。

一般为了方便起见,会从 0 开始,例如第一个 ERC721 NFT 的标识符为 (contract address, 0),第二个 ERC721 NFT 的标识符为 (contract address, 1),以此类推。

同时也在 ERC20 的基础上做了改进,标准化了安全传递函数 safeTransferFrom

为了快速生成 NFT 代码,可以复用 OpenZeppelin 的代码,不过需要注意其增加了一些额外的安全性措施。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract DZPet is ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;

constructor() ERC721("DZPet", "DZP") {}

function awardItem(address player, string memory tokenURI)
public
returns (uint256)
{
uint256 newItemId = _tokenIds.current();
_mint(player, newItemId);
_setTokenURI(newItemId, tokenURI);

_tokenIds.increment();
return newItemId;
}
}

ERC4626

脑洞

MSB

参考位数据隐匿,我们可以在高位中隐写信息,类似于图片隐写中的 MSB,这样我们通过合约的 event 机制,使用少量的 gas 就可以达到一定程度的消息隐匿传递。

比如说可以有这么一个监听合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

interface MessageListenner {
function listen(uint248) external;
}

contract FlagListener is MessageListenner {
event Flag(uint8);

function listen(uint248 x) external override {
emit Flag(uint8(msg.data[4]));
}
}

在高版本的 Solidity 中默认启用 ABI coder v2,所以这里使用低版本方便代码演示,当然也可以通过 pragma 参数修改回 ABI coder v1。

那么在消息发送方仅需获取 FlagListener 的地址进行外部交易,在第零个字节中藏匿信息发送即可。

而消息接收方通过监听合约事件 Flag 接收信息即可,同时也是 gasless。

在这个消息传递中,消息接收方仅告知外部一个合约接口 MessageListenner 和提供一个链外的合约地址提交接口,有一定的隐匿性。

不过由于这种消息传递方式是建立在双方交换了消息发送方法的前提下的,不如交换密钥后加密发送消息,然后在链上进行验证,所以仅仅只能算是一个利用了 Solidity 特性的脑洞

所以可以用来出 MISC 的猜谜题。

为了防止合约花费过高导致消息发送者耗费过多的资金,可以对 gas 做出一定限制。

基于 Hardhat 的一个测试任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import artifacts from "./artifacts/contracts/FlagListener.sol/FlagListener.json"

task("testflag").setAction(async (_, { web3 }) => {
// Deploy `FlagListener` contract
const [deployer, ] = await web3.eth.getAccounts();
const contract = new web3.eth.Contract(artifacts.abi);
const rawContract = contract.deploy({
data: artifacts.bytecode
})
const flagListener = await rawContract.send({
from: deployer
})
// Get the `Flag` event
const eventFlag = flagListener.events.Flag()
// Listen for every charactor of flag
let subcribedFlagHex = ""
eventFlag.on('data', (data) => {
const hex = data.data[data.data.length - 1]
subcribedFlagHex += hex
})
// Get the `listen(uint248)` function selector
const selector = web3.eth.abi.encodeFunctionSignature({
name: "listen",
type: "function",
inputs: [{
name: "x",
type: "uint248"
}]
})
// Send flag
const flag = "Aurora{qsdzyyds!}"
const hexFlag = web3.utils.toHex(flag).substring(2)
// Only send a single HexString at a time
for (let i = 0; i < hexFlag.length; i++) {
const byteString = "0" + hexFlag[i]
const data = selector + byteString + web3.utils.keccak256(Math.random()).substring(4)
console.log(`Send Data: ${data}`)
await web3.eth.sendTransaction({
to: flagListener.options.address,
data: data
})
}
// Get the flag
const finalFlag = web3.utils.hexToAscii("0x" + subcribedFlagHex)
console.log(`Final Flag: ${finalFlag}`)
})

画外音

助记词

助记词,也称为恢复短语或种子短语,是存储恢复加密货币钱包所需的全部信息的单词列表。

助记词通常由 12、15、18、21 或 24 个单词组成,这些单词是从一个包含 2048 个单词的预定列表中生成的。助记词的单词数量与安全级别相对应——更多的单词意味着更多的可能组合,因此安全性更高。

助记词是根据加密标准生成的,其中最常见的加密标准是 BIP-39(比特币第 39 号改进提案)。

在此之外还有 MetaMask 使用的 BIP32/44,或者其他钱包使用的 BIP84、BIP141等。

使用在线网站 https://iancoleman.io/bip39/ 就可以生成助记词或者通过助记词生成种子、密钥等。

也可以选择使用 Python 库 mnemonic 来生成。

同时由于每个词只有 2048(2 的 20 次方)种可能性,如果说大部分助记词被知晓的前提下,可以通过爆破得到用户钱包地址、私钥。

参考