Solidity Grundlagen: Contract, State und Function

1. Überblick

Solidity ist die Primärsprache zum Schreiben von Smart Contracts auf Ethereum und EVM-kompatiblen Chains. Dieser Artikel hilft dir, die drei grundlegendsten Bausteine zu beherrschen: contract, State Variable und function — anhand eines einfachen Wallet-Contracts. Nach Abschluss dieses Artikels kannst du einen Contract schreiben, der den Zustand speichert, den Zugriff mit Modifiern kontrolliert und dein erstes Test mit Hardhat schreibst.

2. Was ist ein Contract?

In Solidity ist ein contract wie eine class in Java — es definiert den State (Daten) und das Behavior (Funktionen), die beim Deploy auf der Blockchain unter einer eigenen Adresse leben.

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

contract Wallet {
    // State Variable und Funktionen werden hier sein
}
  • Die Zeile // SPDX-License-Identifier deklariert eine Lizenz; der Compiler warnt, wenn sie fehlt.
  • pragma solidity ^0.8.24 sperrt die Compiler-Version ab 0.8.24 (aber unter 0.9.0).
  • Jeder Contract bekommt beim Deploy eine eigene Adresse (address), ähnlich wie ein „Objekt”, das auf der Chain für immer lebt.

Hinweis: Verwende niemals ein zu lockeres Pragma wie >=0.8.0 <0.9.0 in Production — ein neuer Compiler kann das Standardverhalten ändern (z. B. Overflow-Prüfung).

3. State Variable — On-Chain gespeicherte Daten

State Variable sind Variablen, die direkt in den Storage der Blockchain geschrieben werden, bestehen zwischen Funktionsaufrufen und verursachen Gas-Kosten beim Schreiben.

contract Wallet {
    address public owner;
    uint256 public balance;
    mapping(address => uint256) public allowance;

    constructor() {
        owner = msg.sender;
    }
}
  • address public owner: speichert die Adresse des Contract-Besitzers. Das Keyword public generiert automatisch einen kostenlosen Getter owner().
  • uint256 public balance: der Kontostand, ein 256-Bit unsigned Integer — der häufigste Zahlentyp in Solidity.
  • mapping(address => uint256): ähnlich wie HashMap<Address, BigInteger>, aber man kann die Keys nicht iterieren, nur nach Key nachschlagen.
  • constructor() wird genau einmal beim Deploy ausgeführt; msg.sender ist die Adresse, die die Deploy-Transaktion aufruft.

3.1. Storage, Memory und Calldata unterscheiden

function updateAllowance(address spender, uint256 amount) external {
    // amount befindet sich in calldata (read-only, günstiger Gas)
    allowance[spender] = amount; // schreibe in storage
}
  • storage: dauerhaft auf der Chain gespeicherte Daten (State Variable).
  • memory: temporäre Daten während der Funktionsausführung, nach Funktionsende verloren.
  • calldata: ähnlich wie memory, aber read-only, verwendet für Parameter von external-Funktionen — sparsamer bei Gas als memory.

Hinweis: Das Vergessen der Deklaration von memory/calldata für Parameter vom Typ array, struct, string führt zu einem Compiler-Fehler — anders als Java, wo man sich nicht um den Speicher kümmern muss.

4. Functions und Sichtbarkeit

Funktionen in Solidity müssen explizit eine Sichtbarkeit deklarieren; es gibt keine Standard-„package-private” wie in Java.

contract Wallet {
    address public owner;
    uint256 public balance;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        balance += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(msg.sender == owner, "Wallet: nicht Owner");
        require(amount <= balance, "Wallet: unzureichender Saldo");

        balance -= amount;
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Wallet: Übertragung fehlgeschlagen");
    }

    function getBalance() external view returns (uint256) {
        return balance;
    }
}
  • external: kann nur von außerhalb des Contracts aufgerufen werden (z. B. MetaMask Wallet, anderer Contract) — günstiger bei Gas als public, wenn Parameter große Arrays/Strings sind.
  • payable: ermöglicht der Funktion, ETH mit einer Transaktion zu empfangen; msg.value ist der in Wei gesendete Betrag.
  • view: verspricht, den State nicht zu ändern, verursacht keine Gas-Kosten, wenn off-chain aufgerufen (via eth_call).
  • require(condition, "message"): prüft eine Bedingung, bricht die gesamte Transaktion mit einer Nachricht ab, wenn falsch — ähnlich wie das Werfen von IllegalArgumentException, aber es wird auch der State und das verbrauchte Gas (außer der Gas für die Prüfung selbst) zurückgesetzt.
  • owner.call{value: amount}(""): die empfohlene Methode zum Senden von ETH gegenüber transfer()/send(), da sie nicht auf 2300 Gas begrenzt ist.

Vier Sichtbarkeitsstufen:

Visibility Aufruf von innen Aufruf von Subcontract Aufruf von außen
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌ (muss this.foo() verwenden) ❌ ✅

5. Modifier — Wiederverwendung von Prüfbedingungen

Die Wiederholung von require(msg.sender == owner, ...) in mehreren Funktionen deutet darauf hin, dass du es in einen Modifier auslagern solltest.

contract Wallet {
    address public owner;
    uint256 public balance;

    modifier onlyOwner() {
        require(msg.sender == owner, "Wallet: nicht Owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= balance, "Wallet: unzureichender Saldo");
        balance -= amount;
        payable(owner).transfer(amount);
    }
}
  • modifier onlyOwner() definiert einen Prüfblock; _; ist die Stelle, an der der ursprüngliche Funktionskörper eingefügt wird.
  • Das Anhängen von onlyOwner nach der withdraw-Deklaration lässt den Code wie „nur der Wallet-Besitzer kann Geld abheben” klingen — klarer als wiederholte require-Anweisungen in jeder Funktion.
  • Modifier können Parameter empfangen, z. B. modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }.

6. Events — Logging für Off-Chain

Ein Contract kann historische Daten nicht direkt an das Frontend zurückgeben; event dient dazu, Logs zu schreiben, die dApps abhören können.

contract Wallet {
    event Deposited(address indexed from, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);

    uint256 public balance;

    function deposit() external payable {
        balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
}
  • event Deposited(...) deklariert eine Log-Struktur; maximal 3 Parameter können indexed sein, um das Filtern zu erleichtern.
  • emit Deposited(...) schreibt ein Log in die Transaction Receipt; die Gas-Kosten sind viel günstiger als das Speichern in Storage.
  • Frontend kann mit einer Bibliothek wie ethers.js auf contract.on("Deposited", ...) abhören, um die UI in Echtzeit zu aktualisieren.

7. Häufige Anfängerfehler beim Lernen von Solidity

  • Verwechselung von transfer/send/call beim Senden von ETH: transfer ist auf 2300 Gas begrenzt und kann bei einem empfangenden Contract mit komplexer Logik (Proxy, Multisig) fehlschlagen. Verwende call mit require-Überprüfung des Ergebnisses wie im Beispiel in Abschnitt 4.
  • Vergessen von require-Überprüfungen bei Input: fehlende Validierung macht den Contract anfällig für Exploits (z. B. negative Abhebungen, Overflow — obwohl Solidity 0.8+ automatisch bei Overflow bricht, erfordert die Geschäftslogik immer noch separate Validierung).
  • Falsche Unterscheidung zwischen external und public: Das Verwenden von public für Funktionen, die nur von außen aufgerufen werden, verbraucht mehr Gas, wenn Parameter Arrays/Strings sind, da Solidity die Daten in memory kopieren muss, statt sie direkt aus calldata zu lesen.
  • Nicht view/pure deklarieren, wenn möglich: Tools wie Block Explorer und Wallets können dann nicht erkennen, dass die Funktion nur lesend ist, und Benutzer denken fälschlicherweise, dass Gas erforderlich ist.

Wann sollte eine Funktion NICHT public sein: Wenn eine Funktion niemals von einem anderen Contract oder einer erbenden Klasse aufgerufen wird, verwende external, um Gas zu sparen und die Angriffsfläche (Attack Surface) zu begrenzen.

8. Tests mit Hardhat schreiben

Füge Abhängigkeiten zum Hardhat-Projekt (Node.js) hinzu:

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

Test das Verhalten von withdraw, das nur dem Owner erlaubt:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Wallet", function () {
  it("nur der Owner darf abheben", async function () {
    const [owner, stranger] = await ethers.getSigners();
    const Wallet = await ethers.getContractFactory("Wallet");
    const wallet = await Wallet.deploy();

    await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });

    await expect(
      wallet.connect(stranger).withdraw(ethers.parseEther("1"))
    ).to.be.revertedWith("Wallet: nicht Owner");

    await expect(
      wallet.connect(owner).withdraw(ethers.parseEther("1"))
    ).to.changeEtherBalance(owner, ethers.parseEther("1"));
  });
});
  • ethers.getContractFactory("Wallet") kompiliert und lädt das Artefakt des Contracts.
  • wallet.connect(stranger) simuliert eine von einer anderen Adresse als dem Owner gesendete Transaktion.
  • revertedWith("...") stellt fest, dass die Transaktion mit der genauen Nachricht aus require zurückgerollt wird.
  • changeEtherBalance prüft, dass der ETH-Saldo des Owners nach der Abhebung genau um 1 ETH steigt — ein üblicher Test statt nur den Rückgabewert zu überprüfen.

Tests ausführen:

npx hardhat test
Wallet
  ✔ nur der Owner darf abheben (120ms)

1 passing (120ms)

9. Fazit

Du hast gerade vier fundamentale Konzepte von Solidity durchlaufen: contract als Deployment-Einheit, State Variables, die On-Chain-Daten mit drei Speicherbereichen (Storage/Memory/Calldata) speichern, Funktionen mit Sichtbarkeit und Modifiern zur Zugriffskontrolle, und Events für die Kommunikation mit Off-Chain. Diese Konzepte bilden die Grundlage, um zu fortgeschritteneren Themen wie Vererbung, Interfaces oder den Standards ERC-20/ERC-721 überzugehen — alles wird auf genau diesen vier Bausteinen aufgebaut.

solidityblockchainsmart-contractethereumweb3