智能合约开发笔记

概述

智能合约(英语:Smart contract)是一种智能协议,在区块链内制定合约时使用,当中内含了代码函数(Function),亦能与其他合约进行交互、做决策、存储资料及发送以太币等功能。智能合约主力提供验证及执行合约内所订立的条件。智能合约允许在没有第三方的情况下进行可信交易。这些交易可追踪且不可逆转。智能合约概念于1994年由一名身兼计算机科学家及密码学专家的学者尼克·萨博首次提出。智能合同的目的是提供优于传统合同方法的安全,并减少与合同相关的其他交易成本。

智能合约是“执行合约条款的计算机交易协议”。区块链上的所有用户都可以看到基于区块链的智能合约。但是,这会导致包括安全漏洞在内的所有漏洞都可见,并且可能无法迅速修复。

以太坊智能合约中的问题包括合约编程Solidity、编译器错误、以太坊虚拟机错误、对区块链网络的攻击、程序错误的不变性以及其他尚无文档记录的攻击。

区块链基础

区块

在比特币中,要解决的一个主要难题,被称为“双花攻击 (double-spend attack)”:如果网络存在两笔交易,都想花光同一个账户的钱时(即所谓的冲突)会发生什么情况?交易互相冲突?

简单的回答是网络会为你自动选择一条交易序列,并打包到所谓的“区块”中,然后它们将在所有参与节点中执行和分发。如果两笔交易互相矛盾,那么最终被确认为后发生的交易将被拒绝,不会被包含到区块中。

这些块按时间形成了一个线性序列,这正是“区块链”这个词的来源。区块以一定的时间间隔添加到链上 —— 对于以太坊,这间隔大约是17秒。

作为“顺序选择机制”(也就是所谓的“挖矿”)的一部分,可能有时会发生块(blocks)被回滚的情况,但仅在链的“末端”。末端增加的块越多,其发生回滚的概率越小。因此你的交易被回滚甚至从区块链中抹除,这是可能的,但等待的时间越长,这种情况发生的概率就越小。

事务

区块链是全球共享的事务性数据库,这意味着每个人都可加入网络来阅读数据库中的记录。如果你想改变数据库中的某些东西,你必须创建一个被所有其他人所接受的事务。事务一词意味着你想做的(假设您想要同时更改两个值),要么一点没做,要么全部完成。此外,当你的事务被应用到数据库时,其他事务不能修改数据库。

举个例子,设想一张表,列出电子货币中所有账户的余额。如果请求从一个账户转移到另一个账户,数据库的事务特性确保了如果从一个账户扣除金额,它总被添加到另一个账户。如果由于某些原因,无法添加金额到目标账户时,源账户也不会发生任何变化。

这样,就可非常简单地为数据库的特定修改增加访问保护机制。在电子货币的例子中,一个简单的检查可以确保只有持有账户密钥的人才能从中转账。

以太坊虚拟机

以太坊虚拟机 EVM 是智能合约的运行环境。它不仅是沙盒封装的,而且是完全隔离的,也就是说在 EVM 中运行代码是无法访问网络、文件系统和其他进程的。甚至智能合约之间的访问也是受限的。

账户

以太坊中有两类账户(它们共用同一个地址空间)

  • 外部账户:由公钥-私钥对控制(由人控制)
  • 合约账户:由和账户一起存储的代码控制

外部账户的地址是由公钥决定的,而合约账户的地址是在创建该合约时确定的(这个地址通过合约创建者的地址和从该地址发出过的交易数量计算得到的,也就是所谓的“nonce”)

无论帐户是否存储代码,这两类账户对 EVM 来说是一样的。每个账户都有一个键值对形式的持久化存储。其中 key 和 value 的长度都是256位,我们称之为 存储 。每个账户有一个以太币余额( balance),单位是“Wei”,余额会因为发送包含以太币的交易而改变。

交易

交易可以看作是从一个帐户发送到另一个帐户的消息,如果目标账户含有代码,此代码会被执行并以 payload 作为入参。

如果目标账户是零账户(账户地址为0),此交易将创建一个新合约。合约的地址不是零地址,而是通过合约创建者的地址和从该地址发出过的交易数量计算得到的所谓的“nonce”,这个用来创建合约的交易的 payload 会被转换为 EVM 字节码并执行。执行的输出将作为合约代码被永久存储。这意味着为创建一个合约,不需要发送实际的合约代码而是发送能够产生合约代码的代码。

Gas

每笔交易都收取一定数量的 gas ,目的是限制执行交易所需要的工作量和为交易支付手续费。EVM 执行交易时,gas 将按特定规则逐渐耗尽。

gas price是交易发送者设置的一个值,发送者账户需要预付的手续费 = gas_price * gas 。如果交易执行后还有剩余, gas会原路返还。

无论执行到什么位置,一旦 gas 被耗尽(比如降为负值),将会触发一个out-of-gas异常。当前调用帧(call frame)所做的所有状态修改都将被回滚。

存储、内存和栈

存储:每个账户有一块持久化内存区,存储是将256位字映射到256位字的键值存储区。 在合约中枚举存储是不可能的,且读存储的相对开销很高,修改存储的开销甚至更高。合约只能读写存储区内属于自己的部分。

内存:合约会试图为每一次消息调用获取一块被重新擦拭干净的内存实例。 内存是线性的,可按字节级寻址,但读的长度被限制为256位,而写的长度可以是8位或256位。当访问(无论是读还是写)之前从未访问过的内存字(word)时(无论是偏移到该字内的任何位置),内存将按字进行扩展(每个字是256位)。扩容也将消耗一定的gas。随着内存使用量的增长,其费用也会增高(以平方级别)。

栈:EVM 不是基于寄存器的,而是基于栈的,因此所有的计算都在一个被称为 栈(stack) 的区域执行。栈最大有1024个元素,每个元素长度是一个字(256位)。对栈的访问只限于其顶端,限制方式为:允许拷贝最顶端的16个元素中的一个到栈顶,或者是交换栈顶元素和下面16个元素中的一个。所有其他操作都只能取最顶的两个(或一个,或更多,取决于具体的操作)元素,运算后,把结果压入栈顶。当然可以把栈上的元素放到存储或内存中。但是无法只访问栈上指定深度的那个元素,除非先从栈顶移除其他元素

Hardhat简介

Hardhat是一个编译、部署、测试和调试以太坊应用的开发环境。它可以帮助开发人员管理和自动化构建智能合约和dApps过程中固有的重复性任务,并围绕这一工作流程轻松引入更多功能。这意味着hardhat在最核心的地方是编译、运行和测试智能合约。

Hardhat内置了Hardhat网络,这是一个专为开发设计的本地以太坊网络。主要功能有Solidity调试,跟踪调用堆栈、console.log()和交易失败时的明确错误信息提示等。

项目实战

安装

  1. 创建一个空文件夹,运行npm init --y初始化项目。
  2. 项目初始化之后,运行npm install --save-dev hardhat
  3. 在项目文件夹中运行npx hardhat来创建Hardhat项目,项目结构如图所示
    avatar

项目会要求安装hardhat-waffle和hardhat-ethers,以便让Hardhat与Waffle构建的测试兼容
如果你错过了,你可以运行命令npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers来安装它们

  1. 在项目文件夹中运行npx hardhat,可以快速了解可用的命令和任务
    avatar

编译合约

在contracts目录,添加Transactions.sol文件

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
47
48
49
50
51
52
53
54
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity 0.8.19;

contract Transactions {
uint256 transactions_count;

event Transfer(
address sender,
address receiver,
uint amount,
string message,
uint256 timestamp
);

struct TransferStruct {
address sender;
address receiver;
uint amount;
string message;
uint256 timestamp;
}

TransferStruct[] transactions;

function addToBlockchain(
address payable receiver,
uint amount,
string memory message
) public {
transactions.push(
TransferStruct(
msg.sender,
receiver,
amount,
message,
block.timestamp
)
);
transactions_count++;
emit Transfer(msg.sender, receiver, amount, message, block.timestamp);
}

function getAllTransactions()
public
view
returns (TransferStruct[] memory)
{
return transactions;
}

function getTransactionCount() public view returns (uint256) {
return transactions_count;
}
}

合约编写完成之后,要编译它,只需运行npx hardhat compile

测试合约

在test目录下可以看到Lock.js文件,使用npx hardhat test来运行测试
avatar

部署合约

使用Hardhat脚本部署合约。在scripts目录下可以看到deploy.js文件。添加如下代码

1
2
3
4
5
6
7
8
9
10
11
async function main() {
const Transactions = await hre.ethers.getContractFactory("Transactions");
const transactions = await Transactions.deploy();
await transactions.waitForDeployment();
console.log(`transactions deployed to ${transactions.target}`);
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

npx hardhat run scripts/deploy.js运行它。结果如图所示
avatar

连接钱包或Dapp到Hardhat网络

本地部署

Hardhat在启动时,默认情况下总会启动一个Hardhat Network的内存实例,你也可以以独立的方式运行Hardhat Network,以便外部客户(可能是MetaMask,你的Dapp前端,或者一个脚本)可以连接到它。要以独立的方式运行Hardhat Network,运行npx hardhat node
avatar

这将暴露一个JSON-RPC接口链接到Hardhat网络。只要将钱包或应用程序连接到 http://127.0.0.1:8545就可以使用它。如果要把Hardhat连接到这个节点上,例如,要在这个网络上运行一个部署脚本,只需要使用--network localhost来运行脚本。

先用npx hardhat node启动一个节点,再用npx hardhat run scripts/deploy.js --network localhost部署本地节点
avatar

远程部署

远程部署以Alchemy为例

Alchemy是一个构建Web3应用程序的平台,提供强大的API、SDK和工具,可轻松构建和扩展Web3 应用程序。

  1. 在Alchemy平台注册登录,在控制面板创建一个app,选择你要部署的测试网络,本文以goerli测试网为例。
    avatar
  2. 点击创建好的app右上角的api key,复制端点地址(以HTTPS为例)
    avatar
  3. 点击metamask,选择查看账户详情,显示私钥并复制粘贴。切记不能泄露私钥!!!
    avatar
  4. 做完准备工作,打开hardhat.config.js文件,并添加以下代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.19",
networks: {
goerli: {
url: "你的Alchemy端点地址",
accounts: [
"你的metamask私钥",
],
},
},
};

替换代码中的url和accounts

运行命令npx hardhat run scripts/deploy.js --network goerli。结果如下所示:
avatar

部署过程中可能遇到的问题

  1. "TypeError: Cannot read property 'sendTransaction' of null"
    解决方案:检查hardhat.config.js文件中的配置是否正确

  2. "TypeError: AstroMint.deploy is not a function"
    解决方案:在最近的更新中,hardhat 团队从 Hardhat-waffle 迁移到@nomicfoundation/hardhat-toolbox。所以deployment()不再使用需要替换为waitForDeployment()。

  3. "Error: contract runner does not support calling (operation="call", code=UNSUPPORTED_OPERATION, version=6.3.0)"
    解决方案:

  • 检查配置文件中私钥开头是否包含0x,私钥中不以0x开头
  • 检查配置文件中accounts字段是否是accounts而不是account
  • 对一些测试网络可能还需要添加chainId字段,这个根据具体情况考虑是否添加
  • 检查deploy文件中部署合约的时候是否添加await关键字

参考文献

Solidity文档
Hardhat文档