ERC-20: Schreibe deinen ersten Token mit Solidity und Foundry
1. Übersicht
ERC-20 ist der beliebteste Token-Standard auf Ethereum. USDC, DAI, LINK und Tausende weitere Tokens folgen ihm. In diesem Artikel wirst du einen minimalen ERC-20-Token selbst schreiben, um den Standard zu verstehen, dann Tests mit Foundry schreiben. Danach wirst du zur OpenZeppelin-Implementierung für echte Umgebungen wechseln und deinen Token auf einem lokalen Blockchain deployen.
2. Was ist ERC-20?
ERC-20 ist eine interface, also eine Menge von Funktionen und Events, die jeder Token haben muss. Dank dieser einheitlichen interface können Wallets, Börsen und DeFi-Contracts mit jedem Token auf die gleiche Weise arbeiten, ohne zu wissen, wie der Token intern geschrieben ist.
// 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);
}
Du kannst diese interface in zwei Gruppen aufteilen:
- Direkte Überweisung:
transferüberweist Token vom Aufrufer (msg.sender) zuto. - Genehmigung:
approveerlaubt einemspender, maximalvalueTokens von dir auszugeben. Dann ruftspendertransferFromauf, um zu überweisen. Das ist der Mechanismus, mit dem eine DEX-Börse Tokens aus deiner Wallet “zieht”, wenn du swappst.
Die drei Funktionen name(), symbol() und decimals() gehören zur Metadaten-Erweiterung. Theoretisch sind sie nicht erforderlich, aber praktisch hat fast jeder Token sie.
3. Umgebung mit Foundry vorbereiten
Dieser Artikel verwendet Foundry: Du schreibst Contract und Tests beide in Solidity, und Tests laufen sehr schnell. Nach der Installation von Foundry erstellst du ein Projekt:
forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts
Der Befehl forge install lädt die OpenZeppelin-Bibliothek in das Verzeichnis lib/. Du musst ein Remapping deklarieren, um kürzere Importe zu verwenden. Füge die folgende Zeile zur Datei remappings.txt im Stammverzeichnis hinzu:
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
Die Projektstruktur besteht aus src/ für Contracts, test/ für Tests und script/ für Deploy-Skripte. Du kannst die Sample-Dateien Counter löschen, die forge init erstellt hat.
4. Schreibe einen minimalen ERC-20 selbst
Der Zweck dieses Abschnitts ist es, den ERC-20-Standard zu verstehen, nicht ihn in der Praxis zu verwenden. Erstelle die Datei 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);
}
}
Beachtenswerte Punkte:
publicState-Variablen generieren automatisch getter. Die Deklaration vonmapping(...) public balanceOferzeugt eine FunktionbalanceOf(address), die exakt die Signatur im interface erfüllt.allowancefunktioniert genauso.decimals = 18. Die Blockchain hat keine Dezimalzahlen, daher speichert der Token alle Saldos als ganze Zahlen. Wenn eine Wallet “1,5 CAFE” anzeigt, ist der echte Wert im Contract1.5 * 10^18.- Der Constructor gibt ein event
Transfervonaddress(0)ab. Das ist eine Konvention, um “Token wurden erzeugt” zu signalisieren (Mint). Block-Explorer verwenden diesen event, um die Transaktionshistorie anzuzeigen. transferFromzieht die Genehmigung ab, bevor es überweist. Wenn nicht, könntespenderunbegrenzt abheben.- Keine SafeMath erforderlich. Seit Solidity 0.8 revert
-=automatisch bei Underflow. Trotzdem helfenrequiremit klaren Meldungen beim Debuggen.
5. Tests mit Foundry schreiben
Tests in Foundry sind Contracts, die von Test erben. Jede Funktion, die mit test beginnt, ist ein Testfall. Erstelle die Datei 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);
}
}
Erklärung der einzelnen Teile:
setUp()läuft vor jedem Test neu. Der Test-Contract ist der Deploy-Account des Tokens, daher hältaddress(this)alle 1000 Tokens.1_000 etherist nur eine Schreibweise für1000 * 10^18. Das Schlüsselwortethermultipliziert hier nur mit1e18und hat nichts mit echtem ETH zu tun.makeAddr("alice")erzeugt eine beschriftete Adresse, sodass Logs bei Testfehlern lesbarer sind.vm.prank(alice)bewirkt, dass der nächste Aufrufmsg.senderalsalicehat.vm.expectEmit(true, true, false, true)prüft Topic 1 (from), Topic 2 (to) und den Datenteil (value) des Events, das unmittelbar danach ausgegeben wird.vm.expectRevertbestätigt, dass der nächste Aufruf mit genau der angegebenen Nachricht revertieren muss.
Tests ausführen:
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
Die Gas-Werte auf deinem Computer können je nach Compiler-Version leicht abweichen.
6. OpenZeppelin für einen echten Token verwenden
Die Eigenimplementierung von oben funktioniert, ist aber nicht geprüft. Für echte Tokens solltest du ERC20 von OpenZeppelin erben. Diese Bibliothek wurde mit Milliarden US-Dollar an Wert überprüft. Erstelle die Datei 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);
}
}
Mit nur etwa zehn Zeilen hast du einen vollständigen Token:
ERC20("Cafe Token", "CAFE")setztnameundsymbol. Standardmäßig gibtdecimals()18 zurück._minterhöhttotalSupply, addiert den Saldo zum Empfänger und gibt einTransfer-event vonaddress(0)ab.Ownable(initialOwner)(OpenZeppelin v5) verlangt die explizite Übergabe des Owners. Der modifieronlyOwnerblockiert andere von dermint-Aufrufen.
OpenZeppelin v5 verwendet Custom Errors statt Revert-Strings. Deshalb müssen Tests auch nach Error-Selectoren prüfen. Erstelle die Datei 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);
}
}
Wenn Transfer fehlschlägt, gibt ERC20InsufficientBalance(sender, balance, needed) dir genau an, wer zu wenig hat und wie viel fehlt. Diese Information ist viel nützlicher als eine generische Nachricht.
7. Auf ein lokales Netzwerk deployen und interagieren
Foundry hat anvil eingebaut, einen lokalen Ethereum-Node mit 10 vorfinanzierten Accounts. Öffne ein separates Terminal und führe aus:
anvil
Deploye deinen Token in einem anderen Terminal mit dem ersten Account von anvil. Dieser Private Key ist öffentlich und nur für das lokale Netzwerk:
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...
Verwende cast, um Guthaben zu lesen und Tokens zu überweisen:
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 liest nur Daten, also kostet es kein Gas. cast send erstellt eine echte Transaktion auf anvil. Das Argument 50ether wird von cast automatisch in 50 * 10^18 konvertiert.
8. Häufige Fehler und Hinweise
Hinweis: Schreibe nicht dein eigenes ERC-20 für Production. Die Version aus Abschnitt 4 dient nur dem Lernen. Für echte Tokens solltest du OpenZeppelin erben und nur deine spezifische Logik hinzufügen.
- Falsches
decimals. Der Aufruftransfer(to, 100)überweist nur100 * 10^-18Token, praktisch 0. Multipliziere immer mit10 ** decimals(). Umgekehrt verwendet USDC 6 Dezimalstellen, nicht 18, also hard-code nicht1e18, wenn du mit Token anderer Leute arbeitest. - Race Condition von
approve. Angenommen, du senkst die Genehmigung von 100 auf 50. Ein böser spender kann den Mempool überwachen, 100 abheben, bevor der neue Befehl bestätigt wird, und dann noch 50 abheben. Sicherer ist es, die Genehmigung zuerst auf 0 zu setzen oder einen Token zu verwenden, derpermitunterstützt (ERC-2612). - Token halten sich nicht an den Standard. Einige ältere Token wie USDT geben kein
boolvontransferzurück. Wenn dein Contract zu anderen Tokens aufruft, verwende dieSafeERC20-Bibliothek (using SafeERC20 for IERC20;dannsafeTransferaufrufen) statttransferdirekt aufzurufen. - Tokens in Contract stecken fest. ERC-20 hat keinen Mechanismus, um den Empfänger zu benachrichtigen. Wenn jemand Tokens an einen Contract sendet, der keine Auszahlungsfunktion hat, werden diese Tokens auf ewig gesperrt.
- Fee-on-transfer und Rebasing-Tokens. Es gibt Tokens, bei denen die empfangene Menge kleiner ist als die gesendete Menge, oder bei denen sich der Saldo mit der Zeit ändert. Wenn dein Contract beliebige Tokens empfängt, miss den Saldo vor und nach der Überweisung statt auf dem
amount-Parameter zu vertrauen. - Unbegrenztes
mint. Einemint-Funktion mitonlyOwnerbedeutet, dass der Owner beliebig viele Tokens prägen kann. Wenn du einen festen Gesamtbestand haben möchtest, entferne diese Funktion oder setze eine Obergrenze, beispielsweise mitERC20Capped.
9. Fazit
In diesem Artikel hast du die ERC-20-interface kennengelernt und einen minimalen Token selbst implementiert, um zu sehen, wie balanceOf, allowance, transfer und transferFrom zusammenarbeiten. Du hast auch Tests mit Foundry mit vm.prank, vm.expectEmit und vm.expectRevert geschrieben. Danach bist du zu CafeToken basierend auf OpenZeppelin gewechselt, hast ihn auf anvil deployed und mit cast interagiert.
Der nächste logische Schritt ist, Erweiterungen wie ERC20Permit für signaturbasierte Approvals, ERC20Burnable zum Verbrennen von Tokens oder ERC20Capped zur Begrenzung der Gesamtmenge auszuprobieren. Du solltest auch üben, Contracts zu schreiben, die Tokens sicher mit SafeERC20 empfangen.