Ethereum & Smart Contracts
Ethereum ist eine Kryptowährung und Applikationsplattform, die 2015 von Vitalik Buterin, Gavin Wood und Jeffrey Wilcke erstmalig vorgestellt worden ist. Die zeitweilig genutzte markige Bezeichnung als „World Computer“ deutet darauf hin, dass die Kryptowährung nur eine Nebensache ist und der Hauptfokus den Smart Contracts gilt. Im Gegensatz zu Bitcoin (und Derivaten), in denen nur eine primitive Stack-basierte Skriptsprache zur Verfügung steht, verfügt Ethereum über eine vollwertige virtuelle Maschine, die eine spezielle Assembler-Sprache auszuführen vermag.
Ein Smart Contract ist dabei vergleichbar zu einem Objekt in gängigen Programmiersprachen – eine Ansammlung an aufrufbaren Methoden –, das in der Blockchain gespeichert ist und seinen eigenen Zustand verwaltet. Während sich der Zustand durch Methodenaufrufe ändern kann, bleibt der Code stets fix.
Prinzipiell kann jeder Mensch so einen Smart Contract auf der Ethereum-Blockchain aufrufen, solange er der Transaktion genügend Honorar mitschickt: der sogenannte Sprit. Sprit wird in Ether, der eingebauten Kryptowährung von Ethereum, gemessen. Er sorgt dafür, dass die Ausführung von Code endlich ist, d.h. niemand gratis Berechnungen ausführen kann. Ganz ähnlich wie ein Notar, der schließlich auch Gebühren für die Ausführung von Verträgen verlangt.
Programmierung von Smart Contracts
Die Ethereum-VM versteht eine Reihe von Assemblerbefehlen, so dass Verträge auf der untersten Ebene in etwa so aussehen:
Solchen Code möchte aber niemand von Hand schreiben. Daher gibt es eine Reihe von Programmiersprachen, die sich mehr oder weniger an bekannte Sprachen anlehnen, aber eben zu Ethereum-Bytecode kompilieren. Der Platzhirsch ist die Sprache Solidity, deren Syntax an JavaScript angelehnt ist.
Ein Beispiel-Contract in Solidity sieht so aus:
Auffällig ist zunächst, dass Solidity im Gegensatz zu JS ein Typsystem besitzt.
Außerdem sind bestimmte Methoden speziell annotiert.
Im obigen Beispiel steht z.B. payable
dafür, dass mit der Methode Ether (abzüglich Sprit) in den Vertrag eingezahlt werden können.
Solidity als Standard
Ganz genau messbar ist es zwar nicht, aber Solidity hat sich mittlerweile als Standard für die Programmierung auf Ethereum durchgesetzt. Das zeigt sich auch daran, dass ein Tooling-Ökosystem um die Sprache herum entstanden ist.
Allerdings rufen die Vielzahl an öffentlichen Verträgen mit teils hohen Kontoständen auch kriminelle Akteure auf den Plan. Ein Smart Contract, einmal programmiert und ausgerollt, kann keine Bugfixes mehr enthalten, was es umso wichtiger macht, dass sie hohen Qualitätsansprüchen genügen.
Ironischerweise fällt Solidity dabei als nicht besonders solide auf. Eine Forschergruppe an der University of Texas hat in einer Untersuchung insgesamt 44 Fehlerklassen festgestellt, von denen fünf auf Solidity zurückgehen. In der Vergangenheit wurden einige spektakuläre Fehler in Solidity-Verträgen ausgenutzt, um acht- bis neunstellige Dollarbeträge abzuzweigen.
Während Probleme in Programmiersprachen oftmals durch eine neue Version ausgeglichen werden können, wiegen Designfehler in der Ethereum-VM deutlich schwerer. Aufgrund von Abwärtskompatibilität kann Laufzeitverhalten nur schwer geändert werden, denn die gesamte existierende Basis an Smart Contracts muss lauffähig bleiben.
Alles in allem erinnert diese Problematik stark an die Debatte, die die Community der Systemprogrammiererinnen schon seit geraumer Zeit führt. Liegen Sicherheitslücken an schlampiger Programmierung oder sind die Ursachen in der Programmiersprache zu finden? Aus dieser Beobachtung ist die Programmiersprache Rust geboren, die sich anschickt, ganze Klassen von Sicherheitsprobleme durch besseres Sprachdesign zu eliminieren.
Rust, eine vielseitige Sprache
Auf das Tapet der Systemprogrammierung kam Rust erst verhältnismäßig spät. Graydon Hoare gestaltete für Mozilla eine Programmiersprachen mit dem Fernziel, eine neue, sicherere Browser-Engine zu schaffen. Mittlerweile haben bereits größere in Rust entwickelte Module Einzug in Firefox gehalten. Mozilla sponsort deswegen die Weiterentwicklung und die Community hilft kräftig mit. Auf GitHub ist Rust eine der am stärksten wachsenden Programmiersprachen im Zeitraum 2018/19.
Das Versprechen von Rust ist es, ähnlich wie C++ durch manuelle Speicherverwaltung und Abstraktionen ohne Laufzeitkosten hohe Performance zu garantieren, gleichzeitig aber durch ein starkes Typsystem gängige Fehler zu verhindern. So ist es beispielsweise in Rust unmöglich, in einem parallelen Programm einen Data Race zu erzeugen: Der Compiler verbietet kurzerhand, dass zu einem Objekt gleichzeitig mehrere schreibbare Zeiger existieren.
Andererseits stellt die Standardbibliothek und viele Pakete Abstraktionen bereit, mit denen Parallelismus generell einfacher zu handhaben ist.
Mittels der rayon
-Bibliothek lässt sich zum Beispiel folgender Code schreiben:
Der “mutable parallel iterator” erlaubt Veränderungen pro Array-Eintrag, aber unterbindet, dass diese Zeiger außerhalb der Iteration verwendet werden können.
Rust für Smart Contracts
Die Vorteile von Rust haben auch die Ethereum-Entwicklerinnen erkannt. Unter der EWASM-Flagge (kurz für Ethereum flavored WebAssembly) läuft ein Standardisierungsprozess für eine zweite, inkompatible Version der Ethereum-VM auf Basis des WebAssembly-Bytecodeformats (“Phase 2”). WebAssembly ist ein von einem Industriekonsortium ursprünglich für die Browser-Plattform vorangetriebene Spezifikation, die sich aber auch außerhalb des Webs Beliebtheit erfreut.
Die Vorteile von WASM als Low-Level-Format für die Ethereum-VM liegen auf der Hand: zum einen ist es das praxisorientierte Werk erfahrener Sprachdesignerinnen statt einer proprietären Nischenlösung. Zum anderen steht via LLVM eine reichhaltige Basis an Programmiersprachen bereit, die nach WASM übersetzen können, so auch Rust.
Was wäre also zweckmäßiger, als Rust, eine sichere Programmiersprache, mit der soliden Basis von WebAssembly zu kombinieren, um Smart Contracts zu entwickeln, die gegebenenfalls Millionen von Krypto-Tokens verwalten?
Eine Geldbörse in Rust
Der Vertrag für eine einfache Geldbörse, der oben in Solidity wiedergegeben ist, lässt sich auch in Rust formulieren. Als erstes fällt auf, dass man in Rust die Schnittstelle von der Implementierung trennen muss.
Dieser Code definiert ein trait
; ein Interface in Rust, welches später implementiert werden kann.
Die Besonderheit besteht darin, dass alle Traits in Rust implizit einen Typparameter haben (Self
).
Ähnlich wie in Python muss das Objekt, auf dem eine Methode aufgerufen wird, explizit mit übergeben werden (self
vom Typ Self
).
Die Annotation am Trait (eth_abi
) sorgt dafür, dass der Rust-Compiler automatisiert ABI-Definitionen erzeugt.
Unter ABI (kurz für Application Binary Interface) versteht man die Konventionen, mit der Funktionsaufrufe im kompilierten Bytecode stattfinden.
Für Ethereum ist das notwendig, da die EVM selbst keine Methoden vorsieht, sondern jeder Smart Contract nur einen einzigen Einstiegspunkt definiert.
Stattdessen bildet man einen Hash aus gewünschtem Funktionsnamen und -parametern und übergibt diesen an den Smart Contract, der dann üblicherweise per switch
-ähnlichem Statement an die richtige Stelle springt.
EWASM hat diese Konvention von Solidity übernommen.
Die Annotation erzeugt den nötigen Boilerplate, damit sowohl Aufrufer als auch aufgerufener Vertrag dieses Hashing nicht umständlich per Hand implementieren müssen. Ein Baustein bleibt aber noch übrig, nämlich der Einsprungspunkt für den Vertrag (dazu weiter unten mehr).
Im zweiten Schritt definiert man dann die tatsächliche Datenstruktur, die dem Vertrag zugrunde liegt. Im Regelfall kann diese leer ausfallen:
Zunächst sieht das kontraintuitiv aus. Wo genau soll denn der Vertrag nun speichern, wer die Eignerin ist und wie viele Ether sie momentan abgelegt hat? Dazu müssen wir kurz auf das Speichermodell von Ethereum eingehen.
Storage und Felder
In Solidity muss man sich um Storage keine Gedanken machen, denn die Programmiersprache suggeriert das Verwalten von abstrakten Objekten. Definiert man in einem Vertrag eine Reihe von Objektfeldern, dann verhalten diese sich so, wie man es von gängigen Programmiersprachen gewohnt ist. Allerdings handelt es sich dabei nur um eine Abstraktion. Tatsächlich verwaltet die Ethereum VM für persistenten Speicher nur eine Menge von Registern, und zwar 2256 Stück zu je 256 Bit Breite. Was wie eine unvorstellbare Menge erscheint, wird schnell dadurch relativiert, dass das Schreiben und Lesen in ein persistentes Register sehr teuer ist, d.h. einen hohen Spritpreis hat. Deswegen wird bei der Programmierung peinlich genau darauf geachtet, möglichst wenig Speicher zu verschwenden. In Konsequenz heißt das dann auch, dass Ethereum-Nodes die Register komprimiert speichern können und letztendlich nur wenig Plattenplatz benötigt wird.
Solidity versucht demzufolge, die abstrakten Variablen auf ein möglichst effizientes Registerlayout abzubilden. Dazu gibt es verschiedene Strategien, z.B. mehrere 4-Byte-Werte in ein einziges Register zu packen. Trickreich wird es, wenn dynamische Strukturen wie Arrays mit flexibler Größe oder Hashtables abgelegt werden sollen. Solidity nutzt dafür Hashingverfahren und versteckt dies als Implementierungsdetail vor der Nutzerin. Die Details lassen sich aber in der Dokumentation nachlesen.
Speicherverwaltung von Rust
Rust hingegen hat ganz andere Ansprüche an die Programmiererinnen. Code soll ohne „Schnickschnack“ möglichst direkt auf Speicherlayout abgebildet werden können. Diese Einstellung kommt aus den C- und C++-Welten, in denen manuelle Speicherverwaltung Usus ist.
Das bisher noch experimentelle Ethereum-SDK für Rust lässt einen mit der Registerallokation weitestgehend alleine, so dass man die Adressen selbst ausrechnen muss.
Das ist auch der Grund, warum der struct
zum Contract leer geblieben ist: es gibt keinen Notwendigkeit dafür, irgendwelche Werte im Stack abzulegen.
Stattdessen deklariert man sich zwei globale Konstanten:
Würde man das automatisieren wollen, müsste man sich ein Makro schreiben, das ähnlich wie eth_abi
die Stuktur des Vertrags analysiert und Register passend alloziert.
Der große Vorteil von Rust-Makros gegenüber dem C-Präprozessor ist, dass man damit ASTs manipulieren kann, statt grobkörnig Text zu ersetzen.
Der dritte Schritt ist die Implementation der tatsächliche Logik des Vertrags:
Die Abläufe sind ähnlich zum Solidity-Pendant, aber es ist deutlich zu sehen, dass das SDK weniger Hilfestellung leistet.
Insbesondere muss man häufig zwischen verschiedenen Zahlentypen konvertieren (Hashes, Unsigned Integer, Bytearrays).
Der Registerzugriff läuft hier über spezielle Funktionen, die von der pwasm_ethereum
-Bibliothek bereitgestellt werden.
Intern handelt es sich dabei um dünne Wrapper über EWASM-Primitive, die also in der VM implementiert sind.
Call & Deploy
Als letzten Schritt definieren wir noch die Einstiegspunkte für den Vertrag für die beiden Fälle des initialen Deployments und normaler Aufrufe:
Diese beiden Funktionen sind bei den allermeisten Smart Contracts identisch.
Die einzige Aufgabe besteht darin, die eingehenden Argumente zu verarbeiten und an die automatisch (per eth_abi
) generierten dispatch
- und dispatch_ctor
-Methoden weiterzuleiten.
Diese kümmern sich dann um die Selektion der korrekten Smart-Contract-Methode.
Der gesamte Rust-Code des Artikels kann auf GitHub angesehen werden.