ERC-20:用 Solidity 和 Foundry 编写第一个 Token
1. 概述
ERC-20 是以太坊上最流行的 Token 标准。USDC、DAI、LINK 和数千个其他 Token 都遵循它。在本文中,你将自己编写一个最小化的 ERC-20 Token 来理解标准的工作原理,然后使用 Foundry 编写测试。接下来,你将转向 OpenZeppelin 的实现以用于生产环境,并将 Token 部署到本地区块链。
2. 什么是 ERC-20?
ERC-20 是一个 接口,即每个 Token 都必须具有的一组函数和事件。由于这个通用接口,钱包、交易所和 DeFi 合约可以以相同的方式与任何 Token 交互,而不需要了解 Token 的内部实现。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
这个接口可以分成两个组:
- 直接转账:
transfer将 Token 从调用者(msg.sender)转账到to。 - 授权:
approve允许一个spender最多支出你的value个 Token。之后spender调用transferFrom来转出。这是 DEX 交易所在你交换时从你的钱包“提取” Token 的机制。
name()、symbol() 和 decimals() 三个函数属于 metadata 扩展。理论上它们不是强制的,但几乎每个 Token 都有。
3. 使用 Foundry 准备环境
本文使用 Foundry:你可以用 Solidity 编写合约和测试,并且测试运行非常快。安装 Foundry 后,创建项目:
forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts
forge install 命令将 OpenZeppelin 库下载到 lib/ 目录。你需要声明 remapping 来使导入更简洁。将以下行添加到项目根目录的 remappings.txt 文件:
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
项目结构包括 src/ 用于合约、test/ 用于测试、script/ 用于部署脚本。你可以删除 forge init 创建的示例 Counter 文件。
4. 自己编写一个最小的 ERC-20
本部分的目的是 理解 ERC-20 标准,而不是用于实际应用。创建文件 src/SimpleToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SimpleToken {
string public name = "Cafe Token";
string public symbol = "CAFE";
uint8 public constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 initialSupply) {
totalSupply = initialSupply;
balanceOf[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender, initialSupply);
}
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
require(allowed >= amount, "授权额度不足");
allowance[from][msg.sender] = allowed - amount;
_transfer(from, to, amount);
return true;
}
function _transfer(address from, address to, uint256 amount) internal {
require(to != address(0), "零地址");
require(balanceOf[from] >= amount, "余额不足");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
}
值得注意的几点:
public状态变量自动生成 getter。 声明mapping(...) public balanceOf会生成balanceOf(address)函数,其签名与接口中的完全匹配。allowance也是如此。decimals = 18。 区块链没有小数点数据类型,所以 Token 将所有余额以整数形式存储。当钱包显示“1.5 CAFE”时,合约中的实际值是1.5 * 10^18。- Constructor 从
address(0)发出Transfer事件。 这是表示“Token 被创建”(mint)的约定。各个区块浏览器根据这个事件来显示交易历史。 transferFrom在转账前减少授权额度。 如果不这样做,spender可以无限次地提取。- 不需要 SafeMath。 从 Solidity 0.8 开始,
-=操作在发生下溢时会自动回滚。不过,带有清晰错误消息的require仍然更有助于调试。
5. 使用 Foundry 编写测试
Foundry 中的测试是继承 Test 的合约。每个以 test 开头的函数都是一个测试用例。创建文件 test/SimpleToken.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {SimpleToken} from "../src/SimpleToken.sol";
contract SimpleTokenTest is Test {
event Transfer(address indexed from, address indexed to, uint256 value);
SimpleToken token;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
token = new SimpleToken(1_000 ether);
}
function test_transfer_movesBalance() public {
token.transfer(alice, 100 ether);
assertEq(token.balanceOf(alice), 100 ether);
assertEq(token.balanceOf(address(this)), 900 ether);
}
function test_transfer_emitsEvent() public {
vm.expectEmit(true, true, false, true);
emit Transfer(address(this), alice, 1 ether);
token.transfer(alice, 1 ether);
}
function test_transferFrom_requiresAllowance() public {
token.transfer(alice, 100 ether);
vm.prank(alice);
token.approve(bob, 30 ether);
vm.prank(bob);
token.transferFrom(alice, bob, 30 ether);
assertEq(token.balanceOf(bob), 30 ether);
assertEq(token.allowance(alice, bob), 0);
vm.prank(bob);
vm.expectRevert(bytes("授权额度不足"));
token.transferFrom(alice, bob, 1);
}
}
各部分解释:
setUp()在 每个 测试前重新运行。测试合约是 Token 的部署者,所以address(this)拥有全部 1000 个 Token。1_000 ether只是1000 * 10^18的简写。这里的ether关键字只是乘以1e18,与真实的 ETH 无关。makeAddr("alice")创建一个带标签的地址,这样测试失败时日志更易读。vm.prank(alice)使 下一个 调用的msg.sender为alice。vm.expectEmit(true, true, false, true)检查随后发出的事件中的 topic 1(from)、topic 2(to)和数据部分(value)。vm.expectRevert断言下一个调用必须以指定的错误消息回滚。
运行测试:
forge test -vv
Ran 3 tests for test/SimpleToken.t.sol:SimpleTokenTest
[PASS] test_transferFrom_requiresAllowance() (gas: 79120)
[PASS] test_transfer_emitsEvent() (gas: 41871)
[PASS] test_transfer_movesBalance() (gas: 43552)
Suite result: ok. 3 passed; 0 failed; 0 skipped
根据编译器版本,你机器上的 gas 值可能略有不同。
6. 使用 OpenZeppelin 实现真实 Token
上面自己写的版本虽然能运行,但未经审计。对于生产环境的 Token,你应该继承 OpenZeppelin 的 ERC20。这个库已在数十亿美元的流通价值上得到验证。创建文件 src/CafeToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract CafeToken is ERC20, Ownable {
constructor(address initialOwner)
ERC20("Cafe Token", "CAFE")
Ownable(initialOwner)
{
_mint(initialOwner, 1_000_000 * 10 ** decimals());
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
仅用大约十行代码,你就拥有了一个完整的 Token:
ERC20("Cafe Token", "CAFE")设置name和symbol。默认情况下decimals()返回 18。_mint增加totalSupply,将余额添加到接收者,并从address(0)发出Transfer事件。Ownable(initialOwner)(OpenZeppelin v5)要求显式传递 owner。onlyOwner修饰符阻止其他人调用mint。
OpenZeppelin v5 使用 自定义错误 代替 revert 字符串。因此测试也必须按错误选择器进行检查。创建文件 test/CafeToken.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {CafeToken} from "../src/CafeToken.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract CafeTokenTest is Test {
CafeToken token;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
function setUp() public {
token = new CafeToken(owner);
}
function test_initialSupply_goesToOwner() public view {
assertEq(token.totalSupply(), 1_000_000e18);
assertEq(token.balanceOf(owner), 1_000_000e18);
}
function test_transfer_revertsWhenBalanceTooLow() public {
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 1)
);
token.transfer(owner, 1);
}
function test_mint_onlyOwner() public {
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)
);
token.mint(alice, 1e18);
}
}
当转账回滚时,ERC20InsufficientBalance(sender, balance, needed) 告诉你确切地谁缺少余额以及缺少多少。这些信息比模糊的错误字符串有用得多。
7. 部署到本地网络并交互
Foundry 内置 anvil,一个运行本地 Ethereum 节点的工具,具有 10 个预加载 ETH 的账户。打开另一个终端并运行:
anvil
在另一个终端中,使用 anvil 的第一个账户部署 Token。这个私钥是公开的,仅用于本地网络:
export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
forge create src/CafeToken.sol:CafeToken \
--rpc-url http://127.0.0.1:8545 \
--private-key $PK \
--broadcast \
--constructor-args $OWNER
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x...
使用 cast 来读取余额和转账 Token:
export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3
cast call $TOKEN "balanceOf(address)(uint256)" $OWNER --rpc-url http://127.0.0.1:8545
cast send $TOKEN "transfer(address,uint256)" \
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 50ether \
--private-key $PK --rpc-url http://127.0.0.1:8545
1000000000000000000000000 [1e24]
cast call 只读取数据,不消耗 gas。cast send 在 anvil 上创建真实交易。参数 50ether 被 cast 自动转换为 50 * 10^18。
8. 常见错误和注意事项
注意: 不要自己为生产环境编写 ERC-20。第 4 部分的版本仅用于学习。对于真实 Token,继承 OpenZeppelin 并仅添加你的自定义逻辑。
- 混淆
decimals。 调用transfer(to, 100)只转账100 * 10^-18个 Token,几乎为零。始终乘以10 ** decimals()。相反,USDC 使用 6 decimals 而不是 18,所以在与他人的 Token 交互时不要硬编码1e18。 approve的竞态条件。 假设你将授权从 100 降低到 50。恶意的 spender 可以监视内存池,在新命令被确认前提取 100,然后再提取 50。安全的做法是先将授权设为 0,或使用支持permit的 Token(ERC-2612)。- 不符合标准的 Token。 一些旧 Token 如 USDT 不从
transfer返回bool。当你的合约调用外部 Token 时,使用SafeERC20库(using SafeERC20 for IERC20;然后调用safeTransfer)而不是直接调用transfer。 - 转账到合约的 Token 被卡住。 ERC-20 没有通知接收方的机制。如果有人向一个没有提取函数的合约发送 Token,这些 Token 将永久锁定。
- Fee-on-transfer 和 rebasing Token。 有些 Token 接收的数量少于发送的数量,或者余额随时间自动变化。如果你的合约接收任意 Token,请在转账前后测量余额,而不是相信
amount参数。 - 无限制的
mint。 带有onlyOwner的mint函数意味着 owner 可以无限地创建 Token。如果你想要固定总供应量,删除此函数或设置上限,例如使用ERC20Capped。
9. 结论
在本文中,你了解了 ERC-20 接口,并自己实现了一个最小化的 Token,以清晰地看到 balanceOf、allowance、transfer 和 transferFrom 如何协同工作。你还使用 Foundry 编写了测试,包括 vm.prank、vm.expectEmit 和 vm.expectRevert。随后,你转向基于 OpenZeppelin 的 CafeToken,部署到 anvil,并通过 cast 进行交互。
合理的下一步是尝试扩展功能,如 ERC20Permit 用于签名授权、ERC20Burnable 用于销毁 Token,或 ERC20Capped 用于限制总供应。你还应该练习使用 SafeERC20 安全地编写接收 Token 的合约。