Blockchain in der Praxis: Smart Contracts auf Ethereum
Ähnlich wie bereits in meinem letzten Blogpost Bitcoin – Was genau steckt dahinter, möchte ich wieder näher auf das Thema „Blockchain“ eingehen. Der Unterschied bei diesem Artikel ist nun aber, dass wir bis auf Codeebene in die Materie einsteigen und uns diesmal im Gegensatz zu Bitcoin mit der Ethereum-Blockchain auseinandersetzen.
Neben dem Platzhirsch Bitcoin ist Ethereum Stand heute die zweit-wertvollste Kryprowährung. Aber wie kann es sein, dass Computerprogramme (nichts anderes sind solche Kryptowährungen) dermaßen „wertvoll“ werden bzw. global für solch einen “Aufruhr” sorgen? Nun, neben dem reinen Wertaustausch, welcher z.B. die zentrale Idee hinter Bitcoin darstellt, handelt es sich bei Ethereum mehr oder weniger um eine Weiterentwicklung der grundsätzlichen Idee. Zusätzlich zur eigenen Cryptowährung Ether (kurz ETH), spielt das Konzept von so genannten „Smart Contracts“ eine wesentliche Rolle. Somit ist es beispielsweise möglich, gewisse Sachverhalte oder Prozesse in einem Smart Contract abzubilden.
Was schwierig klingt, ist jedoch am Ende des Tages nichts weiter als ein Stückchen Programmcode (Bytecode), welcher auf Ethereum-Knoten (Ethereum Full Nodes) in der „Ethereum Virtual Machine“ (kurz EVM) ausgeführt werden kann. Dieses Konzept ist technisch gesehen kein neues und unter anderem bei .NET und Java schon recht lange im Einsatz. Klarerweise werden Smart Contracts nicht direkt als Bytecode-Programm implementiert. Die bekannteste und meistverwendete Programmiersprache, um Ethereum Smart Contracts zu implementieren heißt Solidity. Sie ähnelt einer Klasse in C# bzw. Java und kann für EntwicklerInnen, welche mit der Objektorientierten Programmierung vertraut sind, recht rasch erlernt werden. Der erstellte Solidity-Code wird anschließend mit dem Solidity-Compiler in Bytecode übersetzt und kann dann auf der Blockchain gespeichert und von Ethereum Nodes zur Ausführung gebracht werden.
Die Interaktion mit Smart Contracts erfolgt nun in zwei unterschiedlichen Herangehensweisen. Während lesende Aktionen recht rasch und quasi ohne Zeitverlust ausgeführt werden können, benötigt es bei schreibenden Aktionen eine Transaktion, die schließlich per Mining in die Blockchain geschrieben wird (die Blockdauer bei Ethereum beträgt ca. 10 bis 20 Sekunden). Analog zur Bestimmung des aktuellen Guthabens z.B. auf der Bitcoin Blockchain muss zur Ermittlung des aktuellen Programmstatus die gesamte Blockchain seit dem Zeitpunkt des Contract-Deployments gescannt werden. Die Summe der einzelnen Schreibzugriffe spiegelt den aktuellen Zustands des Smart Contracts wider.
Jede Transaktion kostet dabei eine gewisse Transaktionsgebühr (ähnlich wie bei Bitcoin). Klarerweise fällt diese niedriger aus, wenn nicht recht viel Programmlogik in der jeweiligen Funktion enthalten ist. Bei Solidity handelt es sich um eine Turing-Vollständige Programmiersprache, welche nur durch die maximal zu verwendende Transaktionsgebühr beschränkt ist. Eine Endlosschleife im Smart Contract führt also nicht zum Absturz der EVM, sondern stoppt, sobald die mit der Transaktion mitgesendete Transaktionsgebühr aufgebraucht ist. Die Höhe der durchschnittlichen Transaktionskosten kann z.B. unter https://ethgasstation.info/ eingesehen werden. Wie immer gilt, je mehr Transaktionsgebühr mitgesendet wird, umso früher wird die Transaktion von Minern verarbeitet und in die Blockchain mitaufgenommen.
Doch genug zur Theorie – show me the code!
Das folgende Beispiel zeigt einen Smart Contract, welcher eine Auktion über die Blockchain abbildet.
pragma solidity ^0.4.24;
/**
* @title Smart Auction Contract
* @dev Implementation of a simple auction on the Ethereum chain
*/
contract SmartAuction {
address public beneficiary;
uint public auctionEnd;
address public highestBidder;
uint public highestBid;
mapping(address => uint) private pendingReturns;
bool private ended;
event HighestBidIncreased(address bidder, uint amount);
event AuctionEnded(address winner, uint amount);
constructor(uint _biddingTime) public {
beneficiary = msg.sender;
auctionEnd = now + _biddingTime;
}
/// Bid on the auction with the value sent together with this transaction.
function bid() public payable {
require(now <= auctionEnd, "Auction already ended.");
require(msg.value > highestBid, "There already is a higher bid.");
if (highestBid != 0) {
// Sending back the money by simply using highestBidder.send(highestBid) is a security risk
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
emit HighestBidIncreased(msg.sender, msg.value);
}
/// Withdraw a bid that was overbid.
function withdraw() public returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// It is important to set this to zero first
pendingReturns[msg.sender] = 0;
if (!msg.sender.send(amount)) {
pendingReturns[msg.sender] = amount;
return false;
}
}
return true;
}
/// End the auction and send the highest bid to the beneficiary.
function auctionEnd() public {
require(now >= auctionEnd, "Auction not yet ended.");
require(!ended, "auctionEnd has already been called.");
ended = true;
emit AuctionEnded(highestBidder, highestBid);
beneficiary.transfer(highestBid);
}
/// Check if the auction is already over.
function auctionAlreadyEnded() public view returns (bool) {
return ended;
}
}
Neben einigen privaten und öffentlichen Datenkomponenten, wie etwa der Begünstigte und der Gewinner der Auktion (beneficiary bzw. highestBidder) werden auch das aktuelle Höchstgebot (highestBid) und einige interne Statusvariablen verwendet. Um Polling zu vermeiden, bietet Solidity das Konzept von Events an. Man kann sich also als Verwender der Smart Contracts direkt signalisieren lassen, falls Änderungen passiert sind. Die Funktionen bid(), withdraw() und auctionEnd() sind dabei für den normalen Auktionsbetrieb zuständig und so genannte schreibende Funktionen (das bedeutet, dass der Zustand des Smart Contracts) verändert wird bzw. werden kann, wohin gegen die Funktion auctionAlreadyEnded() lediglich eine rein lesende (Schlüsselwort view) Hilfsfunktion darstellt, die den Zustand nicht verändert.
Da der Konstruktor des Smart Contracts beim Deployment ausgeführt wird, ist klar, dass der Begünstigte der Auktion jener Netzwerkteilnehmer ist, der die Deploy-Transaktion des Contracts auslöst. Ebenfalls wird zu diesem Zeitpunkt über die Dauer der Auktion entschieden.
Was auf den ersten Blick vielleicht etwas seltsam anmutet ist, dass es eine eigene Methode withdraw() gibt, welche verwendet wird, wenn ein Auktionsteilnehmer überboten wurde. Sie stellt einen Mechanismus zur Verfügung, um das eingesetzte Geld in diesem Fall wieder zurückzufordern. Schnell wirft diese Art der Implementierung die Frage auf, warum man nicht gleich beim erfolgreichen Gebot die Refundierung des zuvor Höchstbietenden durchführen kann. Der Einwand ist berechtigt, zeigt aber die Schattenseiten beim Einsatz von Smart Contracts, da dies schnell zu Sicherheitsproblemen führen kann. Da die genaue Erklärung den Rahmen sprengen würde, bitte ich, sich unter anderem hier https://github.com/ConsenSys/smart-contract-best-practices/blob/master/docs/known_attacks.md oder hier https://consensys.github.io/smart-contract-best-practices/recommendations/#favor-pull-over-push-for-external-calls näher zu informieren bzw. mittels Google nach „favour push over pull based payments“ zu suchen.
Zum Übersetzen des Beispiel-Contracts kann z.B. das Truffle-Framework verwendet. Neben dem Solidity-Compiler beinhaltet es auch alle nötigen Tools zum Unit-Testen und Deployen (sowohl auf einem lokalen Entwicklungsnetz, dem Testnetz wie z.B. Ropsten, als auch auf dem Ethereum-Mainnet). Um die Entwicklung zu beschleunigen, bietet sich der “One-Click-Entwicklungsserver” Ganache an. Hierbei handelt es sich um ein lokales Ethereum-Testnetz mit vielen Einstellungsmöglichkeiten wie etwa die durchschnittliche Minigdauer und Transaktionskosten.
Ist nun unser Smart Contract bereit für den Produktiveinsatz, müssen wir idealerweise noch ein Frontend spendieren. Im Folgenden wird auszugsweise gezeigt, wie mittels Angular 7 und Web3 ein einfaches UI für die Benutzerinteraktion realisiert werden kann.
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';
import * as Web3 from 'web3';
// ...
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
public isLoading = false;
public highestBid = 0;
public auctionAlreadyEnded: boolean;
public amount: number;
private web3: Web3;
private ethPrecision = 10 ** 18;
private smartAuction: Web3.eth.Contract;
private accounts: Web3.eth.Account[];
readonly contractAbi = [
{
"constant": true,
"inputs": [],
"name": "beneficiary",
"outputs": [
{
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
// see full abi after compilation ...
];
readonly contractAddress = "0x45f3485480ffe178dc88c84c524c55439cff04e6";
constructor(
private route: ActivatedRoute,
private notificationService: NotificationService,
private changeDetector: ChangeDetectorRef) {
this.web3 = new Web3(Web3.givenProvider);
}
async ngOnInit() {
try {
this.isLoading = true;
this.accounts = await this.web3.eth.getAccounts();
this.smartAuction = new this.web3.eth.Contract(this.contractAbi, this.contractAddress);
this.smartAuction.events.HighestBidIncreased({}, async (error, result) => {
if (!error) {
await this.fetchCurrentValues();
this.changeDetector.detectChanges();
} else {
// log error here
console.log(error);
}
});
await this.fetchCurrentValues();
} catch (ex) {
console.error("exception while initializing the app", ex);
} finally {
this.isLoading = false;
}
}
public async bid() {
try {
if (this.isLoading) {
return;
}
this.isLoading = true;
const bidResult = await this.smartAuction.methods.bid().send({ from: this.accounts[0], value: this.amount * this.ethPrecision });
// not really needed here since we work with the corresponding event but better once more ;)
await this.fetchCurrentValues();
await this.notificationService.notify('Your bid has been placed successfully.');
} catch (ex) {
console.error("exception while bidding", ex);
await this.notificationService.notifyError('An error while placing your bid occured - please try again.');
} finally {
this.isLoading = false;
}
}
public async fetchCurrentValues() {
try {
this.isLoading = true;
this.highestBid = (await this.smartAuction.methods.highestBid().call()) / this.ethPrecision;
this.auctionAlreadyEnded = await this.smartAuction.methods.auctionAlreadyEnded().call();
console.log("update state finished...");
} catch (ex) {
console.error("exception while fetching current values", ex);
await this.notificationService.notifyError('An internal error occured - please reaload the page.');
} finally {
this.isLoading = false;
}
}
public async withdraw() {
...
}
public async end() {
...
}
}
Auf fast magische Weise wird in diesem Code-Snippet mit einer so genannten Web3-Instanz kommuniziert. Es handelt sich hier um ein Feature so genannter DApp-Browser (Programme, mit inkludierter Ethereum-Bridge), bei denen automatisch eine Web3-Instanz in die Web-Seite injiziert wird, sobald man diese annavigiert. Ein Beispiel für einen DApp-Browser ist das Browser-Plugin MetaMask. Es kann z.B.: als Chrome- oder Firefox-Plugin installiert werden und stellt neben einer Ethereum-Wallet eine Bridging-Funktion für die Interaktion mit (Ethereum) Smart Contracts zur Verfügung. Wer lieber sein Smartphone benutzt, kann beispielsweise auf Store-Apps wie etwa Trust zurückgreifen oder eine eigene, native App schreiben, welche auf einer Web3-Bibliothek aufbaut.
Der Rest ist recht einfach. Angular 7 rendert das Frontend und die Eventhandler (bid(), withdraw(), etc.) kümmern sich um die Behandlung der UI-Interaktionen. Wie auch schon bei der Implementierung des Smart Contracts selbst, erkennt man auch bei der Web3-Interaktion den Unterschied zwischen lesenden und schreibenden Aktionen. Während die lesenden einfach aufgerufen werden können, müssen die schreibenden in eine Transaktion “verpackt” werden. In der Dokumentation zu Web3 sieht man alle Parameter, die hier verwendet werden können.
Die beiden Konstanten contractAbi und contractAddress möchte ich nochmal besonders hervorheben. Beim so genannten Application Binary Interface handelt es sich um eine Metadatenrepräsentation der Contract-Schnittstelle, welche vom Solidity-Compiler beim Übersetzungsschritt erzeugt wird. Es ist somit möglich, Clients für einen Smart Contract zu erstellen, selbst wenn man nicht über den (Solidity-)Code verfügt (z.B. wenn der Contract bereits als Bytecode in der Blockchain festgeschrieben ist). Bei der Adresse handelt es sich um die Adresse des Codes innerhalb des Bytecodes (ähnlich zu einer Walletadresse). Was hier klar wird ist, dass es verschiedenste Deployments eines und desselben Contracts geben kann und man eben über die Adresse entscheidet, mit welcher Instanz man interagiert.
Weiters ist festzuhalten, dass deployte Smart Contracts nicht mehr gelöscht oder verändert werden können. Fehlt z.B. die Möglichkeit, das Höchstgebot an den Begünstigten (Auktionsersteller) zu transferieren, dann hat man mehr oder weniger ein schwarzes Loch implementiert, welches zwar die Auktion an sich abhandelt, ebenso den Bietern deren Geld refundiert, aber es eben dem Ersteller nicht ermöglicht, an die Ether-Coins nach Auktionsende zu kommen. An dieser Stelle möchte ich ebenso die Möglichkeit nützen, darauf hinzuweisen, dass bei der Entwicklung von Smart Contracts ein ganz spezielles Augenmerk auf Testabdeckung gelegt werden sollte, da der erste “Versuch” auch bereits der letzte ist, was Fluch und Segen zugleich sein kann.
Ich hoffe, es ist mir gelungen, einen groben Überblick über das sehr interessante Thema “Smart Contracts auf Ethereum” zu gegeben. Leider ist das Themengebiet so umfangreich, dass man im Format eines Blogartikels nur die Spitze des Eisbergs behandeln kann. Das gesamte Beispiel samt Unit-Tests, Angular 7 Frontend und PowerPoint-Slides ist hier frei verfügbar: https://github.com/PendelinP/SmartAuctionDemoBlockchainConf2018