1. 引言:什么是 ERC-20?
ERC-20 是以太坊上最流行的代币标准,它定义了一套统一的接口规则,使得任何遵循该标准的代币都能与钱包、交易所和去中心化应用(DApp)无缝交互。简单来说,ERC-20 为代币的创建和管理提供了"通用说明书"。
本文将带你从零开始,实战开发一个具备铸造(Mint) 和转账(Transfer) 功能的 ERC-20 代币。你将学习到:
- ERC-20 标准的核心接口。
- 如何使用 Solidity 和 Hardhat 编写安全的代币合约。
- 如何实现铸造功能,为特定地址生成新代币。
- 如何实现并测试转账功能。
- 将合约部署到测试网并进行验证。
2. 环境与工具准备
在开始编码前,请确保你的开发环境已就绪。
2.1 安装 Node.js 和 npm
访问 Node.js 官网 下载并安装 LTS 版本。安装完成后,在终端运行以下命令验证:
bash
node --version
npm --version
2.2 初始化 Hardhat 项目
我们将使用 Hardhat 作为开发、测试和部署框架。
bash
mkdir erc20-tutorial && cd erc20-tutorial
npm init -y
npm install --save-dev hardhat
npx hardhat init
在初始化向导中,选择 "Create a JavaScript project",并安装所有推荐的依赖。
2.3 安装 OpenZeppelin 合约库
OpenZeppelin 提供了经过严格审计、安全可靠的合约实现,我们将基于它来构建我们的代币。
bash
npm install @openzeppelin/contracts
3. ERC-20 标准核心接口解析
ERC-20 标准规定了代币合约必须实现的最小接口。理解这些接口是开发的基础。
3.1 必须实现的函数
totalSupply(): 返回代币的总供应量。balanceOf(address account): 返回指定account地址的代币余额。transfer(address recipient, uint256 amount): 从调用者地址向recipient地址转账amount数量的代币。allowance(address owner, address spender): 返回owner授权给spender使用的代币数量。approve(address spender, uint256 amount): 授权spender可以从调用者地址转出最多amount数量的代币。transferFrom(address sender, address recipient, uint256 amount): 在授权额度内,从sender地址向recipient地址转账amount数量的代币。
3.2 必须触发的事件
Transfer(address indexed from, address indexed to, uint256 value): 当代币被转账(包括铸造和销毁)时触发。Approval(address indexed owner, address indexed spender, uint256 value): 当授权成功时触发。
4. 编写我们的代币合约:MyToken
我们将创建一个名为 MyToken 的可铸造 ERC-20 代币。
4.1 创建合约文件
在 contracts/ 目录下创建 MyToken.sol 文件。
4.2 合约代码详解
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 导入 OpenZeppelin 的 ERC-20 实现和拥有者权限控制
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title MyToken - 一个可铸造的 ERC-20 代币
* @dev 继承自 ERC20 和 Ownable,只有合约所有者可以铸造新代币。
*/
contract MyToken is ERC20, Ownable {
/**
* @dev 构造函数,初始化代币名称和符号,并将合约部署者设为所有者。
* @param initialOwner 初始合约所有者地址,拥有铸造权限。
*/
constructor(address initialOwner)
ERC20("MyToken", "MTK")
Ownable(initialOwner)
{
// 构造函数体可以为空,或者执行一些初始设置
// 例如,可以在这里进行初始铸造:_mint(initialOwner, 1000000 * 10 ** decimals());
}
/**
* @dev 铸造新代币的函数,仅所有者可调用。
* @param to 接收新铸造代币的地址。
* @param amount 要铸造的代币数量(以最小单位计,例如 wei)。
*/
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
代码解析:
ERC20("MyToken", "MTK"): 继承 OpenZeppelin 的ERC20合约,并传入代币名称和符号。Ownable(initialOwner): 继承Ownable合约,实现所有权管理。initialOwner在部署时被设为所有者。onlyOwner: 这是一个函数修饰器(modifier),确保只有合约所有者才能调用mint函数。_mint(to, amount): 调用 ERC-20 内部函数,安全地向地址to铸造amount数量的代币,并自动触发Transfer事件。
5. 编译与本地测试
编写测试是确保合约安全可靠的关键步骤。
5.1 编写测试脚本
在 test/ 目录下创建 MyToken.test.js 文件。
javascript
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("MyToken", function () {
let MyToken;
let myToken;
let owner;
let addr1;
let addr2;
beforeEach(async function () {
// 获取合约工厂
MyToken = await ethers.getContractFactory("MyToken");
// 获取测试账户
[owner, addr1, addr2] = await ethers.getSigners();
// 部署合约,将部署者(owner)设为初始所有者
myToken = await MyToken.deploy(owner.address);
await myToken.waitForDeployment();
});
describe("部署与初始化", function () {
it("应该设置正确的名称和符号", async function () {
expect(await myToken.name()).to.equal("MyToken");
expect(await myToken.symbol()).to.equal("MTK");
});
it("应该将部署者设为所有者", async function () {
expect(await myToken.owner()).to.equal(owner.address);
});
});
describe("铸造功能", function () {
it("只有所有者可以铸造代币", async function () {
// 所有者铸造成功
await expect(myToken.connect(owner).mint(addr1.address, 1000))
.to.emit(myToken, "Transfer")
.withArgs(ethers.ZeroAddress, addr1.address, 1000);
expect(await myToken.balanceOf(addr1.address)).to.equal(1000);
// 非所有者铸造失败
await expect(
myToken.connect(addr1).mint(addr2.address, 500)
).to.be.revertedWithCustomError(myToken, "OwnableUnauthorizedAccount");
});
});
describe("转账功能", function () {
beforeEach(async function () {
// 先给 addr1 铸造一些代币用于测试转账
await myToken.connect(owner).mint(addr1.address, 1000);
});
it("应该允许用户转账代币", async function () {
await expect(myToken.connect(addr1).transfer(addr2.address, 300))
.to.emit(myToken, "Transfer")
.withArgs(addr1.address, addr2.address, 300);
expect(await myToken.balanceOf(addr1.address)).to.equal(700);
expect(await myToken.balanceOf(addr2.address)).to.equal(300);
});
it("转账金额不能超过余额", async function () {
await expect(
myToken.connect(addr1).transfer(addr2.address, 1500)
).to.be.revertedWithCustomError(myToken, "ERC20InsufficientBalance");
});
});
});
5.2 运行测试
在项目根目录下运行:
bash
npx hardhat test
如果一切正常,你将看到所有测试用例通过。
6. 部署到测试网
让我们将合约部署到 Sepolia 测试网。
6.1 配置网络和私钥
- 在
hardhat.config.js中添加 Sepolia 网络配置。 - 从 Alchemy 或 Infura 获取一个 Sepolia RPC URL。
- 将你的测试账户私钥(切勿使用主网私钥!)添加到环境变量或配置中。
一个简化的 hardhat.config.js 示例:
javascript
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.20",
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},
};
6.2 编写部署脚本
在 scripts/ 目录下创建 deploy.js:
javascript
async function main() {
const [deployer] = await ethers.getSigners();
console.log("使用账户部署合约:", deployer.address);
const MyToken = await ethers.getContractFactory("MyToken");
// 部署时将部署者地址作为 initialOwner 传入
const myToken = await MyToken.deploy(deployer.address);
await myToken.waitForDeployment();
console.log("MyToken 合约已部署到地址:", await myToken.getAddress());
console.log("代币名称:", await myToken.name());
console.log("代币符号:", await myToken.symbol());
console.log("合约所有者:", await myToken.owner());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
6.3 执行部署
运行以下命令进行部署:
bash
npx hardhat run scripts/deploy.js --network sepolia
部署成功后,控制台会输出合约地址。你可以使用 Etherscan Sepolia 查看你的合约。
7. 与合约交互
部署后,你可以通过 Hardhat Console 或编写前端 DApp 与合约交互。
7.1 使用 Hardhat Console 铸造和转账
bash
npx hardhat console --network sepolia
在控制台中:
javascript
// 连接到已部署的合约
const MyToken = await ethers.getContractFactory("MyToken");
const myToken = await MyToken.attach("YOUR_DEPLOYED_CONTRACT_ADDRESS");
// 获取当前账户(部署者)
const [owner, addr1] = await ethers.getSigners();
// 所有者铸造 1000 MTK 给 addr1
const tx = await myToken.mint(addr1.address, ethers.parseUnits("1000", 18));
await tx.wait();
console.log("铸造成功!");
// 检查 addr1 的余额
const balance = await myToken.balanceOf(addr1.address);
console.log("addr1 余额:", ethers.formatUnits(balance, 18), "MTK");
// addr1 向 owner 转账 500 MTK
const tx2 = await myToken.connect(addr1).transfer(owner.address, ethers.parseUnits("500", 18));
await tx2.wait();
console.log("转账成功!");
7.2 前端集成(简要)
你可以使用 ethers.js 或 web3.js 库在网页中连接用户钱包(如 MetaMask),并调用合约的 mint 和 transfer 函数。核心逻辑与上述控制台脚本类似。
8. 安全注意事项与最佳实践
- 使用经过审计的库:始终优先使用 OpenZeppelin 等成熟库,避免重复造轮子。
- 权限控制 :像
mint这样的敏感操作必须严格限制访问权限(如onlyOwner)。 - 正确处理小数 :ERC-20 使用
decimals变量处理小数,默认是 18。在交互时,注意单位转换(如parseUnits,formatUnits)。 - 充分测试:覆盖正常路径、边界情况和异常情况(如余额不足、未授权访问)。
- 考虑升级性:如果未来可能需要修复 bug 或添加功能,可以考虑使用可升级合约模式(如 OpenZeppelin Upgrades)。
- 主网部署前:务必在测试网上进行全面测试,并考虑进行第三方审计。
9. 总结
通过本文,你已完成了一个具备铸造和转账功能的完整 ERC-20 代币项目。你学会了:
- ERC-20 标准的核心。
- 基于 OpenZeppelin 编写安全合约。
- 使用 Hardhat 进行开发、测试和部署。
- 实现并测试了核心的铸造与转账逻辑。
下一步,你可以尝试为代币添加更多功能,如销毁(Burn)、暂停(Pausable)、快照(Snapshot)或实现代币销售(ICO)逻辑。区块链开发的世界广阔而有趣,祝你探索愉快!
相关资源: