新兴的软件行业通常会共享相同的技术栈,以太坊生态系统也不例外。开发以太坊项目时,首选的语言是 JavaScript。许多以太坊相关的库,包括 OpenZeppelin 的软件,都是用 JavaScript 或其变体编写的。
传统上,JavaScript 代码通常运行在浏览器中,作为网站的一部分,但通过 Node.js,它也可以作为独立进程运行。
本指南将帮助你设置 Node.js 开发环境,便于使用 OpenZeppelin 的工具和其他第三方产品。如果你已经熟悉 Node、npm 和 Git,可以跳过本指南。
你可以通过多种方式在你的机器上安装 Node.js:可以使用包管理器安装,或者直接下载安装程序。
如果你使用的是 Windows,建议考虑使用 Windows Subsystem for Linux(WSL),因为很多以太坊生态系统的工具都是为 Linux 编写的。
安装完成后,在终端中运行 node --version
来检查安装情况。任何有效或维护版本的 Node.js
都应与大多数以太坊软件兼容。
$ node --version
v20.17.0
JavaScript 软件通常打包成 npm 包,并通过 npm
注册表分发。一个 npm 包其实就是一个目录,里面包含一个
package.json
文件,描述了包的名称、版本、内容等信息。当你创建自己的项目时,即使不打算分发它,你也会创建一个
npm 包。
所有的 Node.js 安装都包含一个用于 npm 注册表的命令行客户端,你在开发项目时会使用它。要启动一个新项目,首先创建一个目录:
$ mkdir learn && cd learn
然后初始化项目:
$ npm init -y
就这么简单!项目中的 package.json
文件会随着项目的增长而更新,例如在安装依赖时通过
npm install
命令自动更新。
JavaScript 和 npm 是世界上最常用的软件工具之一,如果你遇到疑问,可以在线找到丰富的信息。
npm 注册表中的包有两种类型:库和可执行文件。已安装的库可以像其他 JavaScript 代码一样使用,而可执行文件则比较特殊。
安装 Node.js 时,还包括了一个名为 npx 的第三个可执行文件。npx 用于运行本地安装的项目中的可执行文件。
虽然 Hardhat 可以全局安装,但我们建议为每个项目本地安装,这样你可以为每个项目控制不同的版本。
为确保避免由于二进制文件不在系统路径中导致的错误,我们会在命令中显示完整的
npx
命令:
$ hardhat init
hardhat: command not found
$ npx hardhat init
👷 Welcome to Hardhat v2.22.12 👷
? What do you want to do? …
确保在运行 npx
时处于项目的目录中!否则,npx
会重新下载整个可执行文件来运行该命令,而这通常不是你想要的结果。
在开始编写代码之前,应该为项目添加版本控制软件来跟踪更改。
最常用的工具是 Git,通常与 GitHub 配合使用以托管项目。你可以在 GitHub 上找到 OpenZeppelin 软件的全部源代码和历史记录。
如果你从未使用过 Git,可以从 Git 手册入手。
注意!不要将助记词、私钥或 API
密钥等秘密信息提交到版本控制中!请确保将包含这些秘密的文件添加到
.gitignore
中,以免泄露。
本指南不会详细讲解语言的语法或关键词,如果你需要相关知识,可以参考以下资源:
创建项目的第一步是安装一个开发工具。目前最流行的以太坊开发框架是 Hardhat 和 Foundry。它们各有优点,建议都熟悉。
本指南主要讲解如何使用 Hardhat 来开发、测试和部署智能合约,并结合 ethers.js。
$ npm install --save-dev hardhat
$ npx hardhat
将 Solidity 源文件(.sol
)存储在 contracts
目录中,类似于其他语言中常用的 src
目录。
我们编写一个名为 Box 的简单智能合约,允许人们存储和检索一个值。代码如下:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Box {
uint256 private _value;
event ValueChanged(uint256 value);
function store(uint256 value) public {
_value = value;
emit ValueChanged(value);
}
function retrieve() public view returns (uint256) {
return _value;
}
}
以太坊虚拟机(EVM)不能直接执行 Solidity 代码,需要先将其编译为 EVM 字节码。
在 hardhat.config.js
文件中配置适当的 Solidity
编译器版本:
然后使用以下命令编译合约:
$ npx hardhat compile
编译完成后,会在项目目录中生成 artifacts
文件夹,存放编译好的字节码和元数据。
随着项目的扩展,你会创建更多的合约并让它们相互交互。每个合约都应该存储在单独的
.sol
文件中。
例如,添加一个简单的访问控制系统,让只有被授权的用户可以使用 Box 合约。我们创建一个名为 Auth 的合约,用来存储管理员地址:
// contracts/access-control/Auth.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Auth {
address private _administrator;
constructor(address deployer) {
_administrator = deployer;
}
function isAdministrator(address user) public view returns (bool) {
return user == _administrator;
}
}
然后在 Box 合约中导入并使用 Auth 合约:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./access-control/Auth.sol";
contract Box {
uint256 private _value;
Auth private _auth;
event ValueChanged(uint256 value);
constructor() {
_auth = new Auth(msg.sender);
}
function store(uint256 value) public {
require(_auth.isAdministrator(msg.sender), "Unauthorized");
_value = value;
emit ValueChanged(value);
}
function retrieve() public view returns (uint256) {
return _value;
}
}
OpenZeppelin Contracts 提供了大量经过审计和验证的模块和库,可以用于构建智能合约。
例如,Ownable
合约标记部署者为合约的所有者,并提供一个 onlyOwner
修饰符,限制某些功能只能由所有者调用。通过继承该合约,可以避免重复实现这些功能。
首先,安装 OpenZeppelin 合约库:
$ npm install @openzeppelin/contracts
然后在 Box 合约中使用 OpenZeppelin 的 Ownable 合约来简化访问控制:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract Box is Ownable {
uint256 private _value;
event ValueChanged(uint256 value);
function store(uint256 value) public onlyOwner {
_value = value;
emit ValueChanged(value);
}
function retrieve() public view returns (uint256) {
return _value;
}
}
在开始之前,我们需要一个可以部署合约的环境。以太坊主网(mainnet)要求使用以太币(Ether)支付费用,这使得它在测试新想法或工具时不是理想的选择。
为了解决这个问题,存在多个测试网络(testnets),例如 Sepolia 和 Holesky 区块链。这些网络的工作方式与主网非常相似,唯一的区别是你可以免费获得这些网络上的以太币,因此使用它们不需要花费任何费用。然而,你仍然需要处理私钥管理、12秒以上的区块生成时间,以及获取免费以太币的问题。
在开发过程中,使用本地区块链会更为方便。它运行在你的本地机器上,不需要互联网连接,提供你所需的所有以太币,并能立即生成区块。由于这些原因,本地区块链非常适合进行自动化测试。
如果你想学习如何在公共区块链(例如以太坊测试网)上部署和使用合约,可以查看我们的连接到公共测试网络指南。
Hardhat 自带一个本地区块链,即 Hardhat Network。启动时,Hardhat Network 会创建一组未锁定的账户并分配给它们以太币。
启动本地网络的命令如下:
$ npx hardhat node
Hardhat Network 会打印出它的地址
http://127.0.0.1:8545
,并显示可用账户及其私钥。
请注意,每次运行 Hardhat Network 时都会创建一个全新的本地区块链——之前的运行状态不会被保留。对于短期实验,这没有问题,但你需要确保在这些指南期间保持 Hardhat Network 窗口的打开。
在 Developing Smart Contracts 指南中,我们已经设置了开发环境。如果你还没有设置,请先创建并配置项目,然后创建并编译 Box 智能合约。
项目设置完成后,我们就可以部署合约了。这里我们将部署
Box 合约,确保你在 contracts/Box.sol
中有该合约的副本。
Hardhat
支持两种方式来部署合约:声明式部署和脚本部署。我们将编写一个脚本来部署
Box 合约,并将其保存为 scripts/deploy.js
文件。
// scripts/deploy.js
async function main () {
// 获取要部署的合约
const Box = await ethers.getContractFactory('Box');
console.log('Deploying Box...');
const box = await Box.deploy();
await box.waitForDeployment();
console.log('Box deployed to:', await box.getAddress());
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error);
process.exit(1);
});
我们在脚本中使用了 ethers.js,因此需要安装
ethers
和 @nomicfoundation/hardhat-ethers
插件:
$ npm install --save-dev @nomicfoundation/hardhat-ethers ethers
接下来,在 hardhat.config.js
文件中添加对
hardhat-ethers 插件的引用:
现在可以运行以下命令,将 Box 合约部署到本地网络(Hardhat Network):
$ npx hardhat run --network localhost scripts/deploy.js
你将会看到以下输出(示例地址):
Deploying Box...
Box deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
请注意,Hardhat
不会自动跟踪已部署的合约,因此我们在脚本中输出了部署的合约地址(如示例中的
0x5FbDB2315678afecb367f032d93F642f64180aa3
)。你可以使用此地址在后续的编程交互中与合约进行交互。
本地区块链的部署速度非常快,但请记住,本地区块链不会保留状态。如果你关闭了本地区块链进程,你将需要重新部署合约。
在我们部署了 Box 合约后,可以立即与其交互。我们将使用 Hardhat 控制台与本地网络上的已部署 Box 合约进行交互。
首先,需要通过 Hardhat 控制台连接到我们本地网络上的 Box 合约。在控制台启动时,确保指定网络为 localhost。如果不指定网络,Hardhat 将默认使用一个新的临时网络,而 Box 合约不会部署到该网络。
$ npx hardhat console --network localhost
这将启动 Node.js 控制台。
我们可以使用 ethers.getContractFactory('Box')
获取合约工厂类。该工厂类代表了合约的抽象定义。
要与已部署的合约进行交互,需要将其附加到相应的合约地址。将部署脚本中输出的
Box 合约地址传递给 Box.attach
:
这一步后,box
对象就表示我们部署的 Box
合约,可以开始与它交互了。
Box 合约的 store
函数接收一个整数值,并将其存储在合约的存储中。因为这个函数会修改区块链的状态,因此需要发送一笔交易来执行它。
例如,将 42
作为值发送到合约:
当交易发送成功后,控制台将返回交易的哈希值,类似于:
Box 合约的 retrieve
函数返回存储的整数值。因为这是对区块链状态的查询,不需要发送交易,只需要调用该函数:
查询结果为 42n
(JavaScript 中的 BigInt
类型)。可以将该数值转换为字符串进行显示:
通过 Hardhat 控制台,您可以轻松与部署的合约进行交互。发送交易会修改区块链的状态,而查询状态则只读取数据,且不需要花费以太币。
尽管使用控制台可以方便地进行原型设计和运行查询或交易,但在实际应用中,你最终需要通过自己的代码与合约交互。
本节将展示如何从 JavaScript 与已部署的合约交互,并使用 Hardhat 来运行脚本和配置。
首先,我们在 scripts/index.js
文件中编写 JavaScript
代码,并包含一些基本的异步代码模板:
// scripts/index.js
async function main () {
// 我们的代码将写在这里
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error);
process.exit(1);
});
测试与本地节点的连接,例如获取启用的账户列表:
// 从本地节点获取账户
const accounts = (await ethers.getSigners()).map(signer => signer.address);
console.log(accounts);
运行以下命令来执行脚本,确保获取到可用的账户:
输出类似于:
这些账户应该与启动本地区块链时显示的账户匹配。
为了与部署的 Box 合约交互,我们将使用 ethers.js 的合约实例。合约实例是代表区块链上合约的 JavaScript 对象,可以用来与合约交互。
首先,提供已部署的合约地址并获取合约实例:
// 设置 ethers 合约实例,代表已部署的 Box 合约
const address = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
const Box = await ethers.getContractFactory('Box');
const box = Box.attach(address);
注意替换示例地址为你实际部署合约时得到的地址。
接下来,我们从合约中调用 retrieve
方法并打印出当前的存储值:
// 调用已部署 Box 合约的 retrieve() 方法
const value = await box.retrieve();
console.log('Box value is', value.toString());
运行脚本:
输出结果应为:
如果你在运行期间重启了本地区块链,可能会出现地址不匹配的情况,此时需要重新部署合约。
现在我们将发送一笔交易来存储新值
23
,并使用之前的代码检查更新后的值:
// 发送交易存储新值到 Box
await box.store(23);
// 调用 retrieve() 方法
const value = await box.retrieve();
console.log('Box value is', value.toString());
运行脚本来验证 Box 的值已更新:
输出结果应为:
通过编程方式,你可以方便地与已部署的智能合约交互。使用 ethers.js 提供的合约实例对象,你可以读取和修改合约中的数据。
在区块链环境中,一个小错误可能导致所有资金损失,甚至更糟,影响用户的资金。通过编写自动化测试,你可以确保应用程序按照预期运行。本指南将帮助你编写测试来验证你的合约行为。
我们将涵盖以下内容:
测试有多种方式,从简单的手动验证到复杂的端到端设置,每种方式都有其用途。对于智能合约开发,实践证明,单元测试非常有效。单元测试简单易写,执行迅速,能够自信地添加功能和修复 bug。
智能合约的单元测试通常由多个小的、集中的测试组成,每个测试验证合约的某个部分是否正确。例如,“管理员可以暂停合约”、“转移代币时会触发事件”或”非管理员不能铸造新代币”等。
由于智能合约在区块链中执行,使用实际的以太坊网络进行测试非常昂贵,而测试网络虽然免费,但速度较慢。因此,我们将使用本地区块链,它不需要以太币,且能立即生成区块,非常适合自动化测试。
我们将使用 Hardhat 的 Chai 断言库 进行单元测试,并通过安装 Hardhat Toolbox 来实现:
我们将测试文件放在 test
目录中,并与
contracts
目录保持对应结构。每个合约对应一个
.sol
文件和一个测试文件。
现在,我们编写第一个测试,测试简单的 Box 合约,该合约允许检索之前存储的值。
在项目根目录下创建 test
文件夹,将以下测试代码保存为
test/Box.test.js
:
// test/Box.test.js
const { expect } = require('chai');
describe('Box', function () {
before(async function () {
this.Box = await ethers.getContractFactory('Box');
});
beforeEach(async function () {
this.box = await this.Box.deploy();
await this.box.waitForDeployment();
});
it('retrieve returns a value previously stored', async function () {
await this.box.store(42);
expect((await this.box.retrieve()).toString()).to.equal('42');
});
});
通过运行以下命令来执行 test
目录中的所有测试,确保你的合约按预期工作:
输出结果应为:
有时你可能需要捕捉智能合约中的更复杂的属性,例如:
OpenZeppelin Test Helpers 是一个专门用于测试这些属性的库。它还可以简化模拟区块链时间的流逝以及处理大数值的任务。
安装 Test Helpers:
我们可以更新之前的测试,使用 Test Helpers 来处理大数值、检查事件是否被触发,以及验证交易是否回滚:
// test/Box.test.js
const { expect } = require('chai');
const { BN, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
const Box = artifacts.require('Box');
contract('Box', function ([ owner, other ]) {
const value = new BN('42');
beforeEach(async function () {
this.box = await Box.new({ from: owner });
});
it('retrieve returns a value previously stored', async function () {
await this.box.store(value, { from: owner });
expect(await this.box.retrieve()).to.be.bignumber.equal(value);
});
it('store emits an event', async function () {
const receipt = await this.box.store(value, { from: owner });
expectEvent(receipt, 'ValueChanged', { value: value });
});
it('non owner cannot store a value', async function () {
await expectRevert(
this.box.store(value, { from: other }),
'Ownable: caller is not the owner',
);
});
});
这些测试会验证 Ownable
合约的相关属性:管理员可以存储值、触发事件、非管理员无法存储值。
再次运行测试:
输出结果应为:
自动化测试是保障智能合约安全性的重要手段。通过使用 Chai 断言库和 OpenZeppelin Test Helpers,可以编写高效且强大的测试,确保合约在多种场景下都能按预期运行。
在你编写并本地测试了智能合约之后,接下来的一步是将它们部署到公共测试环境,供你和你的测试用户使用。公共测试网络(testnets)非常类似于以太坊主网,不同的是测试网络上的以太币(Ether)是免费的,没有实际价值,非常适合零成本地测试智能合约。
本指南将带你了解如何在测试网络上部署和与合约交互,包括:
测试网络是开发者在不消耗真实以太币的情况下测试应用的地方。推荐的测试网络是 Sepolia(网络ID = 11155111)。你可以选择其他测试网络,但 Sepolia 是目前的首选。
要将项目连接到公共测试网络,步骤如下:
我们可以使用公共节点服务,如 Alchemy 或 Infura。这些服务允许开发者访问测试网络和主网节点。本指南使用 Alchemy,你需要在 Alchemy 注册并获取一个 API 密钥,用于连接网络。
使用 mnemonics
包可以生成一个新的以太坊账户助记词:
记住助记词,不要将其提交到版本控制系统中。
我们将使用 Sepolia 测试网络,首先更新
hardhat.config.js
文件:
// hardhat.config.js
const { alchemyApiKey, mnemonic } = require('./secrets.json');
module.exports = {
networks: {
sepolia: {
url: `https://eth-sepolia.g.alchemy.com/v2/${alchemyApiKey}`,
accounts: { mnemonic: mnemonic },
},
},
};
确保将 API 密钥和助记词保存在 secrets.json
文件中,并将该文件添加到 .gitignore
中以保护机密信息。
通过以下命令检查配置是否正确,并查看 Sepolia 网络上的账户:
$ npx hardhat console --network sepolia
> accounts = (await ethers.getSigners()).map(signer => signer.address)
你还可以查询账户余额:
要发送交易,你需要一些测试以太币。你可以访问 Alchemy 的免费 Sepolia 测试网络水龙头(faucet)来获取测试以太币。
现在我们可以将 Box 合约部署到 Sepolia 测试网络,命令与本地部署相同,但会耗时几秒钟,因为需要等待新的区块生成。
部署成功后,你可以在 Sepolia 的区块浏览器上查看你的合约,例如 sepolia.etherscan.io。
你可以使用 Hardhat 控制台
或通过编程方式与部署的合约交互。例如,在控制台中调用 Box 合约的
store
和 retrieve
方法:
$ npx hardhat console --network sepolia
> const Box = await ethers.getContractFactory('Box');
> const box = await Box.attach('0x1b99CCaCea0e4046db618770dEF72180F8138641');
> await box.store(42);
> (await box.retrieve()).toString();
'42'
请记住,每次交易都会消耗少量的 gas,因此你可能需要定期为你的账户充值测试以太币。
通过将你的项目连接到公共测试网络,你可以在不产生成本的情况下,测试智能合约在接近真实网络的环境中的表现。使用像 Alchemy 或 Infura 这样的公共节点服务,并确保账户有足够的测试以太币来执行交易。
通过 OpenZeppelin 的升级插件部署的智能合约可以在保持其地址、状态和余额的情况下升级代码。这使得开发者可以逐步添加新功能,或修复生产环境中的 bug。
在以太坊中,智能合约一旦部署就不可更改。然而,在某些情况下,能够修改合约是有利的。例如,修复 bug 或添加新功能。对于不可升级的合约,要修复 bug 需要:
为避免这些繁琐步骤,OpenZeppelin 插件支持合约的代码升级,同时保持状态和地址不变。
使用 deployProxy
部署的合约实例可以升级。只有最初部署合约的地址有权限进行升级。
deployProxy
执行以下操作: 1. 部署实现合约(如 Box
合约)。 2. 部署代理合约并运行初始化函数。 3. 自动部署
ProxyAdmin
合约来管理代理。
// hardhat.config.js
require('@nomicfoundation/hardhat-ethers');
require('@openzeppelin/hardhat-upgrades');
scripts/deploy_upgradeable_box.js
:const { ethers, upgrades } = require('hardhat');
async function main() {
const Box = await ethers.getContractFactory('Box');
console.log('Deploying Box...');
const box = await upgrades.deployProxy(Box, [42], { initializer: 'store' });
await box.waitForDeployment();
console.log('Box deployed to:', await box.getAddress());
}
main();
创建新版本的合约 BoxV2
,增加 increment
函数:
// contracts/BoxV2.sol
pragma solidity ^0.8.0;
contract BoxV2 {
uint256 private _value;
// Increments the stored value by 1
function increment() public {
_value = _value + 1;
}
}
编写升级脚本 scripts/upgrade_box.js
:
const { ethers, upgrades } = require('hardhat');
async function main () {
const BoxV2 = await ethers.getContractFactory('BoxV2');
console.log('Upgrading Box...');
await upgrades.upgradeProxy('0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', BoxV2);
console.log('Box upgraded');
}
main();
升级合约:
再次使用控制台交互:
OpenZeppelin 的升级插件部署三个合约: 1. 实现合约:你编写的逻辑合约。 2. 代理合约:与用户交互并将调用委托给实现合约。 3. ProxyAdmin:管理代理的管理员合约。
代理合约通过 delegatecall
将所有调用委托给实现合约,而代理保存状态,这允许我们升级实现合约而不影响状态。
升级步骤: 1. 部署新的实现合约。 2. 发送交易更新代理合约的实现地址。
initializer
函数。测试可升级合约时,应对每个实现合约进行单元测试,并对代理合约进行高层次的交互测试。
在测试网运行项目一段时间且没有问题后,下一步就是将其部署到以太坊主网。然而,部署到主网的规划应该在预定发布日期之前开始。
请记住,尽管在测试网和主网上管理合约的技术操作是相同的,但主网与测试网的一个重大区别在于:主网管理的是用户的真实价值,因此需要额外的安全措施。
在智能合约中,安全至关重要,因为任何人都可以直接向你的合约发送交易,而且所有代码和状态都是公开的。如果遭遇黑客攻击,资金将不可挽回。安全应贯穿项目开发的各个阶段,而不仅仅是上线前的最后几周。
建议参考智能合约安全最佳实践并加入相关安全讨论。此外,项目完成后,可以向审计公司申请审计,例如 OpenZeppelin 的研究团队。尽管审计不能完全消除漏洞,但让有经验的安全研究人员审查代码能大大降低风险。
部署到主网后,验证合约源代码非常重要。验证是将 Solidity 代码提交到第三方平台(如 Etherscan 或 Sourcify),这些平台会将代码编译并与已部署的合约字节码进行匹配,以确保用户能查看到合约代码。
你可以手动在 Etherscan 上验证代码,也可以使用
hardhat-verify
插件:
在 hardhat.config.js
文件中配置:
const { etherscanApiKey } = require('./secrets.json');
require("@nomicfoundation/hardhat-verify");
module.exports = {
networks: {
mainnet: { ... }
},
etherscan: {
apiKey: etherscanApiKey
}
};
最后运行验证任务,指定合约地址、网络以及构造函数参数:
在主网上运行项目时,保护私钥尤为重要。合约部署和交互的账户将持有真正的以太币,因此成为黑客的目标。建议使用硬件钱包来确保密钥安全。
此外,部署到主网时,需要通过交易所购买以太币来支付部署费用。
管理员账户在系统中具有特殊权限,如暂停合约的能力。如果管理员账户被恶意用户控制,系统可能遭受巨大损害。建议使用多重签名(multisig)合约来管理这些关键账户。Safe 是一个常用的多签工具。
在 OpenZeppelin
升级插件中,默认情况下,合约的升级权限属于最初部署合约的外部账户。在主网部署时,建议将
ProxyAdmin 的控制权转移给更安全的多签账户。可以通过
admin.transferProxyAdminOwnership
转移 ProxyAdmin
的所有权。
管理员账户的存在意味着项目并不完全去中心化,因为它们具有改变系统合约的能力。因此,治理机制至关重要。项目中某些操作(如系统参数调整或合约升级)需要特别的权限,如何分配这些权限取决于你的项目需求。
常见的治理模式包括由少数开发者管理或通过社区投票进行决策。
恭喜你完成从编写合约到生产部署的整个开发流程。但这只是个开始,接下来你需要持续收集用户反馈、添加新功能(通过合约升级实现)、监控应用并扩展项目。