ERC-20: viết token đầu tiên với Solidity và Foundry
1. Tổng quan
ERC-20 là chuẩn token phổ biến nhất trên Ethereum. USDC, DAI, LINK và hàng nghìn token khác đều tuân theo nó. Trong bài này, bạn sẽ tự viết một token ERC-20 tối giản để hiểu chuẩn hoạt động ra sao, sau đó viết test bằng Foundry. Tiếp theo, bạn sẽ chuyển sang bản cài đặt của OpenZeppelin cho môi trường thật và deploy token lên một blockchain local.
2. ERC-20 là gì?
ERC-20 là một interface, tức một tập hàm và event mà mọi token phải có. Nhờ interface chung này, ví, sàn và các contract DeFi có thể làm việc với mọi token theo cùng một cách mà không cần biết bên trong token được viết thế nào.
// 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);
}
Có thể chia interface này thành hai nhóm:
- Chuyển trực tiếp:
transferchuyển token từ người gọi (msg.sender) sangto. - Uỷ quyền:
approvecho phép mộtspendertiêu tối đavaluetoken của bạn. Sau đóspendergọitransferFromđể rút. Đây là cơ chế giúp một sàn DEX “kéo” token từ ví bạn khi bạn swap.
Ba hàm name(), symbol() và decimals() thuộc phần mở rộng metadata. Về lý thuyết chúng không bắt buộc, nhưng gần như token nào cũng có.
3. Chuẩn bị môi trường với Foundry
Bài này dùng Foundry: bạn viết contract và test đều bằng Solidity, và test chạy rất nhanh. Sau khi cài Foundry, tạo project:
forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts
Lệnh forge install tải thư viện OpenZeppelin vào thư mục lib/. Bạn cần khai báo remapping để import ngắn gọn hơn. Thêm dòng sau vào file remappings.txt ở thư mục gốc:
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
Cấu trúc project gồm src/ cho contract, test/ cho test và script/ cho script deploy. Bạn có thể xoá các file Counter mẫu do forge init tạo ra.
4. Tự viết một ERC-20 tối giản
Mục đích của phần này là để hiểu chuẩn ERC-20, không phải để dùng thật. Tạo file 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 too low");
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), "zero address");
require(balanceOf[from] >= amount, "balance too low");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
}
Những điểm đáng chú ý:
publicstate variable tự sinh getter. Khai báomapping(...) public balanceOfsinh ra hàmbalanceOf(address), khớp đúng chữ ký trong interface.allowancecũng tương tự.decimals = 18. Blockchain không có số thập phân, nên token lưu mọi số dư dưới dạng số nguyên. Khi ví hiển thị “1.5 CAFE”, giá trị thật trong contract là1.5 * 10^18.- Constructor phát event
Transfertừaddress(0). Đây là quy ước để báo “token được tạo ra” (mint). Các block explorer dựa vào event này để hiển thị lịch sử giao dịch. transferFromtrừ allowance trước khi chuyển. Nếu không trừ,spendercó thể rút vô hạn lần.- Không cần SafeMath. Từ Solidity 0.8, phép
-=sẽ tự revert khi bị underflow. Dù vậy,requirevới thông điệp rõ ràng vẫn giúp debug dễ hơn.
5. Viết test với Foundry
Test trong Foundry là một contract kế thừa Test. Mỗi hàm bắt đầu bằng test là một test case. Tạo file 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("allowance too low"));
token.transferFrom(alice, bob, 1);
}
}
Giải thích từng phần:
setUp()chạy lại trước mỗi test. Contract test là bên deploy token, nênaddress(this)giữ toàn bộ 1000 token.1_000 etherchỉ là cách viết gọn của1000 * 10^18. Từ khoáetherở đây chỉ nhân với1e18, không liên quan tới ETH thật.makeAddr("alice")tạo một địa chỉ có nhãn, nên log khi test fail dễ đọc hơn.vm.prank(alice)khiến lời gọi kế tiếp cómsg.senderlàalice.vm.expectEmit(true, true, false, true)kiểm tra topic 1 (from), topic 2 (to) và phần data (value) của event phát ra ngay sau đó.vm.expectRevertkhẳng định lời gọi tiếp theo phải revert với đúng thông điệp đã nêu.
Chạy test:
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
Giá trị gas trên máy bạn có thể khác một chút tuỳ phiên bản compiler.
6. Dùng OpenZeppelin cho token thật
Bản tự viết ở trên đã chạy được, nhưng chưa được audit. Với token thật, bạn nên kế thừa ERC20 của OpenZeppelin. Thư viện này đã được kiểm chứng trên hàng tỷ USD giá trị lưu hành. Tạo file 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);
}
}
Chỉ khoảng chục dòng, bạn đã có một token đầy đủ:
ERC20("Cafe Token", "CAFE")thiết lậpnamevàsymbol. Mặc địnhdecimals()trả về 18._minttăngtotalSupply, cộng số dư cho người nhận và phát eventTransfertừaddress(0).Ownable(initialOwner)(OpenZeppelin v5) yêu cầu truyền owner một cách tường minh. ModifieronlyOwnerchặn người khác gọimint.
OpenZeppelin v5 dùng custom error thay cho chuỗi revert. Vì vậy test cũng phải kiểm tra theo error selector. Tạo file 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);
}
}
Khi transfer bị revert, ERC20InsufficientBalance(sender, balance, needed) cho bạn biết chính xác ai thiếu và thiếu bao nhiêu. Thông tin này hữu ích hơn nhiều so với một chuỗi chung chung.
7. Deploy lên mạng local và tương tác
Foundry có sẵn anvil, một node Ethereum chạy local với 10 tài khoản đã được nạp sẵn ETH. Mở một terminal riêng và chạy:
anvil
Ở terminal khác, deploy token bằng tài khoản đầu tiên của anvil. Private key này là khoá công khai, chỉ dùng cho mạng local:
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...
Dùng cast để đọc số dư và chuyển 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 chỉ đọc dữ liệu nên không tốn gas. cast send tạo transaction thật trên anvil. Đối số 50ether được cast tự đổi thành 50 * 10^18.
8. Lỗi hay gặp và lưu ý
Lưu ý: Đừng tự viết ERC-20 cho production. Bản trong mục 4 chỉ phục vụ việc học. Với token thật, hãy kế thừa OpenZeppelin và chỉ thêm phần logic riêng của bạn.
- Nhầm
decimals. Gọitransfer(to, 100)chỉ chuyển100 * 10^-18token, gần như bằng 0. Luôn nhân với10 ** decimals(). Ngược lại, USDC dùng 6 decimals chứ không phải 18, nên đừng hard-code1e18khi làm việc với token của người khác. - Race condition của
approve. Giả sử bạn đổi allowance từ 100 xuống 50. Một spender xấu có thể theo dõi mempool, rút 100 trước khi lệnh mới được xác nhận, rồi rút thêm 50. Cách an toàn là đặt allowance về 0 trước, hoặc dùng token hỗ trợpermit(ERC-2612). - Token không tuân chuẩn. Một số token cũ như USDT không trả về
booltừtransfer. Khi contract của bạn gọi tới token bên ngoài, hãy dùng thư việnSafeERC20(using SafeERC20 for IERC20;rồi gọisafeTransfer) thay vì gọitransfertrực tiếp. - Token gửi vào contract bị kẹt. ERC-20 không có cơ chế báo cho bên nhận. Nếu ai đó gửi token vào một contract không có hàm rút, số token đó sẽ bị khoá vĩnh viễn.
- Fee-on-transfer và rebasing token. Có những token mà số lượng nhận được ít hơn số lượng gửi đi, hoặc số dư tự thay đổi theo thời gian. Nếu contract của bạn nhận token bất kỳ, hãy đo số dư trước và sau khi chuyển, thay vì tin vào tham số
amount. mintkhông giới hạn. HàmmintvớionlyOwnernghĩa là owner có thể in thêm token vô hạn. Nếu bạn muốn tổng cung cố định, hãy bỏ hàm này hoặc đặt mức trần, ví dụ dùngERC20Capped.
9. Kết luận
Trong bài này, bạn đã tìm hiểu interface ERC-20 và tự cài đặt một token tối giản để thấy rõ cách balanceOf, allowance, transfer và transferFrom phối hợp với nhau. Bạn cũng đã viết test bằng Foundry với vm.prank, vm.expectEmit và vm.expectRevert. Sau đó, bạn chuyển sang CafeToken dựa trên OpenZeppelin, deploy lên anvil và tương tác qua cast.
Bước tiếp theo hợp lý là thử các extension như ERC20Permit để approve bằng chữ ký, ERC20Burnable để đốt token, hoặc ERC20Capped để giới hạn tổng cung. Bạn cũng nên luyện viết contract nhận token một cách an toàn với SafeERC20.