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-Identifierdeklariert eine Lizenz; der Compiler warnt, wenn sie fehlt. pragma solidity ^0.8.24sperrt 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.0in 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 Keywordpublicgeneriert automatisch einen kostenlosen Getterowner().uint256 public balance: der Kontostand, ein 256-Bit unsigned Integer — der häufigste Zahlentyp in Solidity.mapping(address => uint256): ähnlich wieHashMap<Address, BigInteger>, aber man kann die Keys nicht iterieren, nur nach Key nachschlagen.constructor()wird genau einmal beim Deploy ausgeführt;msg.senderist 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 wiememory, aber read-only, verwendet für Parameter vonexternal-Funktionen — sparsamer bei Gas alsmemory.
Hinweis: Das Vergessen der Deklaration von
memory/calldatafür Parameter vom Typarray,struct,stringfü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 alspublic, wenn Parameter große Arrays/Strings sind.payable: ermöglicht der Funktion, ETH mit einer Transaktion zu empfangen;msg.valueist der in Wei gesendete Betrag.view: verspricht, den State nicht zu ändern, verursacht keine Gas-Kosten, wenn off-chain aufgerufen (viaeth_call).require(condition, "message"): prüft eine Bedingung, bricht die gesamte Transaktion mit einer Nachricht ab, wenn falsch — ähnlich wie das Werfen vonIllegalArgumentException, 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übertransfer()/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
onlyOwnernach derwithdraw-Deklaration lässt den Code wie „nur der Wallet-Besitzer kann Geld abheben” klingen — klarer als wiederholterequire-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önnenindexedsein, 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/callbeim Senden von ETH:transferist auf 2300 Gas begrenzt und kann bei einem empfangenden Contract mit komplexer Logik (Proxy, Multisig) fehlschlagen. Verwendecallmitrequire-Ü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
externalundpublic: Das Verwenden vonpublicfür Funktionen, die nur von außen aufgerufen werden, verbraucht mehr Gas, wenn Parameter Arrays/Strings sind, da Solidity die Daten inmemorykopieren muss, statt sie direkt auscalldatazu lesen. - Nicht
view/puredeklarieren, 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
publicsein: Wenn eine Funktion niemals von einem anderen Contract oder einer erbenden Klasse aufgerufen wird, verwendeexternal, 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 ausrequirezurückgerollt wird.changeEtherBalanceprü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.