Hier geht es eigentlich nur um intelligente Verträge.
Wenn Sie sich jedoch nicht genau vorstellen können, was ein intelligenter Vertrag ist und im Allgemeinen weit von Krypto entfernt ist, können Sie sich vorstellen, was eine in einer Datenbank gespeicherte Prozedur ist. Der Benutzer erstellt Codeteile, die dann auf unserem Server funktionieren. Es ist für den Benutzer bequem, sie zu schreiben und zu veröffentlichen, und für uns ist es sicher, sie auszuführen.
Leider haben wir noch keine Sicherheit entwickelt, daher werde ich sie jetzt nicht beschreiben, aber ich werde ein paar Hinweise geben.
Wir schreiben auch auf Go, und seine Laufzeit unterliegt einigen sehr spezifischen Einschränkungen. Die wichtigste davon ist, dass wir im Großen und Ganzen keine Verknüpfung zu einem anderen Projekt herstellen können, das nicht unterwegs geschrieben wurde. Dadurch wird unsere Laufzeit jedes Mal gestoppt, wenn wir Code von Drittanbietern ausführen. Im Allgemeinen haben wir die Möglichkeit, eine Art Dolmetscher zu verwenden, für den wir eine völlig gesunde Lua und eine völlig gesunde WASM gefunden haben, aber irgendwie möchte ich Lua keine Kunden hinzufügen, aber mit WASM gibt es jetzt mehr Probleme als Vorteile, es befindet sich in einem Entwurfszustand , die jeden Monat aktualisiert wird, also warten wir, bis sich die Spezifikation beruhigt hat. Wir benutzen es als zweiten Motor.
Aufgrund langwieriger Kämpfe mit seinem eigenen Gewissen wurde beschlossen, intelligente Verträge über GO abzuschließen. Tatsache ist, dass Sie, wenn Sie die Architektur für die Ausführung von kompiliertem GO-Code erstellen, diese Ausführung aus Sicherheitsgründen auf einen separaten Prozess übertragen müssen, wie Sie sich erinnern, und die Übertragung auf einen separaten Prozess einen Leistungsverlust für IPC darstellt, obwohl wir in Zukunft das Volumen der ausführbaren Datei verstanden haben Code, es war sogar irgendwie angenehm, dass wir diese Lösung gewählt haben. Die Sache ist, dass es skalierbar ist, obwohl es jedem einzelnen Anruf eine Verzögerung hinzufügt. Wir können viele entfernte Laufzeiten erhöhen.
Ein bisschen mehr über die Entscheidungen, die getroffen wurden, damit es klar ist. Jeder Smart-Vertrag besteht aus zwei Teilen, ein Teil ist der Klassencode und der zweite Teil sind die Objektdaten. Mit demselben Code können wir nach Veröffentlichung des Codes viele Verträge erstellen, die sich im Wesentlichen gleich verhalten, jedoch unterschiedliche Einstellungen aufweisen und mit einem anderen Zustand. Wenn wir weiter reden, dann geht es bereits um Blockchain und nicht um das Thema dieser Geschichte.
Und so führen wir GO aus
Wir haben uns für den Plugin-Mechanismus entschieden, der nicht nur fertig und gut ist. Er führt Folgendes aus: Wir kompilieren ein Plugin auf besondere Weise in eine gemeinsam genutzte Bibliothek, laden es dann, suchen die darin enthaltenen Symbole und übergeben dort die Ausführung. Aber der Haken ist, dass GO eine Laufzeit hat, und dies ist fast ein Megabyte Code, und standardmäßig geht diese Laufzeit auch in diese Bibliothek, und wir haben überall eine Raznipipenny-Laufzeit. Aber jetzt haben wir uns entschlossen, es zu versuchen und sicher zu sein, dass wir es in Zukunft besiegen können.
Alles ist einfach, wenn Sie Ihre Bibliothek erstellen. Sie erstellen sie mit dem Schlüssel - buildmode = plugin und erhalten die .so-Datei, die Sie dann öffnen.
p, err := plugin.Open(path)
Auf der Suche nach dem Charakter, an dem Sie interessiert sind:
symbol, err := p.Lookup(Method)
Und jetzt, je nachdem, ob die Variable eine Funktion oder eine Funktion ist, rufen Sie sie entweder auf oder verwenden sie als Variable.
Unter der Haube dieses Mechanismus befindet sich ein einfaches dlopen (3). Wir laden die Bibliothek, überprüfen, ob es sich um ein Plugin handelt, und geben den Wrapper darüber. Beim Erstellen des Wrappers werden alle exportierten Zeichen in die Schnittstelle {} eingeschlossen und gespeichert. Wenn es sich um eine Funktion handelt, muss sie auf den richtigen Funktionstyp reduziert und einfach aufgerufen werden, wenn die Variable - dann wie eine Variable funktioniert.
Das Wichtigste ist, dass ein Symbol, wenn es sich um eine Variable handelt, während des gesamten Prozesses global ist und Sie es nicht gedankenlos verwenden können.
Wenn im Plugin ein Typ deklariert wurde, ist es sinnvoll, diesen Typ in ein separates Paket zu stellen, damit der Hauptprozess damit arbeiten kann, z. B. als Argumente an die Funktionen des Plugins übergeben. Dies ist optional, Sie können nicht dämpfen und Reflexion verwenden.
Unsere Verträge sind Objekte der entsprechenden „Klasse“, und zu Beginn wurde die Instanz dieses Objekts in unserer exportierten Variablen gespeichert, sodass wir eine weitere Variable erstellen können:
export, err := p.Lookup("EXPORT") obj := reflect.New(reflect.ValueOf(export).Elem().Type()).Interface()
Deserialisieren Sie bereits in dieser lokalen Variablen des richtigen Typs den Status des Objekts. Nachdem das Objekt wiederhergestellt wurde, können wir Methoden dafür aufrufen. Danach wird das Objekt serialisiert und wieder zum Store hinzugefügt. Prost, wir haben die Methode für den Vertrag aufgerufen.
Wenn Sie daran interessiert sind, wie, aber zu faul, um die Dokumentation zu lesen, dann:
method := reflect.ValueOf(obj).MethodByName(Method) res:= method.Call(in)
In der Mitte müssen Sie das In-Array auch mit leeren Schnittstellen füllen, die den richtigen Argumenttyp enthalten. Wenn Sie interessiert sind, sehen Sie selbst, wie es gemacht wurde. Die Quellen sind offen, obwohl es schwierig sein wird, diesen Ort in der
Geschichte zu finden.
Im Allgemeinen hat alles für uns funktioniert. Sie können Code mit einer Klasse schreiben, ihn in die Blockchain einfügen, einen Vertrag dieser Klasse erneut in der Blockchain erstellen, einen Methodenaufruf darauf ausführen und den neuen Status des Vertrags in die Blockchain zurückschreiben. Großartig! Wie erstelle ich einen neuen Vertrag mit dem vorliegenden Code? Sehr einfach, wir haben Konstruktorfunktionen, die ein frisch erstelltes Objekt zurückgeben, nämlich den neuen Vertrag. Bisher funktioniert alles durch Reflexion und der Benutzer muss schreiben:
var EXPORT ContractType
Damit wir wissen, welches Symbol eine Darstellung des Vertrags ist, und es tatsächlich als Vorlage verwenden.
Wir mögen es nicht wirklich. Und wir haben hart getroffen.
Parsen
Erstens sollte der Benutzer nichts Überflüssiges schreiben, und zweitens haben wir die Idee, dass die Interaktion des Vertrags mit dem Vertrag einfach sein und getestet werden sollte, ohne die Blockchain zu erhöhen. Blockchain ist langsam und schwierig.
Aus diesem Grund haben wir uns entschlossen, den Vertrag in einen Wrapper zu verpacken, der auf der Grundlage des Vertrags und der Wrapper-Vorlage im Prinzip eine verständliche Lösung darstellt. Erstens erstellt der Wrapper ein Exportobjekt für uns und zweitens ersetzt er die Bibliothek, mit der der Vertrag erfasst wird, wenn der Benutzer den Vertrag schreibt, die Grundlagenbibliothek wird mit den darin enthaltenen Mokas verwendet und wenn der Vertrag veröffentlicht wird, wird er durch ein Kampfobjekt ersetzt, das mit der Blockchain selbst funktioniert .
Um zu beginnen, müssen Sie den Code analysieren und verstehen, was wir im Allgemeinen haben, und die Struktur finden, die von BaseContract geerbt wird, um einen Wrapper um ihn herum zu generieren.
Dies geschieht ganz einfach. Wir lesen die Datei mit dem Code in [] Byte. Obwohl der Parser selbst die Dateien lesen kann, ist es gut, den Text an einer Stelle zu haben, auf die sich alle AST-Elemente beziehen. Sie beziehen sich auf die Bytenummer in der Datei und in Zukunft möchten wir sie erhalten Den Strukturcode so wie er ist, nehmen wir einfach so etwas.
func (pf *ParsedFile) codeOfNode(n ast.Node) string { return string(pf.code[n.Pos()-1 : n.End()-1]) }
Wir analysieren die Datei tatsächlich und erhalten den obersten AST-Knoten, von dem aus wir die Datei crawlen.
fileSet = token.NewFileSet() node, err := parser.ParseFile(fileSet, name, code, parser.ParseComments)
Als nächstes gehen wir vom obersten Knoten aus um den Code herum und sammeln alles Interessante in einer separaten Struktur.
for _, decl := range node.Decls { switch d := decl.(type) { case *ast.GenDecl: … case *ast.FuncDecl: … } }
Decls, es wurde bereits in ein Array analysiert, eine Liste aller in der Datei definierten Elemente. Es handelt sich jedoch um ein Array von Decl-Schnittstellen, die nicht beschreiben, was sich darin befindet. Daher muss jedes Element in einen bestimmten Typ konvertiert werden. Die Schnittstelle in go / ast ist eher eine Basisklasse.
Wir interessieren uns für Knoten vom Typ GenDecl und FuncDecl. GenDecl ist die Definition einer Variablen oder eines Typs. Sie müssen überprüfen, was genau der Typ darin ist, und ihn erneut in den TypeDecl-Typ umwandeln, mit dem Sie bereits arbeiten können. FuncDecl ist einfacher - es ist eine Funktion, und wenn das Recv-Feld ausgefüllt ist, ist dies eine Methode der entsprechenden Struktur. Wir sammeln all diese Dinge in einem praktischen Speicher, weil wir dann Text / Vorlage verwenden und es nicht viel Ausdruckskraft hat.
Das einzige, woran wir uns separat erinnern müssen, ist der Name des Datentyps, der von BaseContract geerbt wird, und wir werden darum herum tanzen.
Codegenerierung
Daher kennen wir alle Typen und Funktionen, die in unserem Vertrag enthalten sind, und müssen in der Lage sein, einen Methodenaufruf für ein Objekt aus dem Namen der eingehenden Methode und dem serialisierten Array von Argumenten durchzuführen. Zum Zeitpunkt der Codegenerierung kennen wir jedoch das gesamte Gerät des Vertrags. Daher stellen wir neben unsere Vertragsdatei neben eine andere Datei mit demselben Paketnamen, in die wir alle erforderlichen Importe einfügen. Die Typen sind bereits in der Hauptdatei definiert und nicht erforderlich.
Und hier ist die Hauptsache, Wrapper über Funktionen. Der Name des Wrappers wird durch eine Art Präfix ergänzt, und jetzt ist der Wrapper leicht zu finden.
symbol, err := p.Lookup("INSMETHOD_" + Method) wrapper, ok := symbol.(func(ph proxyctx.ProxyHelper, object []byte, data []byte) (object []byte, result []byte, err error))
Jeder Wrapper hat dieselbe Signatur. Wenn wir ihn also vom Hauptprogramm aus aufrufen, benötigen wir keine zusätzlichen Überlegungen. Das einzige ist, dass sich die Funktions-Wrapper von den Methoden-Wrappern unterscheiden. Sie empfangen den Status des Objekts nicht und geben ihn nicht zurück.
Was haben wir in der Hülle?
Wir erstellen ein Array von leeren Variablen, die den Argumenten der Funktion entsprechen, fügen es in eine Variable vom Typ eines Arrays von Schnittstellen ein und deserialisieren die Argumente darin. Wenn wir eine Methode sind, müssen wir auch den Status des Objekts serialisieren, im Allgemeinen ungefähr so:
{{ range $method := .Methods }} func INSMETHOD_{{ $method.Name }}(ph proxyctx.ProxyHelper, object []byte, data []byte) ([]byte, []byte, error) { self := new({{ $.ContractType }}) err := ph.Deserialize(object, self) if err != nil { return nil, nil, err } {{ $method.ArgumentsZeroList }} err = ph.Deserialize(data, &args) if err != nil { return nil, nil, err } {{ if $method.Results }} {{ $method.Results }} := self.{{ $method.Name }}( {{ $method.Arguments }} ) {{ else }} self.{{ $method.Name }}( {{ $method.Arguments }} ) {{ end }} state := []byte{} err = ph.Serialize(self, &state) if err != nil { return nil, nil, err } {{ range $i := $method.ErrorInterfaceInRes }} ret{{ $i }} = ph.MakeErrorSerializable(ret{{ $i }}) {{ end }} ret := []byte{} err = ph.Serialize([]interface{} { {{ $method.Results }} }, &ret) return state, ret, err } {{ end }}
Ein aufmerksamer Leser wird sich dafür interessieren, was ein Proxy-Helfer ist. - Dies ist ein solches Kombinationsobjekt, das wir noch benötigen, aber im Moment nutzen wir seine Fähigkeit zum Serialisieren und Deserialisieren.
Nun, jeder, der liest, wird fragen: "Aber das sind Ihre Argumente, woher kommen sie?" Hier ist auch eine verständliche Antwort: Ja, Text / Vorlage, es gibt nicht genügend Sterne vom Himmel. Deshalb berechnen wir diese Zeilen im Code und nicht in der Vorlage.
method.ArgumentsZeroList enthält so etwas wie
var arg0 int = 0 Var arg1 string = “” Var arg2 ackwardType = ackwardType{} Args := []interface{}{&arg0, &arg1, &arg2}
Und Argumente enthalten dementsprechend "arg0, arg1, arg2".
So können wir mit jeder Unterschrift alles aufrufen, was wir wollen.
Wir können jedoch keine Antwort serialisieren. Tatsache ist, dass Serialisierer mit Reflexion arbeiten und keinen Zugriff auf nicht exportierte Felder von Strukturen gewähren. Deshalb haben wir eine spezielle Proxy-Hilfsmethode, die ein Fehlerschnittstellenobjekt verwendet und daraus ein Objekt vom Typ Fundament erstellt. Fehler, der sich vom üblichen dadurch unterscheidet, dass sich der Fehlertext im exportierten Feld befindet und wir ihn serialisieren können, wenn auch mit einigem Verlust.
Wenn wir jedoch einen sterilisierenden Sterilisator verwenden, brauchen wir ihn nicht einmal. Wir sind in demselben Paket kompiliert und haben Zugriff auf nicht exportierte Felder.
Aber was ist, wenn wir einen Vertrag von einem Vertrag abrufen wollen?
Sie verstehen die Tiefe des Problems nicht, wenn Sie der Meinung sind, dass es einfach ist, einen Vertrag von einem Vertrag abzurufen. Tatsache ist, dass die Gültigkeit eines anderen Vertrags durch Konsens bestätigt werden muss und die Tatsache dieses Aufrufs in der Blockchain unterschrieben werden muss. Im Allgemeinen funktioniert es nicht, einfach mit einem anderen Vertrag zu kompilieren und dessen Methode aufzurufen, obwohl ich es wirklich möchte. Aber wir sind Freunde von Programmierern, deshalb sollten wir ihnen die Möglichkeit geben, alles direkt zu tun und alle Tricks unter der Haube des Systems zu verstecken. Die Vertragsentwicklung ist also wie bei direkten Anrufen, und die Verträge ziehen sich transparent aneinander. Wenn wir jedoch den Vertrag zur Veröffentlichung abholen, schieben wir einen Proxy anstelle eines anderen Vertrags, der nur dessen Adresse und Anrufsignaturen über den Vertrag kennt.
Wie organisiert man das alles? - Wir müssen andere Verträge in einem speziellen Verzeichnis speichern, damit unser Generator Proxys für jeden importierten Vertrag erkennen und erstellen kann.
Das heißt, wenn wir uns trafen:
import “ContractsDir/ContractAddress"
Wir schreiben es in die Liste der importierten Verträge.
Übrigens, dafür müssen Sie den Quellcode des Vertrags nicht kennen, Sie müssen nur die Beschreibung kennen, die wir bereits zusammengestellt haben. Wenn wir also irgendwo eine solche Beschreibung veröffentlichen und alle Anrufe über das Hauptsystem laufen, ist es uns egal, was Ein anderer Vertrag ist in der Sprache geschrieben. Wenn wir Methoden darauf aufrufen können, können wir auf Go einen Stub dafür schreiben, der wie ein Paket mit einem Vertrag aussieht, der direkt aufgerufen werden kann. Napoleonische Pläne, fangen wir an.
Grundsätzlich haben wir bereits eine Proxy-Helfer-Methode mit dieser Signatur:
RouteCall(ref Address, method string, args []byte) ([]byte, error)
Diese Methode kann direkt aus dem Vertrag aufgerufen werden. Sie ruft den Remote-Vertrag auf und gibt eine serialisierte Antwort zurück, die wir analysieren und zu unserem Vertrag zurückkehren müssen.
Für den Benutzer muss jedoch alles so aussehen:
ret := contractPackage.GetObject(Address).Method(arg1,arg2, …)
Beginnen wir zunächst im Proxy. Sie müssen alle Typen auflisten, die in den Signaturen der Vertragsmethoden verwendet werden. Wie wir uns jedoch erinnern, können wir für jeden AST-Knoten seine Textdarstellung verwenden, und jetzt ist die Zeit für diesen Mechanismus gekommen.
Als nächstes müssen wir eine Art Vertrag erstellen, im Prinzip kennt er seine Klasse bereits, nur eine Adresse wird benötigt.
type {{ .ContractType }} struct { Reference Address }
Als nächstes müssen wir irgendwie die GetObject-Funktion implementieren, die an der Adresse in der Blockchain eine Proxy-Instanz zurückgibt, die weiß, wie man mit diesem Vertrag arbeitet, und für den Benutzer sieht sie wie eine Vertragsinstanz aus.
func GetObject(ref Address) (r *{{ .ContractType }}) { return &{{ .ContractType }}{Reference: ref} }
Interessanterweise ist die GetObject-Methode im Benutzer-Debugging-Modus direkt eine BaseContract-Strukturmethode, aber nichts hindert uns daran, die SLA zu beachten, um das zu tun, was für uns bequem ist. Jetzt können wir einen Proxy-Vertrag erstellen, dessen Methoden wir steuern. Es bleibt tatsächlich Methoden zu erstellen.
{{ range $method := .MethodsProxies }} func (r *{{ $.ContractType }}) {{ $method.Name }}( {{ $method.Arguments }} ) ( {{ $method.ResultsTypes }} ) { {{ $method.InitArgs }} var argsSerialized []byte err := proxyctx.Current.Serialize(args, &argsSerialized) if err != nil { panic(err) } res, err := proxyctx.Current.RouteCall(r.Reference, "{{ $method.Name }}", argsSerialized) if err != nil { panic(err) } {{ $method.ResultZeroList }} err = proxyctx.Current.Deserialize(res, &resList) if err != nil { panic(err) } return {{ $method.Results }} } {{ end }}
Hier die gleiche Geschichte mit der Konstruktion der Argumentliste, da wir faul sind und genau den ast.Node der Methode speichern, werden für Berechnungen viele Typkonvertierungen benötigt, die die Vorlagen nicht kennen, sodass alles im Voraus vorbereitet wird. Mit Funktionen ist alles sehr viel komplizierter, und dies ist das Thema eines anderen Artikels.
Die Funktionen, die wir haben, sind Objektkonstruktoren, und es wird viel Wert darauf gelegt, wie Objekte tatsächlich in unserem System erstellt werden. Die Tatsache der Erstellung wird auf einem Remote-Executor registriert, das Objekt wird auf einen anderen Executor übertragen, es wird dort überprüft und tatsächlich gespeichert, und es gibt viele Möglichkeiten, vergeblich zu speichern Dieser Wissensbereich wird Krypta genannt. Und die Idee ist im Grunde einfach: ein Wrapper, in dem nur die Adresse gespeichert ist, und Methoden, die den Aufruf serialisieren und unseren Singleton-Prozessor ziehen, der den Rest erledigt. Wir können den übertragenen Proxy-Helfer nicht verwenden, da der Benutzer ihn nicht an uns weitergegeben hat. Daher mussten wir ihn zu einem Singleton machen.
Ein weiterer Trick: Tatsächlich verwenden wir immer noch den Aufrufkontext. Dies ist ein solches Objekt, das Informationen darüber speichert, wer, wann, warum, warum unser intelligenter Vertrag aufgerufen wurde. Auf der Grundlage dieser Informationen trifft der Benutzer eine Entscheidung, ob er überhaupt eine Ausführung geben möchte, und wenn möglich dann wie.
Zuvor haben wir den Kontext einfach übergeben. Es war ein nicht ausdrückbares Feld im BaseContract-Typ mit einem Setter und einem Getter, und der Setter erlaubte, das Feld nur einmal festzulegen, sodass der Kontext festgelegt wurde, bevor der Vertrag ausgeführt wurde, und der Benutzer ihn nur lesen konnte.
Aber hier ist das Problem: Der Benutzer liest diesen Kontext nur. Wenn er eine Systemfunktion aufruft, z. B. einen Proxy-Aufruf an einen anderen Vertrag, erhält dieser Proxy-Aufruf keinen Kontext, da ihn niemand weiterleitet. Und dann betritt der lokale Goroutine-Speicher die Szene. Wir haben uns entschieden, keine eigenen zu schreiben, sondern github.com/tylerb/gls zu verwenden.
Hier können Sie den Kontext für die aktuelle Goroutine festlegen und übernehmen. Wenn also innerhalb des Vertrags keine Goroutine erstellt wurde, setzen wir den Kontext nur in gls, bevor wir den Vertrag starten. Jetzt geben wir dem Benutzer keine Methode, sondern nur eine Funktion.
func GetContext() *core.LogicCallContext { return gls.Get("ctx").(*core.LogicCallContext) }
Und er verwendet es gerne, aber wir verwenden es beispielsweise in RouteCall (), um zu verstehen, welcher Vertrag gerade jemanden aufruft.
Im Prinzip kann der Benutzer eine Goroutine erstellen. Wenn dies jedoch der Fall ist, geht der Kontext verloren. Daher müssen wir etwas damit tun. Wenn der Benutzer beispielsweise das Schlüsselwort go verwendet, müssen wir solche Aufrufe in unseren Wrapper einschließen, an den sich der Kontext erinnert und die er erstellt goroutine und stelle den Kontext darin wieder her, aber dies ist das Thema eines anderen Artikels.
Alle zusammen
Grundsätzlich gefällt uns, wie die GO-Sprach-Toolchain funktioniert. Tatsächlich sind es eine Reihe verschiedener Befehle, die eine Sache ausführen, die beispielsweise beim Erstellen zusammen ausgeführt werden. Wir haben uns dazu entschlossen, ein Team legt eine Vertragsdatei in einem temporären Verzeichnis ab, das zweite legt einen Wrapper dafür ab und ruft ein drittes Mal auf, wodurch ein Proxy für jeden importierten Vertrag erstellt wird, das vierte kompiliert alles, das fünfte veröffentlicht es in der Blockchain. Und es gibt einen Befehl, um sie alle in der richtigen Reihenfolge auszuführen.
Hurra, wir haben jetzt eine Toolchain und eine Laufzeit zum Starten von GO from GO. Es gibt immer noch viele Probleme, zum Beispiel müssen Sie nicht verwendeten Code irgendwie entladen, Sie müssen irgendwie feststellen, dass er hängt, und den angehaltenen Prozess neu starten, aber dies sind Aufgaben, die klar sind, wie man ihn löst.
Ja, natürlich gibt der Code, den wir geschrieben haben, nicht vor, eine Bibliothek zu sein, er kann nicht direkt verwendet werden, aber das Lesen eines Beispiels für die Generierung von Arbeitscode ist immer großartig, einmal habe ich ihn verpasst. Dementsprechend kann ein Teil der Codegenerierung im
Compiler angezeigt werden, aber wie er im
Executor startet.