Smart Contracts in der Programmiersprache Rust

Wie wäre es, Rust in Kombination mit WebAssembly zu nutzen, um Smart Contracts zu entwickeln, die gegebenenfalls Millionen von Krypto-Tokens verwalten können?

In Pocket speichern vorlesen Druckansicht 59 Kommentare lesen
Smart Contracts in der Programmiersprache Rust

(Bild: phive/Shutterstock.com)

Lesezeit: 12 Min.
Von
  • Lars Hupel
Inhaltsverzeichnis

Die öffentliche Kryptoplattform Ethereum hat das Konzept der Smart Contracts popularisiert. Doch die hierfür gängige Programmiersprache Solidity leidet unter systematischen Problemen, die des Öfteren zu spektakulären Sicherheitslücken führen. Eine neue Generation von Sprachen schickt sich an, diese Mankos auszugleichen. Besonders interessant ist dabei die – derzeit noch experimentelle – Möglichkeit, WebAssembly als Bytecode-Format für Smart Contracts zu benutzen. Passend dazu eignet sich Rust als Hochsprache, die WASM-Code erzeugen kann.

Young Professionals schreiben für Young Professionals

Dieser Beitrag ist Teil einer Artikelserie, zu der die Heise-Redaktion junge Entwickler:innen einlädt – um über aktuelle Trends, Entwicklungen und persönliche Erfahrungen zu informieren. Bist du selbst ein "Young Professional" und willst einen (ersten) Artikel schreiben? Schicke deinen Vorschlag gern an die Redaktion: developer@heise.de. Wir stehen dir beim Schreiben zur Seite.

Ethereum ist eine erstmals 2015 von Vitalik Buterin, Gavin Wood und Jeffrey Wilcke vorgestellte Kryptowährung und Applikationsplattform. 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: das sogenannte Sprit. Es wird in Ether, der eingebauten Kryptowährung von Ethereum, gemessen. Er sorgt dafür, dass die Ausführung von Code endlich ist, das heißt niemand gratis Berechnungen ausführen kann. Ganz ähnlich wie ein Notar, der schließlich Gebühren für das Ausführen von Verträgen verlangt.

Die Ethereum-VM versteht eine Reihe von Assemblerbefehlen, sodass Verträge auf der untersten Ebene etwa so aussehen:

PUSH1 0x80
PUSH1 0x40
MSTORE
CALLVALUE
DUP1
ISZERO

Solchen Code möchte aber niemand von Hand schreiben. Daher gibt es 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:

pragma solidity >=0.4.22 <0.6;

contract Wallet {
    uint256 balance;
    address payable owner;
    constructor () public {
        balance = 0;
        owner = msg.sender;
    }
    function addfund() payable public returns (uint256) {
        require (msg.sender == owner);
        balance += msg.value;
        return balance;
    }
    function withdraw() public {
        require (msg.sender == owner);
        selfdestruct(owner);
    }
}

Auffällig ist zunächst, dass Solidity im Gegensatz zu JavaScript ein Typsystem besitzt. Außerdem sind bestimmte Methoden speziell annotiert. Im obigen Beispiel steht etwa payable dafür, dass sich mit der Methode Ether (abzüglich Sprit) in den Vertrag einzahlen lässt.

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 ruft 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.

Doch Solidity fällt dabei als nicht besonders solide auf. Eine Forschergruppe an der University of Texas hat in einer Untersuchung 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 sich Probleme in Programmiersprachen oftmals durch eine neue Version ausgleichen lassen, wiegen Designfehler in der Ethereum-VM deutlich schwerer. Aufgrund von Abwärtskompatibilität lässt sich Laufzeitverhalten nur schwer ändern, denn die gesamte Basis an Smart Contracts muss lauffähig bleiben.

Alles in allem erinnert das Problem stark an die Debatte, die die Community der Systemprogrammierer*innen 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 etliche Sicherheitsprobleme durch besseres Sprachdesign beseitigen möchte.

Auf das Tapet der Systemprogrammierung kam Rust erst verhältnismäßig spät. Graydon Hoare gestaltete für Mozilla eine Programmiersprache mit dem Ziel, eine neue, sicherere Browser-Engine zu schaffen. Mittlerweile haben 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 der vergangenen zwei, drei Jahre.

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. Beispielsweise ist es 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 stellen die Standardbibliothek und viele Pakete Abstraktionen bereit, mit denen Parallelismus generell einfacher zu handhaben ist. Mit der rayon-Bibliothek lässt sich zum Beispiel folgender Code schreiben:

let mut arr = [0, 7, 9, 11];
arr.par_iter_mut().for_each(|p| *p -= 1);
println!("{:?}", arr);

Der "mutable parallel iterator" erlaubt Veränderungen pro Array-Eintrag, aber unterbindet, dass sich diese Zeiger außerhalb der Iteration verwenden lassen.

Die Vorteile von Rust haben auch die Ethereum-Entwickler*innen erkannt. Unter der EWASM-Flagge (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 vom W3C ursprünglich für die Browser-Plattform vorangetriebene Spezifikation, die sich aber auch außerhalb des Web Beliebtheit erfreut.

Die Vorteile von WASM als Low-Level-Format für die Ethereum-VM sind eindeutig: Zum einen ist es das praxisorientierte Werk erfahrener Sprachdesigner und nicht eine proprietäre 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?

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:

#[eth_abi(WalletEndpoint, WalletClient)]
pub trait WalletInterface {
    fn constructor(&mut self);

    #[constant]
    fn owner(&mut self) -> Address;
    #[constant]
    fn balance(&mut self) -> U256;
    fn addfund(&mut self) -> bool;
    fn withdraw(&mut self) -> bool;
}

Der Code definiert ein Trait, ein Interface in Rust, das man später implementieren kann. Die Besonderheit besteht darin, dass alle Traits in Rust implizit einen Typparameter haben (Self). Ähnlich wie in Python ist das Objekt, auf dem eine Methode aufgerufen wird, explizit mitzuübergeben (self vom Typ Self).

Die Annotation am Trait (eth_abi) sorgt dafür, dass der Rust-Compiler automatisiert ABI-Definitionen erzeugt. Unter ABI (Application Binary Interface) versteht man die Konventionen, mit denen Funktionsaufrufe im kompilierten Bytecode stattfinden. Für Ethereum ist das notwendig, da die EVM 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 später mehr).

Im zweiten Schritt definiert man dann die tatsächliche Datenstruktur, die dem Vertrag zugrunde liegt. Im Regelfall kann diese leer ausfallen:

pub struct WalletContract;

Zunächst sieht das nicht intuitiv aus. Wo genau soll denn der Vertrag nun speichern, wer die Eigner sind und wie viele Ether sie momentan abgelegt haben? Dazu müssen wir kurz auf das Speichermodell von Ethereum eingehen.

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 sie 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 teuer ist, das heißt einen hohen Spritpreis hat. Deswegen wird bei der Programmierung peinlich genau darauf geachtet, möglichst wenig Speicher zu verschwenden. In Konsequenz heißt das auch, dass Ethereum-Nodes die Register komprimiert speichern können und letztlich nur wenig Plattenplatz benötigt wird.

Solidity versucht demzufolge, die abstrakten Variablen auf ein möglichst effizientes Registerlayout abzubilden. Dazu gibt es verschiedene Strategien, zum Beispiel 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 das als Implementierungsdetail vor den Nutzer*innen. Die Details lassen sich aber in der Dokumentation nachlesen.

Rust hingegen hat ganz andere Ansprüche an Programmierer*innen. Code soll sich ohne "Schnickschnack" möglichst direkt auf Speicherlayout abbilden lassen. 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 weitgehend allein, sodass man die Adressen selbst ausrechnen muss. Das ist der Grund, warum der struct zum Contract leer geblieben ist: Es gibt keine Notwendigkeit dafür, irgendwelche Werte im Stack abzulegen. Stattdessen deklariert man sich zwei globale Konstanten:

lazy_static! {
    static ref OWNER_KEY: H256 =
        H256::from([2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);
    static ref BALANCE_KEY: H256 =
        H256::from([3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0])
}

Würde man das automatisieren wollen, müsste man sich ein Makro schreiben, das ähnlich wie eth_abi die Struktur 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 Implementierung der tatsächlichen Logik des Vertrags:

impl WalletInterface for WalletContract {
    fn constructor(&mut self) {
        let sender: [u8; 32] = H256::from(pwasm_ethereum::sender()).into();
        pwasm_ethereum::write(&OWNER_KEY, &sender)
    }

    fn owner(&mut self) -> Address {
        H256::from(pwasm_ethereum::read(&OWNER_KEY)).into()
    }

    fn balance(&mut self) -> U256 {
        pwasm_ethereum::read(&BALANCE_KEY).into()
    }
    
    fn addfund(&mut self) -> bool {
        let sender = pwasm_ethereum::sender();
        if sender != self.owner() {
            false
        }
        else {
            let new_balance: [u8; 32] = (self.balance() + pwasm_ethereum::value()).into();
            pwasm_ethereum::write(&BALANCE_KEY, &new_balance);
            true
        }
    }
    
    fn withdraw(&mut self) -> bool {
        let sender = pwasm_ethereum::sender();
        if sender != self.owner() {
            false
        }
        else {
            pwasm_ethereum::suicide(&sender)
        }
    }
}

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, Byte-Arrays). Der Registerzugriff läuft hier über spezielle Funktionen, die die pwasm_ethereum-Bibliothek bereitstellt. Intern handelt es sich dabei um dünne Wrapper über EWASM-Primitive, die also in der VM implementiert sind.

Als letzten Schritt definieren wir noch die Einstiegspunkte für den Vertrag für die beiden Fälle des initialen Deployments und normaler Aufrufe:

#[no_mangle]
pub fn call() {
    let mut endpoint = WalletEndpoint::new(WalletContract{});
    pwasm_ethereum::ret(&endpoint.dispatch(&pwasm_ethereum::input()));
}

#[no_mangle]
pub fn deploy() {
    let mut endpoint = WalletEndpoint::new(WalletContract{});
    endpoint.dispatch_ctor(&pwasm_ethereum::input());
}

Die beiden Funktionen sind bei den meisten 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. Sie kümmern sich um die Auswahl der korrekten Smart-Contract-Methode.

Rust hat sich im Laufe der Zeit zu einer durchaus komfortablen Programmiersprache mit herausragender Tooling-Unterstützung gemausert, wobei das strikte Typsystem einige Eingewöhnung abverlangt. In den Beispielen des Artikels zeigt sich allerdings, dass das EWASM-SDK für Rust noch nicht ausgereift ist. Diese Begrenzungen sind allerdings nicht systematisch, sondern nur auf die derzeitige Experimentierphase zurückzuführen. In Zukunft könnten sich weitere Bibliotheken und Frameworks herausbilden, mit denen Smart Contracts ähnlich komfortabel wie in Solidity zu programmieren sind. Doch bis dahin ist es noch ein weiter Weg. So fehlt es noch an vielen Dingen, etwa einer breiten Implementierung (bislang unterstützt nur der Parity-Client EWASM).

Einige Vorteile zeichnen sich aber ab. Sowohl Rust als auch WASM sind Techniken mit Zukunft. Das starke Typsystem lässt sich dafür nutzen, Verträge sicherer zu gestalten und gängige Sicherheitslücken zu tilgen. Mit WASM als Kompilierziel sind polyglotte Sprach- als und Ausführungsszenarien denkbar: Der gleiche Code ließe sich dann klassisch auf Client oder Server, aber auch mit minimalen Änderungen auf der Blockchain auszuführen.

Den gesamten Rust-Code findet man auf GitHub.

Lars Hupel
ist Consultant bei INNOQ in München und bekannt als einer der Gründer der Typelevel-Initiative, die sich der Entwicklung von typgetriebenen Scala-Bibliotheken in einer einsteigerfreundlichen Umgebung verschrieben hat. Er spricht oft auf Konferenzen und ist im Open-Source-Umfeld in Scala unterwegs. Außerdem programmiert und redet er gern über Haskell, Prolog und Rust.

(ane)