智能合约笔记
智能合约
前言
以太坊是一个基于区块链技术的开源平台,它允许开发者构建和部署智能合约以及分散式应用程序(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 | mkdir hardhat |
安装 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 | $ npx hardhat init |
其余选项默认即可。
在初始化结束后,会得到 hardhat.config.ts
和目录若干。
hardhat.config.ts
相当于 vue
中的
main.ts
,是主入口,需要填写一些代码内容,可能的内容如下
1 | import { HardhatUserConfig } from "hardhat/config"; |
目录下的初始文件删去即可。
默认生成
contracts/Lock.sol
,scripts/deploy.ts
,test/Lock.ts
。
个人喜好的开发环境是以 Vyper 作为智能合约编译器,主要使用 Web3js 与以太坊进行交互。
入门
Hardhat 的文件结构如下
其中
artifacts
- 包含的是合约编译后的结果,在
artifacts/contracts/name.vy/name.json
中,其中name.vy
与合约文件同名。 - json 中主要包含
contractName
- 带有合约名称的字符串abi
- 合约的 ABI JSONbytecode
- 合约未部署的字节码(即包含构造函数),如果编译失败结果为 "0x"deployedBytecode
- 合约部署后的字节码(即不包含构造函数),如果编译失败结果为 "0x"linkReferences
- 未部署字节码的引用对象,由solc
提供deployedLinkReferences
- 部署字节码的引用对象,由solc
提供
- 包含的是合约编译后的结果,在
contracts
- 存放合约代码,在这个文件夹下的代码都会被 Hardhat 自动编译(运行任务时)
scripts
- 规范性的存放
ts
脚本的文件夹
- 规范性的存放
test
- 存放测试脚本的文件夹,主要由 Mocha 代码,内置任务
test
会自动运行其中的所有测试
- 存放测试脚本的文件夹,主要由 Mocha 代码,内置任务
Hardhat 以任务驱动为主,运行方式是
npx hardhat <task name>
。
compile
是一个内置的任务,用于编译合约,一个可能的运行结果为
1 | $ npx hardhat compile |
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 | task("example", "这是一个示例").setAction(async (_, { web3 }) => { |
这样一个最简单的任务就编写完成了,更多的可以参考官方文档。
陷阱
隐私性
所谓private
EVM 是面向合约的,其中的合约可以被视为另一种意义上的对象。
对于一个已部署的合约,其状态在以太坊网络的存储是可见的,类似于计算机内存中对象的数据可见性。
然而,需要注意的是,以太坊中的智能合约存储的可见性仅针对于 EVM 外部。在 EVM 内部,有严格的执行要求阻止合约之间访问其他合约的内部属性。
特别地,Vyper 和 Solidity 针对于 public 属性会默认编译其 getter 方法便于外部访问。
比如说 Vyper 代码如下
1 | #pragma version ^0.3.0 |
使用 Remix 部署合约,可以看见 qsdz
属性是没有 getter
方法的。
需要注意的是,不知道是否是 Remix 的原因,需要与合约进行过一次交易后才可以获取到存储。
但是我们可以通过 web3js 框架在外部与区块链交互,获取合约的存储。
在这里演示使用 Remix 注入脚本的方式执行
1 | (async () => { |
可以获得回显
1 | slot: |
请不要在区块链上存储敏感信息。
存储插槽
在 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 | // SPDX-License-Identifier: MIT |
获取索引为 0 的插槽得到回显
1 | slot: |
定长数组类型
对于定长数组类型,相当于连续存储的值类型。
可以尝试以下测试代码
1 | // SPDX-License-Identifier: MIT |
分别获取索引为 0、1、2 的插槽,得到结果
1 | slot: |
不定长数组类型
对于不定长数组类型,插槽存储数组大小,但数组数据从
keccak256(position)
开始,其中 position
是插槽索引,排列顺序与定长数组类型一样。
1 | // SPDX-License-Identifier: MIT |
一个可能的 ts 代码如下
1 | (async () => { |
映射类型
对于映射类型,插槽本身没有内容,对于每一个键
key
,其值存储在
keccak256(h(key)+position)
,其中 position
是插槽索引,+
表拼接,h
是与键的类型有关的映射函数。
- 对于值类型, 函数
h
将与在内存中存储值的相同方式(encodePacked
)来将值填充为32字节。 - 对于字符串和字节数组,
h(k)
只是未填充的数据。
实际上仅需使用 web3.utils.soliditySha3
函数即可获取插槽索引。
1 | // SPDX-License-Identifier: MIT |
一个可能的 ts 代码如下
1 | (async () => { |
bytes 和 string
bytes
类型用来存储任意字节序列,不做编码假设。在存储时,它们以原始的字节序列形式存储。
string
类型用来存储 UTF-8 编码的 Unicode
字符序列。在存储时,字符串会被转换为 UTF-8
编码的字节序列,并以这种形式存储。
所以本质上它们并无不同,仅仅和编译解析有关。
如果数据最多只有 31
字节长,
元素被存储在高阶字节中(左对齐),最低阶字节存储值
length * 2
。 对于存储数据长度为 32
或更多字节的字节数,插槽存储 length * 2 + 1
,
数据照常存储在 keccak256(position)
。
这意味着可以通过检查最低位是否被设置来区分短数组和长数组: 短数组(未设置)和长数组(设置)。
1 | // SPDX-License-Identifier: MIT |
一个可能的 ts 代码如下
1 | (async () => { |
Vyper
与 Solidity 不同的是,Vyper 暂时还未做出存储紧缩的优化,一个值至少占用一个槽。
即使是
struct
也不会进行存储紧缩。
定长数组类型
1 | #pragma version ^0.3.0 |
一个可能的 ts 代码如下
1 | (async () => { |
在这里本人出现了一点错误,如果索引值为 0 的插槽值为 0 会导致
getStorageAt
阻塞,暂时未了解原因。
Bytes 和 String
特别地,在 Vyper 里,Bytes 和 String 必须是定长数组,存储至少需要两个插槽,前一个插槽存储字符串(字节流)大小,之后存储字符串(字节流)。
1 | #pragma version ^0.3.0 |
一个可能的 ts 代码如下
1 | (async () => { |
动态数组
Vyper 的动态数组的实现是通过定长数组实现的,数据排布格式与定长数组是类似的。
映射类型
在 Vyper 的映射类型为 HashMap
1 | #pragma version ^0.3.0 |
对于映射值的索引计算与 Solidity 是相反的,公式为
keccak256(position+h(key))
一个可能的 ts 代码如下
1 | (async () => { |
随机性
原理
随机数在以太坊中是十分重要的,因为其中大部分智能合约都可以归类为游戏,而游戏往往依赖于随机性,例如最简单的猜硬币正反面。
由于以太坊是一个确定性的图灵机,不涉及固有的随机性,即在合约代码运行之前,以太坊中的状态都是既定的,可预测的。
同时,对于区块链而言,大多数矿工在评估交易时必须获得相同的结果才能达成共识,共识也是区块链技术的支柱之一,随机性意味着所有节点之间不可能达成一致。
另外,对于合约而言,合约的内部状态以及区块链的整个历史都是公共可见的。
最容易想到的随机数来源是区块时间戳(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-3198 和 EIP-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.difficulty
和block.prevrandao
是一个变量,以太坊1.0是PoW,以太坊2.0是PoS,difficulty
命名被重用。EIP-4399
示例
一个比较经典的合约随机数生成算法如下
1 | #pragma version ^0.3.0 |
可以在本地电脑中安装 vyper 生成 interface:
vyper -f external_interface example.vy
1
2
3 # External Interfaces
interface Example:
def getRandomNumber() -> bytes32: view
我们可以写出以下攻击合约:
1 | #pragma version ^0.3.0 |
这是因为同一个交易下的区块状态一定是相同的。
对于 Solidity 的随机数合约是一样的
1 | // SPDX-License-Identifier: MIT |
攻击合约一致。
Chainlink VRF
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。
任何形式的以太币转义包括但不限于转账(
send
,transfer
)和函数调用。
比如说一个拥有存款、取款的合约
1 | #pragma version ^0.3.0 |
很简单的一份代码,使用一个 HashMap
保存每个人账户的余额,使用 deposit
进行存款,使用
withdraw
进行取款。
但需要注意的是,self.balances[msg.sender] = 0
在
send
前后会出现很大区别,因为如果 msg.sender
是合约,send
函数会去寻找该合约的 fallback
函数(在 Vyper 中是
__defalut__
)进行处理,这意味着在金额转移时,控制权交给了另一个合约。
此时如果重新调用 withdraw
函数,断言验证是通过的,意味着我们重入(Re-entrancy)了。
假设我们写下如下攻击代码
1 | #pragma version ^0.3.0 |
在攻击开始,我们首先要使用该合约在银行(Example
)中存储余额,才可以进行
withdraw
。
通过调用 withdraw
函数,使得其触发该合约的
__default__
函数,此时合约再次调用银行(Example
)的
withdraw
函数,在
self.balances[msg.sender] = 0
没有执行前,断言都将成立,故此时递归式获取到所有目标合约的余额,完成攻击。
这里需要注意,
msg
相关的值式与调用者绑定的,即在这里的msg.sender
是Attacker
合约本身。
不过,当你进行尝试时,会发现产生报错,这是由于 gas 限制导致的!
为了防止重入攻击,以太坊官方对 send
做了限额,且不继承交易的 gas,仅可使用 2300
gas(津贴形式),这使得几乎除了输出日志外的其他操作都将失败(会产生revert)。
但是这是十分不实际的,比如说一个自动化交易、投资的智能合约(DeFi)需要在退款(或投资失败)时进行二次投资,此时合约需要大量的
gas 来做操作,send
的限制会阻碍到合约的扩展性。
在 Solidity 遇到这种情况会建议使用 call
直接发送
ether,而 Vyper 为 send
开放了 gas
参数,可以为 send
附加额外的 gas
以便于智能合约执行必要操作。
Vyper 也有类似于
call
的raw_call
函数。
例如一个存在重入漏洞的代码是
1 | #pragma version ^0.3.0 |
Solidity
在开发层面上需要开发者做额外操作,第一是建议使用检查-生效-交互(Checks-Effects-Interactions)模式,即
self.balances[msg.sender] = 0
写在 send
之后。
第二是建议使用哨兵模式,即写入存储一个
guard
,类似于互斥锁的逻辑,保证不会被二次调用。
一般来说建议二者都是用, 因为可能还会存在跨函数重入的情况。
Vyper 与 Solidity
不同,为开发者提供了哨兵模式在语言层面的支持,使用装饰器
nonreentrant
就可以为一个函数上锁,例如
1 |
|
这样就避免了重入。
需要注意的是,在 ^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 | #pragma version ^0.3.0 |
由于 self.balances[msg.sender] -= amount
的问题,在
Vyper 和新版本的 Solidity
中都会检查算数溢出,所以还需结合算术溢出漏洞进行攻击。
算数溢出
EVM 的栈元素只有 256 位,这意味着如果在进行算数时如果算数结果超过 256 位,那么结果存入栈时将会被截断,产生意料之外的结果。这种情况被称为算术溢出。
在 Solidity 0.8.0 版本之前,所有算术溢出结果将不会被检查;在 0.8.0
以后,所有算术溢出结果将抛出 Panic
异常;在 Vyper
中所有算数溢出都将 revert。
1 | // SPDX-License-Identifier: MIT |
这是一个简单的担保合同,保证资金锁定一天以上,如果需要延保还可以调用
delay
函数延迟时间。
部署合约,在这里我们可以尝试担保 1 ether
获取到时间戳 1709363247
,使用 Python 计算
delay = (2 ** 256 - locktime) // oneday + 1
,得到需要 delay
的天数为
1340186218024493002587627141304258192746180378074543565271499814906382180
可以发现,locktime
发生上溢,此时我们可以提前拿出担保资金,破坏了合约本来的功能。
身份验证
永远不要使用 tx.origin
做身份验证,比如说有这么一个钱包合约
1 | #pragma version ^0.3.0 |
使用 raw_call
是为了能够使得钱包给合约转账。
如果说你向恶意合约发送了交易,恶意合约可以将交易跳转回
transferTo
函数,同时能保证断言通过。
比如说伪造一个钱包合约
1 | #pragma version ^0.3.0 |
当你向这个钱包合约进行转账时,它将偷偷转移你钱包里所有的余额。
清除映射
无论是 Vyper 的 HashMap
还是 Solidity 的
mapping
,它们都是一种依赖于以太坊存储的数据结构,二者默认均不提供遍历功能,即并不会帮助记录键的内容。
所以在开发中需要注意,HashMap
或者 mapping
的内容将不会因为变量自身存储槽位的覆盖而清除远在他方的键值变量。
详细可以参考隐私性中的映射类型。
由于 Vyper 暂时不支持用 HashMap
作为
DynArray
的类型,故这里以 Solidity 为例
1 | // SPDX-License-Identifier: MIT |
我们首先 push
,得到一个新的 mapping
,然后
setStatus
,例如参数为
0,"qsdz",12345
,此时可以通过 getStatus
查看到键值对成功写入存储。
然后 pop
再
push
,这在普通计算机平台上理应键值对就被清除了,但实际上在以太坊中,映射类型相当于永久存储,此时再次通过
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 编码器。
委托调用
call
和 delegatecall
都是 Solidity
的低级函数,在 EVM 中有直接对应操作的字节码。
call
用于合约与合约进行交互,正常调用合约代码时就会被翻译成 call
函数,通常用于直接调用其他合约的 fallback
函数;
delegatecall
用于合约复用其他合约代码,msg.sender
、msg.value
和 storage
都将继承自当前合约,正如其名委托调用,被调用者也会被称作代理(Proxy)。
二者的 revert 都不会冒泡;类型检查将被绕过;函数存在性检查将被忽略。
所以为安全起见不建议直接使用这两个低级函数调用合约函数。
相比较 call
的危险性,delegatecall
的危险性更高,最直接的就是 delegatecall
会继承
msg.sender
,使得身份验证
require(msg.sender, owner)
失效。
为了提高代码复用性,增强代码可升级性和节省 gas,故通常使用
delegatecall
利用代理合约(Proxy
Contract)的代码,比如说这么一个钱包代码
1 | // SPDX-License-Identifier: MIT |
这样通过 UserWalletLibrary
的 delegatecall
调用以达到合约代码的复用性,同时在需要更新功能的时候,仅需更新并部署
UserWalletLibrary
合约即可,新的钱包直接重设
lib
变量可以达到与热更新类似的效果。
在这里需要保证 UserWalletLibrary
与
UserWallet
的存储结构保持一致,否则可能会导致插槽访问错误。
故对于复杂存储结构的合约可能会有更高的库合约维护难度。
一切看起来都十分美好,但是在这里错误将 initWallet
变得可复用,导致外部合约可以重复调用该函数,使得钱包易主,这也是著名的
Parity Hack 攻击。
在这里需要有一个良好的开发习惯,不可复用的代码不应该写在库合约(代理合约)中。
我们可以写出攻击代码
1 | // SPDX-License-Identifier: MIT |
将钱包合约地址填入进行攻击,钱包便直接易主。
所以开发时不要滥用
delegatecall
,如果无可避免,那么在库合约中禁止出现不可复用的代码。
延续上文,delegatecall
还可能产生一个开发错误,就是上下文合约中的存储结构不一致的问题。
因为这是合约而不是库,合约是会被编译成字节码的,部署后将不会发生改变,那么合约访问状态变量是被硬编码为插槽位置写死在代码里的。
那没办法嘛,代码是这样写的。
当存储结构不一致时,使用 delegatecall
后的合约代码只知道自己要访问哪个插槽,而不知道自己要访问哪个变量。
比如说可能会有以下代码
1 | // SPDX-License-Identifier: MIT |
由于 UserWalletLibrary
中 status
在插槽索引
0 处,而 UserWallet
中 owner
也在插槽索引 0
处,故 UserWallet.saveStatus
将修改 owner
而不是 status
。
故这种编程方式是不推荐的,更推荐用结构体来保证上下文存储结构的一致性。
1 | // SPDX-License-Identifier: MIT |
而使用结构体的好处就在于,此时可以使用库进行编程来优化代码,这也是官方推荐的做法。
1 | // SPDX-License-Identifier: MIT |
使用时必须记住两件事:
delegatecall
保留上下文(存储、调用者等)delegatecall
调用合约和被调用合约的存储结构必须一致
天降横财
一个没有 receive
、fallback
和其他
payable
函数的合约可能收到 ether 吗?
答案是可能的。
以太坊开发组认为开发者不能盲目相信 balance
的不变性,它实际上极易发生改变,如果需要,最好通过一个状态变量保存合约应该收到了多少
ether。
selfdestruct
selfdestruct
是被设计用于销毁当前合约,为以太坊节省存储空间,防止合约被重复利用的低级函数,它将直接触发
EVM 的 SELFDESTRUCT
字节码。
在 Solidity 的 0.5.0 版本之前是
suicide
。
极其重要的特点是它会转移资金但不会触发接收函数(receive
和 fallback
)。
不过需要注意,合约只有在交易结束并被区块链接受后才真正被销毁,revert 会恢复其未被销毁的状态。
EIP-6780 更改了操作码
SELFDESTRUCT
的功能,只有在创建合约的同一个交易中调用时才会销毁合约代码,否则只有转移资金的操作。
selfdestruct
的确是危险的,因为它可以随意操作其他合约的余额。
例如说一个运气赌博游戏
1 | #pragma version ^0.3.0 |
规则是每个人每次可以发送 1 ether 给合约,第七个给合约发送 ether 的人将成为胜利者,拿取其他人的 ether。
这很好,以小博大,但是如果此时有恶意破坏者,不想游戏进行下去,它可以通过
selfdestruct
发送非 1 ether 的整数倍的余额到合约中,例如 1
wei,这将使得游戏无法正确进行,始终没有赢家。
一个简单的攻击合约
1 | #pragma version ^0.3.0 |
预导入
也许未来 selfdestruct
将发生改变,上述破坏行为不可使用,那么还有一种方式就是预先计算合约的地址,随后在部署合约前向该地址发送
ether,当合约真正被创建时,合约就会有一个非零的 ether 余额。
针对于默认的合约创建模式(create
字节码),其合约地址的计算代码如下,address
是
tx.origin
,nonce
是创建者总共的交易次数。
以下是 TypeScript 的计算代码
1 | import RLP from "rlp"; |
以下是 Python 的计算代码
1 | from Crypto.Hash import keccak |
有关 create2
字节码的地址计算可以参考网络。
看不见的代码
智能合约可能由不同的编程语言编写而成。为了确保合约之间能够互相交互,接口(Interface)是至关重要的。
只要合约满足接口的规范,无论它们是由哪种编程语言编写,并且无论编译出的字节码的大小如何,它们就可以交互。
如果是以太坊外部需要与合约交互,这个接口是 ABI(Application Binary Interface)。
如果说开发者需要从外部获取地址,再通过接口调用地址上的代码,那么很有可能得到的地址是满足接口的恶意合约地址。
比如说一个简单的猜数游戏,需要从外部(预言机)获取随机数
1 | #pragma version ^0.3.0 |
如果说我编写恶意的,满足接口的合约
1 | #pragma version ^0.3.0 |
部署后将合约地址传递给 start
函数,那么每次游戏我仅需要猜 5 即可获胜。
恶意代码在主合约中是不可见的,你根本不知道主合约调用了恶意代码。
所以一般建议非信任地址不要轻易调用,建议在初始化时便定死被调用合约地址,在修改时加上身份验证。
蜜罐
利用上文看不见的代码,我们可以制作蜜罐陷阱欺骗攻击者,使得攻击者损失。
1 | #pragma version ^0.3.0 |
在上面的代码中,似乎 logger
只是简单的日志记录,但实际上我们可以实现同一接口但与常理不同的功能,例如
1 | #pragma version ^0.3.0 |
这样使得攻击者蒙受损失。
合约代码大小
拿到一个合约地址,如何判断其是否是账户地址(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 | #pragma version ^0.3.0 |
accounts
使用 HashMap
保存用户信息,register
要求 msg.sender
的必须为 EOA,参数 name
必须有长度,且之前并没有注册过信息。
但是使用 msg.sender.codesize == 0
来判断是否是账户地址是不靠谱的,可以有以下攻击合约
1 | #pragma version ^0.3.0 |
使得合约成功注册。
如果想要保证 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 | function name() public view returns (string) |
定义了以下事件
1 | event Transfer(address indexed _from, address indexed _to, uint256 _value) |
具体细节可以参考 ERC-20。
为了快速生成代币代码,可以复用 OpenZeppelin 的代码,不过需要注意其增加了一些额外的安全性措施。
1 | // SPDX-License-Identifier: MIT |
当然,也可以自己实现。
Vyper 官方提供 ERC20 的接口,可以通过
from vyper.interfaces import ERC20
的方式导入接口。
ERC721
非同质化代币(NFT)用于以唯一的方式标识某人或者某物。与普通代币不同,NFT 代表的是独特的数字资产,如数字艺术品、游戏中的道具、虚拟地产等。每个 NFT 都有独特的标识符,使其在区块链上不可互换。这使得 NFT 在数字艺术、收藏品、游戏和虚拟现实等领域有着广泛的应用。
ERC721 是为 NFT 设计的非同质化代币标准,可以参考 ERC-721。
其中标准定义了以下方法
1 | function balanceOf(address _owner) external view returns (uint256); |
定义了以下事件
1 | event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); |
与同质化代币不同的是,ERC721 智能合约需要为每一个代币设置独一无二的
NFT 标识符(uint256 tokenId
),这时
(contract address, tokenId)
这一对将成为以太坊链上特定资产的全局唯一和完全限定的标识符。
一般为了方便起见,会从 0 开始,例如第一个 ERC721 NFT 的标识符为
(contract address, 0)
,第二个 ERC721 NFT 的标识符为(contract address, 1)
,以此类推。
同时也在 ERC20 的基础上做了改进,标准化了安全传递函数
safeTransferFrom
。
为了快速生成 NFT 代码,可以复用 OpenZeppelin 的代码,不过需要注意其增加了一些额外的安全性措施。
1 | // SPDX-License-Identifier: MIT |
ERC4626
脑洞
MSB
参考位数据隐匿,我们可以在高位中隐写信息,类似于图片隐写中的
MSB,这样我们通过合约的 event
机制,使用少量的 gas
就可以达到一定程度的消息隐匿传递。
比如说可以有这么一个监听合约
1 | // SPDX-License-Identifier: MIT |
在高版本的 Solidity 中默认启用 ABI coder v2,所以这里使用低版本方便代码演示,当然也可以通过
pragma
参数修改回 ABI coder v1。
那么在消息发送方仅需获取 FlagListener
的地址进行外部交易,在第零个字节中藏匿信息发送即可。
而消息接收方通过监听合约事件 Flag
接收信息即可,同时也是
gasless。
在这个消息传递中,消息接收方仅告知外部一个合约接口
MessageListenner
和提供一个链外的合约地址提交接口,有一定的隐匿性。
不过由于这种消息传递方式是建立在双方交换了消息发送方法的前提下的,不如交换密钥后加密发送消息,然后在链上进行验证,所以仅仅只能算是一个利用了 Solidity 特性的脑洞。
所以可以用来出 MISC 的猜谜题。
为了防止合约花费过高导致消息发送者耗费过多的资金,可以对 gas 做出一定限制。
基于 Hardhat 的一个测试任务
1 | import artifacts from "./artifacts/contracts/FlagListener.sol/FlagListener.json" |
画外音
助记词
助记词,也称为恢复短语或种子短语,是存储恢复加密货币钱包所需的全部信息的单词列表。
助记词通常由 12、15、18、21 或 24 个单词组成,这些单词是从一个包含 2048 个单词的预定列表中生成的。助记词的单词数量与安全级别相对应——更多的单词意味着更多的可能组合,因此安全性更高。
助记词是根据加密标准生成的,其中最常见的加密标准是 BIP-39(比特币第 39 号改进提案)。
在此之外还有 MetaMask 使用的 BIP32/44,或者其他钱包使用的 BIP84、BIP141等。
使用在线网站 https://iancoleman.io/bip39/ 就可以生成助记词或者通过助记词生成种子、密钥等。
也可以选择使用 Python 库
mnemonic
来生成。
同时由于每个词只有 2048(2 的 20 次方)种可能性,如果说大部分助记词被知晓的前提下,可以通过爆破得到用户钱包地址、私钥。