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) zu to.
  • Genehmigung: approve erlaubt einem spender, maximal value Tokens von dir auszugeben. Dann ruft spender transferFrom auf, 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:

  • public State-Variablen generieren automatisch getter. Die Deklaration von mapping(...) public balanceOf erzeugt eine Funktion balanceOf(address), die exakt die Signatur im interface erfüllt. allowance funktioniert 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 Contract 1.5 * 10^18.
  • Der Constructor gibt ein event Transfer von address(0) ab. Das ist eine Konvention, um “Token wurden erzeugt” zu signalisieren (Mint). Block-Explorer verwenden diesen event, um die Transaktionshistorie anzuzeigen.
  • transferFrom zieht die Genehmigung ab, bevor es überweist. Wenn nicht, könnte spender unbegrenzt abheben.
  • Keine SafeMath erforderlich. Seit Solidity 0.8 revert -= automatisch bei Underflow. Trotzdem helfen require mit 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ält address(this) alle 1000 Tokens.
  • 1_000 ether ist nur eine Schreibweise für 1000 * 10^18. Das Schlüsselwort ether multipliziert hier nur mit 1e18 und 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 Aufruf msg.sender als alice hat.
  • 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.expectRevert bestä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") setzt name und symbol. Standardmäßig gibt decimals() 18 zurück.
  • _mint erhöht totalSupply, addiert den Saldo zum Empfänger und gibt ein Transfer-event von address(0) ab.
  • Ownable(initialOwner) (OpenZeppelin v5) verlangt die explizite Übergabe des Owners. Der modifier onlyOwner blockiert andere von der mint-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 Aufruf transfer(to, 100) überweist nur 100 * 10^-18 Token, praktisch 0. Multipliziere immer mit 10 ** decimals(). Umgekehrt verwendet USDC 6 Dezimalstellen, nicht 18, also hard-code nicht 1e18, 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, der permit unterstützt (ERC-2612).
  • Token halten sich nicht an den Standard. Einige ältere Token wie USDT geben kein bool von transfer zurück. Wenn dein Contract zu anderen Tokens aufruft, verwende die SafeERC20-Bibliothek (using SafeERC20 for IERC20; dann safeTransfer aufrufen) statt transfer direkt 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. Eine mint-Funktion mit onlyOwner bedeutet, 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 mit ERC20Capped.

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.

solidityerc-20ethereumfoundrysmart-contract