从零开发 ERC20 代币
使用 Foundry 逐步学习 ERC20 标准的每个函数、测试与部署
1. 什么是 ERC20
ERC20 是以太坊上最常用的代币标准(EIP-20)。它定义了一套通用接口,让所有代币都能用统一的方式进行转账、授权和查询余额。
为什么需要 ERC20
- 统一标准:钱包、交易所、DApp 都能识别
- 可互换性:每个代币价值相同(Fungible Token)
- 可组合性:轻松集成到 DeFi 协议
核心功能
transfer- 转账approve- 授权额度transferFrom- 代理转账balanceOf- 查询余额totalSupply- 总供应量
2. Foundry 初始化
Foundry 是用 Rust 编写的超快速 Solidity 开发工具链。我们用它来编译、测试和部署合约。
1. 安装 Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup2. 初始化项目
forge init my-erc20
cd my-erc203. 目录结构
my-erc20/
├── src/ # Contract source code
├── test/ # Test files
├── script/ # Deploy scripts
├── lib/ # Dependencies
└── foundry.toml # Config file3. IERC20 接口规范
ERC20 标准定义了 6 个必需函数和 2 个事件。我们来看看接口定义:
| 函数 | 说明 | 返回值 |
|---|---|---|
totalSupply() | 返回总供应量 | uint256 |
balanceOf(address) | 查询地址余额 | uint256 |
transfer(address, uint256) | 转账给指定地址 | bool |
approve(address, uint256) | 授权额度给 spender | bool |
allowance(address, address) | 查询授权额度 | uint256 |
transferFrom(address, address, uint256) | 代理转账 | bool |
事件
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);4. 状态变量
ERC20 代币需要存储代币信息、余额和授权额度。我们用以下状态变量:
// State Variables
string public name; // Token name
string public symbol; // Token symbol
uint8 public decimals; // Decimal places, usually 18
uint256 public totalSupply; // Total supply
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;基本信息
name- 代币全称,如 "Uniswap"symbol- 代币简称,如 "UNI"decimals- 小数位,通常 18(与 ETH 一致)
余额与授权
balanceOf- 每个地址的余额allowance- 二维映射:owner → spender → 额度totalSupply- 总发行量
5. 构造函数
构造函数在部署时执行一次,用于初始化代币信息并铸造初始供应量。
constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
name = _name;
symbol = _symbol;
decimals = 18;
// Mint initial supply to deployer
totalSupply = _initialSupply * 10 ** decimals;
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}关键点
- 初始供应量乘以 10^18 是因为 decimals = 18
- 所有代币铸造给 msg.sender(部署者)
- 发出 Transfer 事件(从 0 地址 → 部署者)
6. transfer 转账
transfer 是最常用的函数,用于将代币从调用者转给接收者。
function transfer(address _to, uint256 _value) public returns (bool success) {
require(_to != address(0), "Cannot transfer to zero address");
require(balanceOf[msg.sender] >= _value, "Insufficient balance");
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}安全检查
- 接收地址不能为 0x0(避免烧币)
- 发送者余额必须 ≥ 转账金额
- 使用 require 回滚事务(不是返回 false)
执行流程
- 扣减发送者余额
- 增加接收者余额
- 发出 Transfer 事件
- 返回 true
7. approve 授权
approve 允许其他地址(如 DeFi 合约)代你花费一定额度的代币。这是 DeFi 可组合性的基础。
function approve(address _spender, uint256 _value) public returns (bool success) {
require(_spender != address(0), "Cannot approve zero address");
allowance[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}典型使用场景
- 在 Uniswap 交易前,先 approve Router 合约
- 存款到 Aave 前,先 approve Pool 合约
- 授权额度 = 你允许对方最多花费的金额
⚠️ 安全提示:修改非零 allowance 时,应先 approve(spender, 0) 再设置新值,避免 front-running 攻击。OpenZeppelin 的 increaseAllowance / decreaseAllowance 更安全。
8. transferFrom 代理转账
transferFrom 允许被授权的地址从 owner 转账给第三方。DeFi 协议就是用这个函数来操作你的代币。
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
require(_from != address(0), "Cannot transfer from zero address");
require(_to != address(0), "Cannot transfer to zero address");
require(balanceOf[_from] >= _value, "Insufficient balance");
require(allowance[_from][msg.sender] >= _value, "Allowance exceeded");
balanceOf[_from] -= _value;
balanceOf[_to] += _value;
allowance[_from][msg.sender] -= _value;
emit Transfer(_from, _to, _value);
return true;
}三方关系
_from- 代币所有者msg.sender- 被授权的调用者_to- 接收者
安全检查
- 发送者和接收者都不能为 0x0
- 发送者余额 ≥ 转账金额
- allowance ≥ 转账金额
- 扣减 allowance(防止重复花费)
9. Foundry 测试
Foundry 的测试用 Solidity 编写,速度极快。我们为每个函数写测试用例。
创建测试文件
test/MyToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken token;
address alice = address(0x1);
address bob = address(0x2);
function setUp() public {
token = new MyToken("My Token", "MTK", 1000000);
}
function testInitialSupply() public {
assertEq(token.totalSupply(), 1000000 * 10**18);
assertEq(token.balanceOf(address(this)), 1000000 * 10**18);
}
function testTransfer() public {
token.transfer(alice, 100 * 10**18);
assertEq(token.balanceOf(alice), 100 * 10**18);
}
function testApprove() public {
token.approve(alice, 50 * 10**18);
assertEq(token.allowance(address(this), alice), 50 * 10**18);
}
function testTransferFrom() public {
token.transfer(alice, 100 * 10**18);
vm.prank(alice);
token.approve(bob, 50 * 10**18);
vm.prank(bob);
token.transferFrom(alice, bob, 30 * 10**18);
assertEq(token.balanceOf(bob), 30 * 10**18);
assertEq(token.allowance(alice, bob), 20 * 10**18);
}
}运行测试
forge test -vv查看覆盖率
forge coverageFoundry 测试技巧
vm.prank(addr)- 下一次调用以 addr 身份执行assertEq(a, b)- 断言 a == bvm.expectRevert()- 预期下次调用会 revertforge test -vvvv- 显示详细 trace
10. 部署到 Sepolia 测试网
Sepolia 是以太坊官方测试网,我们先在这里部署和测试。
1. 创建部署脚本
script/DeployMyToken.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../src/MyToken.sol";
contract DeployMyToken is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
MyToken token = new MyToken("My Token", "MTK", 1000000);
console.log("Token deployed at:", address(token));
vm.stopBroadcast();
}
}2. 配置环境变量
创建 .env 文件:
PRIVATE_KEY=your_private_key_here
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_KEY
ETHERSCAN_API_KEY=your_etherscan_api_key3. 获取测试 ETH
从以下水龙头领取 Sepolia ETH:
4. 部署
source .env
forge script script/DeployMyToken.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--broadcast \
--verify部署成功后,你会看到合约地址。复制它到 Etherscan 查看。
11. 部署到 Base / Arbitrum 主网
测试完成后,可以部署到 Base 或 Arbitrum 主网。步骤与 Sepolia 相同,只需切换 RPC。
Base Mainnet
RPC: https://mainnet.base.org
Chain ID: 8453
Explorer: basescan.org部署命令:
forge script script/DeployMyToken.s.sol \
--rpc-url https://mainnet.base.org \
--broadcast \
--verifyArbitrum One
RPC: https://arb1.arbitrum.io/rpc
Chain ID: 42161
Explorer: arbiscan.io部署命令:
forge script script/DeployMyToken.s.sol \
--rpc-url https://arb1.arbitrum.io/rpc \
--broadcast \
--verify💡 成本对比
| 网络 | 部署成本 | 转账成本 |
|---|---|---|
| Ethereum Mainnet | ~$50-200 | ~$5-20 |
| Base | ~$0.10-0.50 | ~$0.01-0.05 |
| Arbitrum One | ~$0.50-2 | ~$0.05-0.20 |
💡 建议:如果是学习或小型项目,优先选择 Base(成本最低,生态活跃)
12. 本地调试与手动测试
使用 Anvil 本地节点和 Cast 命令行工具,手动调试和测试合约的每个功能。
环境准备
启动 Anvil 本地节点
anvilAnvil 会提供 10 个测试账户和对应私钥,每次启动地址和私钥都固定,方便测试。
部署合约
⚠️ 关键注意
--broadcast 必须放在 --constructor-args 前面,否则 constructor-args 会把后面所有参数吃掉导致部署失败。
forge create src/MyToken.sol:MyToken \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--rpc-url http://127.0.0.1:8545 \
--broadcast \
--constructor-args "Music" "MSC" 1000000成功后会返回
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x5624eae8...记下 Deployed to 的合约地址,后续所有调用都需要它。
用 cast 手动调用合约
查询类(只读,不消耗 gas)
这些调用不会修改链上状态,只是读取数据
# Query totalSupply
cast call <CONTRACT_ADDRESS> "totalSupply()" \
--rpc-url http://127.0.0.1:8545
# Query balance of address
cast call <CONTRACT_ADDRESS> "balanceOf(address)" <WALLET_ADDRESS> \
--rpc-url http://127.0.0.1:8545
# Query token name
cast call <CONTRACT_ADDRESS> "name()" \
--rpc-url http://127.0.0.1:8545
# Query allowance
cast call <CONTRACT_ADDRESS> "allowance(address,address)" <OWNER> <SPENDER> \
--rpc-url http://127.0.0.1:8545写入类(需要私钥签名,消耗 gas)
这些调用会修改链上状态,需要私钥签名并消耗 gas
# Transfer
cast send <CONTRACT_ADDRESS> "transfer(address,uint256)" <TO_ADDRESS> <AMOUNT> \
--private-key <PRIVATE_KEY> \
--rpc-url http://127.0.0.1:8545
# Approve
cast send <CONTRACT_ADDRESS> "approve(address,uint256)" <SPENDER_ADDRESS> <AMOUNT> \
--private-key <PRIVATE_KEY> \
--rpc-url http://127.0.0.1:8545
# TransferFrom
cast send <CONTRACT_ADDRESS> "transferFrom(address,address,uint256)" <FROM> <TO> <AMOUNT> \
--private-key <PRIVATE_KEY> \
--rpc-url http://127.0.0.1:8545实际操作示例
以合约地址 0x5FbDB2315678afecb367f032d93F642f64180aa3 为例
# 1. Query totalSupply
cast call 0x5FbDB2315678afecb367f032d93F642f64180aa3 "totalSupply()" \
--rpc-url http://127.0.0.1:8545
# Returns: 0x00000000000000000000000000000000000000000000d3c21bcecceda1000000
# (1000000 * 10^18)
# 2. Transfer 100 tokens from account0 to account1
cast send 0x5FbDB2315678afecb367f032d93F642f64180aa3 \
"transfer(address,uint256)" \
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
100000000000000000000 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--rpc-url http://127.0.0.1:8545
# 3. Verify account1 balance
cast call 0x5FbDB2315678afecb367f032d93F642f64180aa3 \
"balanceOf(address)" \
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--rpc-url http://127.0.0.1:8545
# 4. Account0 approves account1 to spend 50 tokens
cast send 0x5FbDB2315678afecb367f032d93F642f64180aa3 \
"approve(address,uint256)" \
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
50000000000000000000 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--rpc-url http://127.0.0.1:8545
# 5. Account1 uses allowance to transfer
cast send 0x5FbDB2315678afecb367f032d93F642f64180aa3 \
"transferFrom(address,address,uint256)" \
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC \
50000000000000000000 \
--private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
--rpc-url http://127.0.0.1:8545返回值解码
cast 返回的是十六进制,转成十进制
cast --to-dec 0x00000000000000000000000000000000000000000000d3c21bcecceda1000000Or pipe directly:
cast call <CONTRACT_ADDRESS> "totalSupply()" --rpc-url http://127.0.0.1:8545 | cast --to-dec常用 Anvil 账户
| 序号 | 地址 | 私钥 |
|---|---|---|
| 0 | 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 | 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 |
| 1 | 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 | 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d |
| 2 | 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC | 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a |
这些都是 anvil 固定的测试账户,每次启动都一样,只用于本地测试,私钥公开无所谓安全问题。
进阶调试技巧
使用 Chisel 快速测试
Chisel 是 Foundry 的 Solidity REPL,可以交互式执行 Solidity 代码
chisel
> uint256 a = 100 * 10**18;
> a
Type: uint256
└ Value: 100000000000000000000查看合约信息
使用 forge inspect 查看合约的详细信息
forge inspect MyToken abi
forge inspect MyToken methods
forge inspect MyToken storage查看事件日志
使用 cast logs 查询合约发出的事件
cast logs \
--address <CONTRACT_ADDRESS> \
'Transfer(address,address,uint256)' \
--rpc-url http://127.0.0.1:8545调试交易 Trace
使用 cast run 重放交易并查看详细的调用堆栈
cast run <TX_HASH> \
--rpc-url http://127.0.0.1:8545 \
--trace💡 实用技巧
cast --to-dec <hex>- 十六进制转十进制cast --to-wei <amount> <unit>- 转换为 Wei(如 1 ether)cast --from-wei <amount> <unit>- 从 Wei 转换cast block latest- 查看最新区块cast tx <hash>- 查看交易详情cast receipt <hash>- 查看交易回执(包含 gas 使用)cast sig 'transfer(address,uint256)'- 计算函数选择器cast keccak 'Transfer(address,address,uint256)'- 计算事件签名
完整源码
这是我们的完整 ERC20 实现(已包含所有函数):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title MyToken
* @dev ERC20 standard token implementation for educational purposes
*/
contract MyToken {
// ═══════════════════════════════════════════════════════════════════
// State Variables
// ═══════════════════════════════════════════════════════════════════
string public name; // Token name, e.g. "My Token"
string public symbol; // Token symbol, e.g. "MTK"
uint8 public decimals; // Decimal places, usually 18 (same as ETH)
uint256 public totalSupply; // Total supply
// address => balance
mapping(address => uint256) public balanceOf;
// owner => (spender => allowance)
mapping(address => mapping(address => uint256)) public allowance;
// ═══════════════════════════════════════════════════════════════════
// Events
// ═══════════════════════════════════════════════════════════════════
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// ═══════════════════════════════════════════════════════════════════
// Constructor
// ═══════════════════════════════════════════════════════════════════
/**
* @dev Initialize token
* @param _name Token name
* @param _symbol Token symbol
* @param _initialSupply Initial supply (will be multiplied by 10^18)
*/
constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
name = _name;
symbol = _symbol;
decimals = 18;
// Mint initial supply to deployer
totalSupply = _initialSupply * 10 ** decimals;
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
// ═══════════════════════════════════════════════════════════════════
// ERC20 Core Functions
// ═══════════════════════════════════════════════════════════════════
/**
* @dev Transfer tokens
* @param _to Recipient address
* @param _value Amount to transfer
* @return success Whether the transfer succeeded
*/
function transfer(address _to, uint256 _value) public returns (bool success) {
require(_to != address(0), "Cannot transfer to zero address");
require(balanceOf[msg.sender] >= _value, "Insufficient balance");
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
/**
* @dev Approve spender to spend tokens on your behalf
* @param _spender Address to be approved
* @param _value Allowance amount
* @return success Whether the approval succeeded
*/
function approve(address _spender, uint256 _value) public returns (bool success) {
require(_spender != address(0), "Cannot approve zero address");
allowance[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}
/**
* @dev Transfer tokens on behalf of owner
* Requires: caller must have sufficient allowance
* @param _from Sender address
* @param _to Recipient address
* @param _value Amount to transfer
* @return success Whether the transfer succeeded
*/
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
require(_from != address(0), "Cannot transfer from zero address");
require(_to != address(0), "Cannot transfer to zero address");
require(balanceOf[_from] >= _value, "Insufficient balance");
require(allowance[_from][msg.sender] >= _value, "Allowance exceeded");
balanceOf[_from] -= _value;
balanceOf[_to] += _value;
allowance[_from][msg.sender] -= _value;
emit Transfer(_from, _to, _value);
return true;
}
}