JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

從零手寫一個 ERC-20 代幣

代幣不過是一張「誰擁有多少」的試算表——而 ERC-20 正是讓每個錢包與交易所都讀得懂它的共通語言。你會用 Solidity 從零寫出一個正確的 ERC-20,精通 approve/transferFrom 的授權額度模式與它的陷阱,最後明白為何正式環境的程式碼都選擇繼承 OpenZeppelin。

同質化代幣到底是什麼

想像賭場的兌幣櫃台。你走過去、遞上現金,換回一疊一模一樣的 5 美元籌碼。每一枚 5 美元籌碼的價值都和其他的完全相等——沒人在乎你手上拿的是哪一枚,你也能把任何一枚遞給牌桌上任何人。這種可互換的特性就是「同質(fungible)」的意思,而同質化代幣正是這種籌碼的鏈上版本:一種價值單位,其中每一枚代幣的價值都和其他每一枚完全相等。

撥開神祕的外衣,一份代幣合約其實簡單到令人吃驚——它就是一張試算表。一欄放位址,另一欄放每個位址擁有多少代幣。傳送代幣並不是把某個硬幣搬到別處,而是合約改寫兩列資料:從你這裡扣掉、加到收款人那裡。這裡沒有檔案、沒有物件、也沒有真正的硬幣——只有一份智慧合約,維護著一本餘額帳本,並依它所執行的規則讓你重新排列它。

那為什麼需要「標準」?因為一個代幣唯有在別的軟體能讀取並搬動它時才有用。如果每個專案都自創一套函式名稱——這裡叫 sendCoins、那裡叫 pay——那麼每個錢包、交易所與 DeFi 應用都得為每一種代幣寫專屬的程式碼。ERC-20 就是終結這場混亂的約定:一小組固定的函式名稱與行為,讓以太坊上每一種同質化代幣都承諾去實作。實作了它們,整個生態系就能免費看懂你的代幣。與其說 ERC-20 是一段程式碼,不如說它是一套代幣標準——一份用函式簽章寫成的社會契約。

介面:六個函式與兩個事件

既然 ERC-20 是一份約定而非一個實作,看清它最乾淨的方式就是把它寫成一個 Solidity 介面——一串沒有函式主體的函式簽章。任何實作了以下全部內容的合約,就是一個 ERC-20 代幣,無論它的內部怎麼運作:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    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);

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}
ERC-20 的標準介面——實作這些,你就有了一個代幣

由上而下讀,這份設計本身就在說一個故事。三個view 函式負責回答問題:totalSupply(存在多少代幣)、balanceOf(這個位址持有多少)、以及 allowance(某個支用者可代他人搬動多少)。transfer 搬動你自己的代幣。而 approve + transferFrom 這一對才是巧妙之處——它讓第三方(例如一份交易所合約)在你授權下搬動你的代幣。我們會先講簡單情形,再推進到這一對。

接著是兩個事件。每一次代幣移動都會發出 Transfer,每一次權限變更都會發出 Approval。它們並非裝飾——它們是這套標準對外部世界的承諾。區塊鏈瀏覽器、錢包與記帳工具不會重跑你的合約;它們監看日誌串流,並從 Transfer 事件重建出餘額。少發了一個,你的代幣就會看似無聲無息地搬動了金錢,讓每一個監看它的工具全部失準。

打造核心:餘額與轉帳

現在開始實作,從不需要任何權限的部分起步:保管餘額、搬動你自己的代幣。整本帳本就是一個從位址到餘額的映射。把它宣告成 public 是個小技巧——Solidity 會自動生成一個 getter 函式,其簽章恰好就是標準要求的 balanceOf(address),於是這個狀態變數本身就是那個 view 函式。同樣的技巧也讓我們免費得到 totalSupply 與 allowance。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract MiniToken {
    string  public name     = "Mini Token";
    string  public symbol   = "MINI";
    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 value) public returns (bool) {
        require(to != address(0), "ERC20: transfer to the zero address");
        balanceOf[msg.sender] -= value;   // reverts on underflow in 0.8+
        balanceOf[to]         += value;
        emit Transfer(msg.sender, to, value);
        return true;
    }
}
最小 ERC-20 的核心:儲存、建構子與 transfer

來走一遍 transfer。它先檢查收款位址不是零位址(代幣常因此被意外銷毀),接著從呼叫者扣掉 value、加到收款人身上,再發出 Transfer。注意少了什麼:這裡沒有明寫一個「發送者餘額足夠」的 require。我們不需要。自 Solidity 0.8 起,算術預設是受檢查的——如果這個減法會讓數值低於零,這個下溢會被攔截,整筆交易自動回滾。

建構子執行了一次初始的鑄造(mint)。其實沒有什麼特殊的鑄造操作碼;鑄造不過是增加 totalSupply 並把額度記到某個帳戶上。依慣例,我們發出一個發送者欄位設為 address(0) 的 Transfer 事件——零位址代表「憑空而來」,索引器正是靠這個慣例來統計一種代幣的發行量。銷毀則是它的鏡像:送往 address(0) 並減少供給量。

approve 與 transferFrom:把支配權借出去

你自己是搬動代幣的人時,直接用 transfer 就好。但 DeFi 的整個重點,正是讓合約替你搬動代幣——一間去中心化交易所必須把你的代幣從錢包裡拉出來,才能完成一次兌換。合約沒辦法(謝天謝地)伸手進你的帳戶把代幣拿走。於是 ERC-20 借用了線下世界的一個點子:一張簽了名的授權單。你事先告訴代幣合約:「這間交易所最多可動用我 500 枚代幣。」這份被儲存起來的許可,就是一筆授權額度(allowance)

這個模式有兩個動作。首先你呼叫 approve(spender, amount),記下 spender 最多可搬動你 amount 枚代幣——這就是代幣授權這一步,它會發出 Approval。稍後,這位支用者(或協定路由經過的任何合約)呼叫 transferFrom(you, recipient, amount),搬動你的代幣從額度中扣除。以下是這三個函式;把它們加進上面的合約:

    function approve(address spender, uint256 value) public returns (bool) {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value)
        public
        returns (bool)
    {
        require(to != address(0), "ERC20: transfer to the zero address");
        allowance[from][msg.sender] -= value;   // reverts if allowance too low
        balanceOf[from]             -= value;    // reverts if balance too low
        balanceOf[to]               += value;
        emit Transfer(from, to, value);
        return true;
    }
授權額度的流程:approve 記下許可,transferFrom 動用它(allowance 本身就是那個 public 映射的自動 getter)

來追一遍這個雙方流程。假設你想在交易所合約 DEX 上兌換。(1) 你在代幣上呼叫 approve(DEX, 500)——現在你給 DEX 的額度是 500。(2) 你叫 DEX 兌換;在那次呼叫內部,DEX 呼叫 transferFrom(you, DEX, 500)。代幣檢查額度、把它扣到 0、把 500 枚從你那裡搬到 DEX,並發出 Transfer。兩個受檢查的減法守住了整件事:額度不足或餘額不足都會回滾。注意 transferFrom 是由支用者呼叫的,所以 msg.sender 是 DEX,而查詢的額度是以「持有者與呼叫者」為鍵的那一格——恰好就是 approve 寫入的那一格。

陷阱所在:授權競態與會說謊的代幣

approve 函式有個著名的弊病。它覆寫額度,而不是調整額度,這就製造出一個被稱為授權競態條件(approval race condition)的搶先交易空窗。假設 spender 原本已有你給的 100 額度,而你決定把它降到 50。你送出 approve(spender, 50)。一個盯著記憶池的惡意支用者,可以在你的 approve 生效之前搶先送出一筆動用舊額度 100 的 transferFrom,接著在它生效之後再花掉 50——結果在你只想給 50 的情況下被取走了 150。

教科書式的緩解辦法,是絕不一步到位地變更一個非零額度:先 approve(spender, 0)、確認後,再 approve(spender, newAmount)。許多實作也加上了非標準的 increaseAllowance / decreaseAllowance 輔助函式,用增量調整取代覆寫,藉此繞過競態。更現代、更乾淨的答案是 EIP-2612 permit,它用單一一條簽名訊息取代了那筆獨立的 approve 交易——更少的交易、更窄的空窗。實務上這個競態多少有點理論性,現實中更大的危險其實是相反的習慣:圖方便而授權無上限的額度,然後就忘了它。

還有第二個、更陰險的陷阱,它就藏在那看似人畜無害的 returns(bool) 裡。標準說 transfer 與 approve 應該回傳一個布林值,而呼叫者應該檢查它。但歷史是一團亂。一些極受歡迎的代幣——Tether 的 USDT 是經典案例——是在布林值慣例底定之前寫成的,它們什麼也不回傳。另一些則在失敗時回傳 false 而非回滾,於是忽略回傳值的程式碼會若無其事地繼續,彷彿轉帳成功了。要是你打造的協定假設每種代幣都規規矩矩,只要一種行為不端的代幣,就能悄悄搞壞你的整本帳。

這正是 OpenZeppelin 提供一個叫 SafeERC20 的輔助函式庫的原因。它的 safeTransfer、safeTransferFrom 與 forceApprove 把原始呼叫包了起來:它們能處理什麼都不回傳的代幣,並在代幣回傳 false 時回滾,讓每一種代幣都呈現單一、合理的行為。會接觸到任意代幣的正式環境程式碼,其準則是:絕不直接呼叫 transfer 或 transferFrom,一律走 SafeERC20。

站在巨人肩上:OpenZeppelin 與中繼資料

在把鑰匙交給函式庫之前,先誠實交代一個關於我們合約頂端那些 name、symbol、decimals 欄位的細節:它們是選用的中繼資料,並不屬於那必備的六個函式。decimals 純粹是裝飾性的。合約實際上永遠只儲存整數;decimals = 18 只是告訴錢包:把儲存的數字的小數點往左移 18 位再顯示。所以「1 枚代幣」在 18 位小數下,儲存的是 1000000000000000000。18 是近乎通用的慣例(它呼應了 1 ether 等於 10^18 wei),但少數知名代幣並不一樣——USDC 與 USDT 用的是 6——而把 18 寫死的程式碼會把它們的價格算錯。

我們親手寫出每一行,是為了搞懂它,但你幾乎永遠不該上線一個手刻的 ERC-20。OpenZeppelin 的參考實作經過稽核、在數兆美元的價值流動中歷經實戰,並處理好了那些容易在不知不覺中寫錯的邊角情形——零位址檢查、事件正確性、給擴充用的掛鉤。在真實的程式碼裡,你會繼承它。靠著 Solidity 繼承,一個完整、正確、達到正式環境水準的代幣,只要短短幾行:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
    constructor() ERC20("My Token", "MTK") {
        // mint one million tokens to the deployer
        _mint(msg.sender, 1_000_000 * 10 ** decimals());
    }
}
正式環境的做法:繼承 OpenZeppelin 經過稽核的 ERC20

就這麼多。ERC20 提供了 transfer、approve、transferFrom、那兩個餘額與額度映射、那些事件,以及預設為 18 的 decimals()。你只需提供你的代幣獨有的部分:名稱、代號,以及初始供給如何鑄造。內部的 _mint 做的事,和我們建構子親手做的一模一樣——增加 totalSupply、把額度記到帳戶上、發出一個來自零位址的 Transfer。從這裡開始你就能組合搭配:加上 ERC20Burnable 來銷毀、加上 ERC20Permit 來做免 gas 授權、加上 Ownable 搭配一個 mint 函式來做受控供給。