Smart Contracts in Rust

Sicherer programmieren in Ethereum

Ethereum ist die erste öffentliche Kryptoplattform, die das Konzept der Smart Contracts popularisiert hat, und auch immer noch die erfolgreichste. Doch die gängige Programmiersprache Solidity leidet unter einer ganzen Reihe von 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. Wir schauen uns an, wie das intern funktioniert und solche Verträge implementiert werden können.

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:

PUSH1 0x80
PUSH1 0x40
MSTORE
CALLVALUE
DUP1
ISZERO

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:

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 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:

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

#[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;
}

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:

pub struct WalletContract;

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:

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 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:

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, 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:

#[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());
}

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.

Fazit

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 obigen Beispielen zeigt sich allerdings, dass das EWASM-SDK für Rust noch nicht ausgereift ist.

Diese Limitationen 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, wie z.B. einer breiten Implementierung (bislang unterstützt nur der Parity-Client EWASM).

Einige Vorteile zeichnen sich aber bereits jetzt schon ab. Sowohl Rust als auch WASM sind Technologien mit Zukunft. Das starke Typsystem kann dafür genutzt werden, Verträge sicherer zu gestalten und gängige Sicherheitslücken auszumerzen. Mit WASM als Kompilationsziel sind sowohl polyglotte Sprach- als auch Ausführungsszenarien denkbar: der gleiche Code könnte dann klassisch auf Client oder Server, aber auch mit minimalen Änderungen auf der Blockchain ausgeführt werden.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen