开发教程

从零开发 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
foundryup

2. 初始化项目

forge init my-erc20
cd my-erc20

3. 目录结构

my-erc20/
├── src/           # Contract source code
├── test/          # Test files
├── script/        # Deploy scripts
├── lib/           # Dependencies
└── foundry.toml   # Config file

3. IERC20 接口规范

ERC20 标准定义了 6 个必需函数和 2 个事件。我们来看看接口定义:

函数说明返回值
totalSupply()返回总供应量uint256
balanceOf(address)查询地址余额uint256
transfer(address, uint256)转账给指定地址bool
approve(address, uint256)授权额度给 spenderbool
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)

执行流程

  1. 扣减发送者余额
  2. 增加接收者余额
  3. 发出 Transfer 事件
  4. 返回 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 coverage

Foundry 测试技巧

  • vm.prank(addr) - 下一次调用以 addr 身份执行
  • assertEq(a, b) - 断言 a == b
  • vm.expectRevert() - 预期下次调用会 revert
  • forge 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_key

3. 获取测试 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 \
  --verify

Arbitrum 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 本地节点

anvil

Anvil 会提供 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 0x00000000000000000000000000000000000000000000d3c21bcecceda1000000

Or pipe directly:

cast call <CONTRACT_ADDRESS> "totalSupply()" --rpc-url http://127.0.0.1:8545 | cast --to-dec

常用 Anvil 账户

序号地址私钥
00xf39Fd6e51aad88F6F4ce6aB8827279cffFb922660xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
10x70997970C51812dc3A010C7d01b50e0d17dc79C80x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
20x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a

这些都是 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 实现(已包含所有函数):

MyToken.sol
// 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;
    }
}