Ethereum und seine Währung Ether sind zwar das zweitgrößte Kryptogeldsystem nach Bitcoin, aber der eigentliche Clou von Ethereum sind Smart Contracts. Das sind Programme auf der Blockchain, die global verteilt, manipulationssicher und allgemein überprüfbar ausgeführt werden. Im Prinzip schreibt man einen Smart Contract wie jedes andere Programm auch. Das geschieht üblicherweise in einer Hochsprache, die zu Bytecode für die Ethereum Virtual Machine (EVM) kompiliert wird. Anschließend muss man den Vertrag ausrollen, also auf der Blockchain platzieren. Dazu benötigt man ein Ethereum-Konto und eine (mit dem Browser verknüpfte) Wallet-Applikation.

Nachdem ein Vertrag ausgerollt wurde, können Blockchain-Nutzer mit ihm interagieren. Das geschieht über Transaktionen wie beim reinen Verschieben von Geld, allerdings ist das Zielkonto der Smart Contract. Transaktionen können auch 0 Ether überweisen, eine Vertragsinteraktion muss also nicht unbedingt auch Geld an den Vertrag senden. Transaktions­kosten entstehen aber in jedem Fall. Weil der Zustand der Blockchain und auch alle Transaktionen öffentlich sind und die EVM rein deterministisch arbeitet, ist jederzeit klar, wann welcher Vertrag aufgerufen wurde und welches Ergebnis der Aufruf hatte.

Von sich aus werden Verträge nie aktiv, sie ähneln Objekten aus der objektorientierten Programmierung: Ein Vertrag hat einen Satz Methoden, manche können „von außen“ aufgerufen werden, andere sind nur intern für den Vertrag selbst zugänglich. Neben Sender, Empfänger und Betrag haben Transaktionen noch ein spezielles Feld, das angibt, welche öffentliche Methode des Vertrags mit welchen Parameterwerten aufgerufen werden soll. Andere Ethereum-Transaktionen haben dieses Feld auch, aber bei einer einfachen Überweisung von Person zu Person bleibt es meist leer. Einmal aufgerufen, können Verträge auch miteinander interagieren und sogar neue erzeugen.

Geheimniskrämerei

Für einen Beispiel-Vertrag eignet sich das Spiel „Schere, Stein, Papier“ gut. Knackpunkt des Spiels ist, dass beide Spieler geheim festlegen, wie sie spielen. Wenn ein Spieler die Wahl seines Gegenübers beobachten und passend reagieren kann, dann kann er immer gewinnen. Für eine Smart-Contract-Version des Spiels ist das ein Problem, weil sämtliche Transaktionen in der Ethereum-Blockchain öffentlich einsehbar und geordnet sind. Ein Spieler muss zuerst wählen und der jeweils zweite kann diese Wahl beobachten. Die Lösung dieses Problems sind sogenannte Commitment-Verfahren. Damit können sich Parteien auf einen Wert fest­legen, ohne diesen vorab preiszugeben. Die Verfahren laufen in zwei Phasen ab: Commit und Reveal.

Ein einfaches Commitment-Verfahren lässt sich folgendermaßen umsetzen: Die Spieler entschieden sich nicht nur für Schere, Stein oder Papier, sondern denken sich auch jeweils eine beliebige Zeichenkette aus und bilden über beides zusammen einen kryptografischen Hashwert. Den Hashwert veröffentlichen die Spieler (Commit-Phase), aber weil niemand sonst die zusätzliche Zeichenkette kennt, lässt sich aus diesem Wert nicht auf die getroffene Wahl schließen.

Nachdem beide Spieler committet haben, können Sie in der zweiten Phase des Protokolls (Reveal) ihre Wahl und ihre geheime Zeichenkette veröffentlichen. Nun kann jedermann zum einen sehen, wer gewonnen hat, und zum anderen den Hashwert überprüfen. Betrug ist ausgeschlossen, denn mit einer nachträglichen Änderung der Wahl entstünde ein anderer Hashwert.

Dieses Protokoll eignet sich gut für einen Smart Contract. Damit Gewinnen und Verlieren nicht komplett beliebig sind, soll der Vertrag auch etwas monetären Spieleinsatz in Form von Ether erlauben, der an den Gewinner ausgezahlt wird. Um das Beispiel einfach zu halten, soll der Einsatz aber immer vom ersten Spieler kommen.

Entwicklung mit Remix

Die gängigste Hochsprache für Ethereum heißt Solidity. Sie ist syntaktisch an JavaScript angelehnt, aber statisch typisiert. Dank der webbasierten IDE Remix kann man direkt mit der Programmierung loslegen, ohne Software installieren zu müssen.

Mit der browserbasierten IDE „Remix“ kann man schnell und einfach in die Programmierung von Smart Contracts einsteigen.
Mit der browserbasierten IDE „Remix“ kann man schnell und einfach in die Programmierung von Smart Contracts einsteigen.

Wenn man die IDE zum ersten Mal aufruft, erscheinen in der linken Leiste die Dateien eines Beispielprojekts (siehe Screenshot, 1), die man getrost ignorieren kann. Eine neue leere Datei lässt sich per „New File“ (2) erzeugen. Als Dateinamen können Sie zum Beispiel „game.sol“ vergeben, „sol“ ist die übliche Dateiendung für Solidity-Programme. In Remix öffnet sich nun ein leeres Editor-Tab (3), in dem Sie das Grundgerüst des Vertrags schreiben können:

pragma solidity >=0.7 <0.9;

contract RockPaperScissors {
  address payable player1;
  address payable player2;

  constructor(
    address payable _player2
  ) payable {
    require(_player2 != msg.sender);
    player1 = payable(msg.sender);
    player2 = _player2;
  }
}

Zunächst wird die gewünschte Compiler-Version angegeben; es soll sich um Version 0.7.x oder die aktuelle 0.8.x handeln. Man sollte nicht einfach alle zukünftigen Versionen erlauben, weil zwischen Versionen auch inkompatible Änderungen auftreten können. Anschließend spezifizieren Sie den Vertrag mit einem Kon­struktor sowie zwei Variablen (player1 und player2), die die Adressen der Ethereum-Konten der Spieler speichern werden. Der Typ address payable steht für Ethereum-Adressen, an die eine Auszahlung erfolgen kann.

Der Konstruktor initialisiert die Adressen der zwei Spieler. Der erste Spieler ist automatisch derjenige, der den Vertrag ausrollt, also die Transaktion abgesetzt hat. Seine Adresse steht in der speziellen Variable msg.sender. Diese Variable ist vom Typ address – eine Adresse an die nicht ausgezahlt werden kann – und muss in address payable konvertiert werden. Die Adresse des zweiten Spielers muss dem Konstruktor als Parameter übergeben werden. Vorher wird noch geprüft, dass die beiden Spieler auch wirklich verschiedene Adressen haben. Andernfalls bricht die Erzeugung des Vertrags ab.

Auch der Konstruktor selbst trägt das Attribut payable, der Vertrag könnte sonst kein Geld entgegennehmen. Der Spieler, der den Vertrag ausrollt, schließt eine beliebige Menge Ether in diese Transaktion ein. Über diesen Betrag kann der Vertrag dann verfügen, das ist der Spieleinsatz.

Dieser Code lässt sich bereits kompilieren. Dafür wechselt man in Remix in der linken Spalte auf die Ansicht „Solidity compiler“ (siehe Screenshot, 4) und klickt auf „Compile game.sol“. Das führt zu einer Warnung, weil keine Lizenz für den Code angegeben wurde. Ansonsten tut sich nicht viel, der Code im Editor ist (hoffentlich) fehlerfrei. Um die Warnung zu beheben, stellen Sie dem Code folgenden Kommentar voran:

// SPDX-License-Identifier: UNLICENSED

Ein weiterer Klick auf den Compile-Button führt zu einem kleinen grünen Haken beim Solidity-Compiler-Icon, alles paletti. Theoretisch könnten Sie das Kompilat auch schon auf der Blockchain ausrollen. Das wäre aber nicht sehr spannend, weil der Vertrag ja noch nichts tut.

Die Compilereinstellungen von Remix kann man einfach übernehmen. Falls Fehler oder Warnungen auftreten, werden sie unter den Einstellungen angezeigt.
Die Compilereinstellungen von Remix kann man einfach übernehmen. Falls Fehler oder Warnungen auftreten, werden sie unter den Einstellungen angezeigt.

Versprechen

Für die erste Phase des Commitment-Protokolls brauchen Sie zwei weitere Variablen im Vertrag, die die Commitments der Spieler speichern, sowie eine Funktion, um diese Variablen zu befüllen. (Solidity spricht von „Funktionen“ und nicht „Methoden“.) Jeder Spieler soll diese Funktion genau ein Mal aufrufen dürfen. Fügen Sie also unter den Spieler-Adressen folgende Variablen ein:

bytes32 comm1;
bytes32 comm2;

comm1 und comm2 werden die zwei Hashes speichern, mit denen die beiden Spieler ihre Wahl festlegen. Gut geeignet sind SHA-256-Hashes, die 32 Byte lang sind. Die Variablen sind deshalb vom Typ bytes32, der ein Array von 32 Bytes bezeichnet. Variablen werden standardmäßig mit Nullen vorbelegt, was eine Initialisierung von comm1 und comm2 im Konstruktor überflüssig macht. Zum Glück, denn jede Operation eines Smart Contracts und auch jeder Schreib- und Lesezugriff ist mit Kosten verbunden und man sollte daher sparsam mit ihnen umgehen.

Die dazugehörige Funktion sieht wie folgt aus:

function commit(bytes32 comm) public {
  require(msg.sender == player1 || msg.sender == player2);
  require(comm != bytes32(0));

  if (msg.sender == player1) {
    require(comm1 == bytes32(0));
    comm1 = comm;
  } else {
    require(comm2 == bytes32(0));
    comm2 = comm;
  }
}

Wie üblich in der objektorientieren Programmierung kann man Funktionen als private oder public deklarieren. commit() muss public sein, schließlich sollen die Spieler die Funktion von außen aufrufen. Als Parameter nimmt sie einen 32-Byte-Wert entgegen, das ist der Hash, mit dem ein Spieler seine Wahl festlegt. Die Implementierung von commit() folgt gängigen Praktiken: Zuerst wird geprüft, dass der Aufrufer der Funktion auch dazu berechtigt ist; es muss sich um einen der beiden Spieler handeln. Bei anderen Aufrufern evaluiert die require-Bedingung zu false. Die Transaktion wird dann abgebrochen (aber auch dieser Abbruch wird in der Blockchain vermerkt).

Außerdem prüft commit(), dass das übergebene Commitment nicht nur aus Nullen besteht. Das wäre kein gültiger Wert, weil der Vertrag Null-Werte benutzt, um anzuzeigen, dass noch kein Commitment vorliegt. Es ist zwar harmlos, Null-Werte mit Null-Werten zu überschreiben, aber auch sinnlos und der Check verhindert so eine (versehentliche) Fehlbedienung. Anschließend unterscheidet die Funktion, welcher Spieler sie aufgerufen hat. Ein Spieler darf sein Commitment nur dann beschreiben, wenn es noch leer ist. Wenn alles passt, wird das Commitment gespeichert.

Enthüllungen

Ist das Commitment geklärt, kommt die Reveal-Phase. Es gibt die drei Möglichkeiten – Schere, Stein und Papier –, die mit den Zahlen 1, 2 und 3 kodiert werden. Auch hier muss sich der Smart Contract merken, welcher Spieler welche Möglichkeit gewählt hat. Daher braucht der Vertrag zwei weitere Variablen:

uint8 reveal1;
uint8 reveal2;

Der Typ uint8 ist eine vorzeichenfreie 8-Bit-Ganzzahl. Kleinere Ganzzahltypen kennt Solidity nicht, auch wenn man für drei Zahlen eigentlich nur zwei Bit bräuchte. Wie beim Commitment zeigt der Standardwert 0 an, dass ein Spieler seine Wahl noch nicht eingetragen hat.

Beim Reveal soll der Smart Contract prüfen, ob der Hash zuvor korrekt committet wurde. Dafür muss jeder Spieler seine Wahl sowie seinen geheimen String preisgeben. Deswegen hat die zugehörige Funktion zwei Parameter:

function reveal(uint8 choice, string calldata secret) public {
  require(msg.sender == player1 || msg.sender == player2);
  require(comm1 != bytes32(0) && comm2 != bytes32(0));

  bytes32 expected = sha256(abi.encodePacked(choice2str(choice), secret));

  if (msg.sender == player1) {
    require(expected == comm1 && reveal1 == 0);
    reveal1 = choice;
  } else {
    require(expected == comm2 && reveal2 == 0);
    reveal2 = choice;
  }
}

Der eigentümliche Typ string calldata des zweiten Parameters ist der ungewöhnlichen Speicherverwaltung in der Ethereum Virtual Machine geschuldet: Objekte mit variabler Länge wie Strings oder dynamische Arrays können nicht 1:1 auf die Register der EVM abgebildet werden. Deswegen muss Solidity in solchen Fällen praktisch eine Speicherverwaltung implementieren und in den kompilierten Vertrag einfügen. Das ist sehr aufwendig und sollte vermieden werden.

Das calldata-Attribut hilft, eben diesen Fall zu vermeiden: Wie erwähnt gibt ein spezielles Transaktionsfeld beim Aufruf eines Vertrages an, welche Funktion des Vertrags mit welchen Parameterwerten aufgerufen werden soll. Der Typ string calldata sagt aus, dass der Parameterwert zwar aus diesem Datenfeld ausgelesen werden soll, aber nicht in den eigenen Speicher kopiert werden muss, weil sich der Wert nicht ändert.

Hashkontrolle

Der Rumpf der Funktion stellt zuerst sicher, dass nur die beiden Spieler die Funktion aufrufen dürfen. Außerdem müssen beide Spieler bereits ein Commitment abgegeben haben. Nun muss der Smart Contract nachvollziehen, ob der zuvor committete Hash korrekt ist und zur jetzt angegebenen Wahl passt. Die Berechnung an sich ist recht einfach, weil Smart Contracts häufig solche Hashes benötigen und Solidity deshalb Hashfunktionen eingebaut hat. Der Aufruf von sha256() berechnet den SHA-256-Hash eines Byte-­Arrays.

Dieses Byte-Array liefert die Funktion abi.encodePacked(), die alle übergebenen Argumente nacheinander in einen Buffer schreibt. Übergeben werden sowohl die Wahl des Spielers (choice) als auch sein geheimer String (secret). Allerdings wird choice zuvor noch durch die Funktion choice2str() in einen String konvertiert. Wenn man reveal() beispielsweise mit den Parameterwerten 3 und "halloct" aufruft, dann wird zunächst die Zahl in den String "3" umgewandelt. encodePacked() verknüpft dann dessen UTF-8-Repräsentation (0x33) mit der UTF-8-Bytefolge für "halloct": 0x3368616c6c6f6374. Anschließend hasht sha256() die Bytefolge.

Zu guter Letzt muss reveal() noch prüfen, ob der berechnete Hash mit dem Commitment übereinstimmt und der Spieler noch kein Commitment abgegeben hat. Falls ja, ist die getroffene Wahl gültig und wird gespeichert.

Wie der Name vermuten lässt, ist choice2str() keine eingebaute Funktion. Wegen der restriktiven Speicherallokation in der EVM bietet Solidity keine eingebaute Konvertierung von Ganzzahlen zu Strings. Sie müssen choice2str() selbst definieren:

function choice2str(uint8 choice) private pure returns (string memory) {
  if (choice == 1) return "1";
  if (choice == 2) return "2";
  return "3";
}

Diese Hilfsfunktion wird nur innerhalb des Vertrags benötigt, weshalb man sie als private deklarieren sollte. Außerdem kann man sie mit pure als reine, nebenwirkungsfreie Funktion markieren, weil sie keinen Zustand des Vertrags liest oder schreibt. Weder private noch pure sind zwingend notwendig, aber es ist sauberer, Funktionen so weit wie möglich einzuschränken, um Programmierfehler zu verhindern. Solidity-Funktionen dürfen mehrere Rückgabewerte haben. Diese Hilfsfunktion hat nur einen String, der temporär im Speicher abgelegt wird (memory).

Man könnte sich diese Konvertierung auch sparen und die Wahl der Spieler direkt als Bytewert (0x01, 0x02 oder 0x03) hashen. Aber nur durch den Umweg über UTF-8-Strings (0x31, 0x32 und 0x33) sind die entstehenden Hashwerte identisch zu solchen, die sich leicht auf der Kommandozeile berechnen lassen.

Die heiße Phase

Sobald beide Spieler ihre Wahl offengelegt haben, ist das Spiel vorbei. Jetzt müssen Sie nur noch die Gewinnlogik des Vertrages programmieren, damit der Gewinner den Einsatz ausgezahlt bekommt. Dafür brauchen Sie eine zweite Hilfsfunktion, die die Adresse des Gewinners ermittelt:

function winner() private view returns (address payable) {
  if (reveal2 < 1 || reveal2 > 3)
    return player1;
  if (reveal1 < 1 || reveal1 > 3)
    return player2;

  if (
    (reveal1 == 1 && reveal2 == 2) || // Schere vs. Stein
    (reveal1 == 2 && reveal2 == 3) || // Stein vs. Papier
    (reveal1 == 3 && reveal2 == 1)    // Papier vs. Schere
  )
    return player2;
  else
    return player1;
}

Auch diese Hilfsfunktion ist als privat markiert. Im Gegensatz zu pure erlaubt es das view-Attribut, Variablen des Vertrags (wie reveal1 und reveal2) zu lesen, aber nicht zu schreiben. Wieder ist die Markierung zwar nicht unbedingt nötig, aber guter Stil. Zuerst überprüft die Funktion, ob einer der beiden Spieler einen ungültigen Wert, also nicht 1, 2 oder 3, angegeben hat – dann gewinnt der jeweils andere. Anschließend werden die Werte verglichen. Bei einem Patt, oder falls beide Werte ungültig sind, gewinnt Spieler 1, von ihm kommt ja auch der Spieleinsatz.

Bleibt nur noch, die Abschlussfunktion zu implementieren:

function finish() public {
  require(reveal1 > 0 && reveal2 > 0);
  selfdestruct(winner());
}

Die Vorbedingung erzwingt, dass beide Spieler die Reveal-Phase abgeschlossen haben. Ansonsten gibt es keine Beschränkung, auch wildfremde Blockchain-Teilnehmer dürfen diese Funktion aufrufen. Der letzte Schritt ermittelt die Gewinneradresse per winner() und zerstört den Vertrag. Dadurch wird sämtliches Geld, über das der Vertrag verfügt, an den Gewinner ausgezahlt. Im Beispiel ist das just der Betrag, der anfänglich an den Konstruktor übergeben wurde. Nach der Selbstzerstörung ist keine weitere Interaktion mit dem Vertrag mehr möglich.

Probe aufs Exempel

Um den Vertrag testen und nutzen zu können, muss man ihn auf der wahren Blockchain oder einer Test-Chain ausrollen. Stellen Sie dazu sicher, dass Remix den Vertrag ohne Fehler kompiliert hat. Wechseln Sie dann in den Bereich „Deploy & run transactions“ (siehe obigen Screenshot, 5).

Dort gibt es im Drop-down-Menü „Environment“ drei Möglichkeiten zur Auswahl. Die standardmäßig ausgewählte JavaScript-VM stellt 15 vordefinierte Accounts mit jeweils 100 Fake-Ether zur Verfügung. Sämtliche Transaktionen laufen dabei ausschließlich lokal im Browser ab; die wahre Blockchain bekommt der Smart Contract so nicht zu sehen.

Die JavaScript-VM eignet sich gut für Experimente, aber Sie haben ja bereits einen funktionstüchtigen Vertrag und können ihn auch direkt auf der Blockchain eines Testnetzes wie Ropsten ausrollen. Dazu müssen Sie eine Wallet-Applikation mit Ihrem Browser verknüpfen. Gut eignet sich MetaMask, ein Browser-Add-on für Firefox und Chrome. Außerdem sollten Sie mindestens zwei Accounts des Testnetzes in Ihrem Wallet angelegt haben oder bereits einen echten Mitspieler mit eigenem Account haben. Ropsten, MetaMask und die Erstellung von Accounts haben wir in Teil 2 beschrieben.

Wählen Sie nun die zweite Environment-Option „Injected Web3“ aus. Beim ersten Mal resultiert das in einer Sicherheitsabfrage von MetaMask. Erlauben Sie den Zugriff durch Remix und legen Sie fest, mit welchen Accounts sich die IDE verbinden darf.

MetaMask fragt nach, ob und auf welche Ethereum-Accounts Remix zugreifen darf.
MetaMask fragt nach, ob und auf welche Ethereum-Accounts Remix zugreifen darf.

Wechseln Sie nun in MetaMask zu einem dieser Accounts und kopieren dessen Adresse in die Zwischenablage, indem Sie auf den Accountnamen klicken. Wechseln Sie anschließend in den zweiten Account, den Sie nutzen möchten. Dessen Adresse und Kontostand sollte jetzt auch im „Account“-Feld in Remix erscheinen und direkt darüber auch das (Test-)Netz, zu dem der Account gehört.

Stellen Sie bei „Value“ den Spieleinsatz des Vertragserstellers ein, zum Beispiel 1 Finney, also ein Tausendstel Ether. Kopieren Sie anschließend die Adresse des anderen Accounts in das Feld neben dem roten „Deploy“-Knopf und drücken Sie diesen danach.

Ein Klick auf „Deploy“ und schon wird der Smart Contract auf der (Test-)Blockchain ausgerollt.
Ein Klick auf „Deploy“ und schon wird der Smart Contract auf der (Test-)Blockchain ausgerollt.

MetaMask zeigt nun eine Abfrage mit den insgesamt geschätzten Kosten der Transaktion. Diese setzen sich zusammen aus dem Spieleinsatz (1 Finney = 0,001 Ether) sowie der Transaktionsgebühr, die sich in ähnlicher Höhe bewegen sollte. Bestätigen Sie die Transaktion, wenn alles gut aussieht.

1 Finney Einsatz plus Transaktionsgebühren macht 2,057 Finney. Per Klick auf „Bestätigen“ gibt MetaMask die Transaktion frei.
1 Finney Einsatz plus Transaktionsgebühren macht 2,057 Finney. Per Klick auf „Bestätigen“ gibt MetaMask die Transaktion frei.

Nach einer kleinen Weile sollte eine Benachrichtigung von MetaMask erscheinen, die die Transaktion bestätigt. In Remix erscheint die frisch erzeugte Instanz des Smart Contracts in der Seitenleiste unter „Deployed Contracts“. Außerdem steht in der Remix-Konsole (siehe obigen Screenshot, 6) ein Link auf Etherscan.io. Darüber können Sie – wenn Sie mögen – noch einmal alle Transaktionsdetails einsehen. Insbesondere kann man dort auch den vom Solidity-­Compiler erzeugten Bytecode sehen.

Die Spiele mögen beginnen

Klicken Sie unter „Deployed Contracts“ auf den kleinen Pfeil links vom Titel des Vertrages, um ihn zu öffnen. Es werden nun die drei Funktionen „commit“, „finish“ und „reveal“ angezeigt, die im Vertrag als public deklariert sind.

Über die öffentlichen Funktionen „commit“, „finish“ und „reveal“ kann man mit dem Vertrag interagieren.
Über die öffentlichen Funktionen „commit“, „finish“ und „reveal“ kann man mit dem Vertrag interagieren.

Diese Funktionen könnten Sie – und jeder andere – nun beliebig aufrufen, aber die mit require im Vertrag notierten Vorbedingungen verhindern, dass Aufrufe in der falschen Reihenfolge oder durch die falschen Accounts einen Effekt haben – das würde nur Transaktionsgebühren verschwenden.

Um zu spielen, erzeugen Sie zunächst aus "1" (Schere), "2" (Stein) oder "3" (Papier) und einem Codewort einen Hash. Auf der (Linux-)Kommandozeile geht das zum Beispiel mit folgendem Befehl, wenn man Papier spielen will und sich den geheimen String "halloct" ausdenkt:

echo -n "3halloct" | sha256sum -

Geben Sie den so entstandenen Hash des ersten Spielers im Format 0x4ee4… (mit vorangestelltem 0x) im Feld neben „commit“ ein und klicken dann auf den orangen Knopf der Funktion.

Machen Sie bei der Eingabe einen Fehler, erscheint je nach Fehlerart in der Konsole eine Meldung oder Remix warnt bereits vor Ausführung. Letzteres passiert zum Beispiel, wenn man zweimal hintereinander mit der gleichen Adresse committen möchte, was im Vertrag ausgeschlossen ist. Falls alles in Ordnung ist, meldet sich wieder MetaMask mit einer Kostenschätzung. Genehmigen Sie die Transaktion und warten Sie ab, bis sie vom Netzwerk bestätigt worden ist.

Das wird wahrscheinlich schiefgehen: Remix warnt, wenn es absehen kann, dass der Vertrag einen Aufruf blockieren wird.
Das wird wahrscheinlich schiefgehen: Remix warnt, wenn es absehen kann, dass der Vertrag einen Aufruf blockieren wird.

Wechseln Sie nun in MetaMask auf den anderen Account – wie vorhin spiegelt sich diese Änderung auch in Remix –, berechnen Sie einen Hash für den zweiten Spieler und senden Sie auch dieses Commitment ab.

Das Aufdecken funktioniert ebenso: Tragen Sie die Wahl und das Geheimnis im Format 3,"halloct" in das Feld neben „reveal“ ein und klicken Sie auf „reveal“. Wechseln Sie wieder den Account und wiederholen Sie das Aufdecken für den anderen Spieler.

In beiden Fällen emuliert Remix wieder die Transaktion vor dem Absenden, sodass Sie bei einem fehlerhaften Hash bereits vorher gewarnt werden. Sie können die Warnung in den Wind schlagen, dann wird der Vertrag selbst, wie geplant, die Transaktion abbrechen – die Transaktionsgebühren sind in diesem Fall aber weg.

Wenn beide Reveals bestätigt worden sind, können Sie die finish-Transaktion ausführen. Der Gewinner erhält automatisch die Auszahlung von 1 Finney.

Fehler im Vertrag

Alles hat geklappt, also ist der Vertrag in Ordnung? Leider nein, um einen Vertrag wirklich produktiv zu nutzen, sollte man tunlichst sicherstellen, dass er ganz genau so funktioniert wie gewünscht.

Klassischerweise schreibt man in der Softwareentwicklung Tests, die das korrekte Verhalten überprüfen. In der Ethereum-Welt ist das auch möglich. Zum Beispiel stellt die Remix-IDE ein Unit-Testing-Plug-in bereit, mit dem man Tests in Solidity schreiben und ausführen kann. Wer es etwas professioneller haben möchte, greift zur „Truffle Suite“, einer Sammlung von Kommandozeilentools auf Node.js-Basis, die den gesamten Entwicklungszyklus von Smart Contracts abdeckt. Deren Bedienung würde aber den Rahmen des Artikels sprengen (siehe dazu auch unseren Blog-Post über Truffle).

Ein Nachteil von Unit Tests ist, dass Menschen dazu neigen, nur die „positiven“ Fälle zu testen. Beispiel gefällig? Nehmen Sie folgenden Ablauf an: Spieler 1 wählt Papier, erzeugt den Vertrag mit Einsatz und ruft commit() auf. Spieler 2 wählt Stein – verliert also, weiß das aber noch nicht – und ruft ebenfalls commit() auf. Falls nun zuerst Spieler 1 per reveal() seine Wahl aufdeckt, dann erfährt Spieler 2 zu diesem Zeitpunkt, dass er verlieren wird – Spieler 1 kann das hingegen noch nicht wissen, weil die Wahl von Spieler 2 noch geheim ist.

Welche Motivation hätte Spieler 2 jetzt noch, seine Wahl offenzulegen? Schließlich erhält er keine Auszahlung und müsste auch noch die Transaktionsgebühr für reveal() bezahlen. Ein rationaler Spieler würde also einfach gar nichts tun. Da die finish-Funktion für die Auszahlung aber das reveal des zweiten Spielers verlangt, wäre der Smart Contract jetzt für immer in der Schwebe.

Um dies zu verhindern, könnte der Vertrag eine Deadline setzen. Verpasst ein Spieler die Deadline, so wird das Spiel automatisch für den anderen entschieden. Solidity bietet die globale Variable block.timestamp an, die den Zeitstempel des aktuellen Ethereum-Blocks enthält. Damit kann man den Vertrag geeignet modifizieren, damit finish() nach Ablauf einer Frist zulasten des säumigen Spielers auszahlt.

In der Praxis wird es sogar noch komplizierter. Wir haben festgelegt, dass bei einem Patt immer Spieler 1 den Einsatz zurückerhält. Fairer wäre es, wenn der Vertrag bei einem Patt die Commitments zurücksetzen und eine neue Runde starten würde. Noch fairer wäre, wenn beide Spieler gleich viel Geld setzen müssten und der Vertrag das kontrollieren würde. Dafür wäre neben dem Konstruktor eine weitere Funktion nötig, die mit payable markiert ist und Geld von Spieler 2 entgegennehmen kann.

Schon in diesem simplen Beispiel lauern also allerlei Probleme – und das, obwohl der Vertrag bereits mit require-Anweisungen gespickt ist. Wer mit Smart Contracts ernsthaft arbeiten und relevante Geldmengen verwalten will, sollte sie ausführlichst und detailliert testen. Wer wirklich auf Nummer sicher gehen will, muss schwere Geschütze auffahren und den Vertragscode mit formalen Methoden verifizieren, also seine Korrektheit mathematisch herleiten. Diesen erheblichen Aufwand scheuen bislang allerdings selbst die großen Player im Geschäft, obwohl schon des Öfteren folgenschwere Fehler passiert sind.

Fazit

Dank Web-IDEs und Testnetzwerken fällt der Einstieg in die Solidity-Programmierung leicht. Mit einer an JavaScript angelehnten Syntax konnten die Solidity-Entwickler eine Sprache mit einem lebhaften Ökosystem schaffen. Wer tiefer einsteigen will, dem helfen die Dokumentationen von Solidity und Remix.

Einmal erzeugte Smart Contracts sind dank der Blockchain transparent und unveränderlich. Diese zentrale Eigenschaft ist ein zweischneidiges Schwert: Einerseits kann jeder interessierte Nutzer sie nachvollziehen und sicher sein, dass sich nichts daran ändert. Andererseits sind Bugs genau deshalb kaum zu beheben und können katastrophale Folgen haben; im schlimmsten Falle wird Kryptogeld komplett verbrannt und kann nicht wieder zurückbezahlt werden.

Wie so oft muss man also Chancen und Risiken gegeneinander abwägen. Bis die Smart Contracts in Ethereum den Mainstream erreichen – oder vielleicht sogar Verbrauchergeschäfte darüber abgewickelt werden – ist es noch ein langer Weg, der zahlreiche weitere Bausteine erfordert. Ein solcher sind verteilte Applikationen, sogenannte DApps, mit denen man Smart Contracts ein einfach zu benutzendes Web-Interface geben kann. Wie das geht, werden wir in Teil 4 zeigen.

TAGS