Achtung - GAS! oder wie wir intelligente Verträge ohne Kohlensäure abgeschlossen haben


Blockchain- und Smart-Verträge sind nach wie vor ein heißes Thema bei Entwicklern und Technikern. Es gibt viel Forschung und Diskussion über ihre Zukunft und wohin sich alles bewegt und wohin es führen wird. Wir von Waves Platform haben unsere eigene Meinung dazu, was intelligente Verträge sein sollten, und in diesem Artikel werde ich Ihnen erklären, wie wir sie abgeschlossen haben, auf welche Probleme wir gestoßen sind und warum sie nicht wie intelligente Verträge anderer Blockchain-Projekte sind (vor allem) Ethereum).


Dieser Artikel ist auch ein Leitfaden für diejenigen, die verstehen möchten, wie intelligente Verträge im Waves-Netzwerk funktionieren, versuchen, einen eigenen Vertrag zu schreiben und sich mit den Tools vertraut zu machen, über die Entwickler bereits verfügen.


Wie sind wir zu einem solchen Leben gekommen?


Wir wurden oft gefragt, wann wir intelligente Verträge erhalten haben, weil die Entwickler die einfache Arbeit mit dem Netzwerk, die Netzwerkgeschwindigkeit (dank Waves NG ) und die geringen Provisionen mochten. Intelligente Verträge bieten jedoch viel mehr Raum für Fantasie.


Intelligente Verträge sind in den letzten Jahren aufgrund der Verbreitung der Blockchain sehr beliebt geworden. Diejenigen, die bei ihrer Arbeit auf Blockchain-Technologie gestoßen sind, denken bei der Erwähnung intelligenter Verträge normalerweise an Ethereum und Solidity. Es gibt jedoch viele Blockchain-Plattformen mit intelligenten Verträgen, und die meisten von ihnen haben einfach wiederholt, was Ethereum getan hat (virtuelle Maschine + ihre eigene Vertragssprache). Eine interessante Liste mit verschiedenen Sprachen und Ansätzen befindet sich in diesem Repository .


Was ist ein intelligenter Vertrag?


Im weitesten Sinne ist ein intelligenter Vertrag ein Protokoll zur Unterstützung, Überprüfung und Durchsetzung der Bedingungen einer Transaktion oder der Ausführung von Verträgen zwischen Parteien. Die Idee wurde erstmals 1996 von Nick Szabo vorgeschlagen, aber intelligente Verträge sind erst in den letzten Jahren populär geworden.


Aus technischer Sicht (was uns mehr interessiert) ist ein intelligenter Vertrag ein Algorithmus (Code), der nicht auf einem Server oder Computer ausgeführt wird, sondern auf vielen (oder allen) Knoten im Blockchain-Netzwerk, d. H. dezentralisiert.


Wie funktioniert es


Der erste Prototyp eines intelligenten Vertrags in der Blockchain wird korrekt als Bitcoin-Skript betrachtet - unvollständig von Turing, einer stapelbasierten Sprache im Bitcoin-Netzwerk. In Bitcoin gibt es kein Kontokonzept, stattdessen gibt es Ein- und Ausgänge. In Bitcoin muss beim Erstellen einer Transaktion (Erstellen einer Ausgabe) auf die empfangene Transaktion (Eingabe) verwiesen werden. Wenn Sie an den technischen Details des Bitcoin-Geräts interessiert sind, empfehle ich Ihnen, diese Artikelserie zu lesen. Da Bitcoin keine Konten enthält, bestimmt Bitcoin Script, in welchem ​​Fall der eine oder andere Exit ausgegeben werden kann.


Ethereum bietet viel mehr Funktionen als Bietet Solidity, eine Turing-vollständige Sprache, die in einer virtuellen Maschine in jedem Knoten ausgeführt wird. Mit großer Kraft geht große Verantwortung und eine Vielzahl von Möglichkeiten einher - eine ziemlich große Anzahl von Einschränkungen, über die wir später sprechen werden.


Intelligente Verträge Wellen


Wie ich oben schrieb, wurden wir oft nach intelligenten Verträgen gefragt, aber wir wollten nicht "like on air" oder "like in anyblockchainname" machen, und es gibt viele Gründe dafür . Daher haben wir die bestehenden Fälle für Verträge analysiert und wie wir mit ihrer Hilfe helfen können, echte Probleme zu lösen.


Nach der Analyse der Nutzungsszenarien haben wir festgestellt, dass es zwei große Kategorien von Aufgaben gibt, die normalerweise mit intelligenten Verträgen gelöst werden:


  1. Einfache und unkomplizierte Aufgaben wie Multisig, Atomic Swaps oder Escrow.
  2. dApps, vollwertige dezentrale Anwendungen mit Benutzerlogik. Genauer gesagt ist dies ein Backend für dezentrale Anwendungen. Die auffälligsten Beispiele sind Cryptokitties oder Bancor.

Es gibt auch eine dritte, beliebteste Art von Verträgen - Token. Im Ethereum-Netzwerk beispielsweise sind die meisten Arbeitsverträge ERC20-Standard-Token. In Waves müssen zum Erstellen von Token keine intelligenten Verträge abgeschlossen werden Sie sind Teil der Blockchain selbst. Um ein Token auszugeben (mit der Möglichkeit, es sofort an einer dezentralen Börse (DEX) zu handeln), reicht es aus, eine Transaktion des Ausgabetyps (Ausgabetransaktion) zu senden.


Für die beiden oben genannten Arten von Aufgaben (der Einfachheit halber werden wir einfache und komplexe Fälle nennen) sind die Anforderungen an Sprache, Verträge und Konzepte sehr unterschiedlich. Ja, wir können sagen, dass eine Turing-vollständige Sprache sowohl einfache als auch komplexe Probleme lösen kann, aber es gibt eine wichtige Bedingung: Die Sprache sollte helfen, Fehler zu vermeiden. Diese Anforderung ist auch für gewöhnliche Sprachen wichtig, und für intelligente Vertragssprachen ist sie besonders wichtig, weil Operationen sind finanziell miteinander verbunden, und Verträge sind oft unveränderlich, und es gibt keine Möglichkeit, einen Fehler schnell und einfach zu beheben.


Angesichts der oben beschriebenen Aufgabentypen haben wir uns entschlossen, schrittweise voranzukommen und als ersten Schritt ein Werkzeug zur Lösung einfacher Probleme bereitzustellen und als nächsten Schritt eine Sprache anzugeben, mit der jede Benutzerlogik problemlos implementiert werden kann. Infolgedessen erwies sich das System als viel leistungsfähiger, als wir es uns zu Beginn der Reise vorgestellt hatten.


Lassen Sie uns Konten intelligent machen


Allmählich kamen wir zum Konzept der Smart Accounts, mit denen vor allem einfache Aufgaben gelöst werden sollen. Ihre Idee ist Bitcoin Script sehr ähnlich: Dem Konto können zusätzliche Regeln hinzugefügt werden, die die Gültigkeit der ausgehenden Transaktion bestimmen. Die Hauptanforderungen für Smart Accounts waren:


  1. Maximale Sicherheit. Fast jeden Monat finden Sie Neuigkeiten, dass in Ethereum-Modellverträgen eine weitere Sicherheitslücke gefunden wurde. Das wollten wir vermeiden.
  2. Kein Gasbedarf, so dass die Provision feststeht. Dazu muss das Skript in einer vorhersehbaren Zeit ausgeführt werden und recht strenge Größenbeschränkungen aufweisen.

Bevor wir mit den technischen Details der Implementierung und des Schreibens von Verträgen fortfahren, skizzieren wir einige Merkmale der Waves-Blockchain, die für das weitere Verständnis wichtig sind:


  1. Die Waves-Blockchain verfügt derzeit über 13 verschiedene Arten von Transaktionen.


  1. In der Waves-Blockchain keine Ein- und Ausgänge (wie in Bitcoin), sondern Konten (wie zum Beispiel in Nxt). Eine Transaktion wird für ein bestimmtes Konto ausgeführt.
  2. Standardmäßig wird die Richtigkeit einer Transaktion durch den aktuellen Status der Blockchain und die Gültigkeit der Signatur bestimmt, für die die Transaktion gesendet wird. Die JSON-Darstellung der Transaktion sieht einfach aus:


Da wir bereits verschiedene Arten von Transaktionen in der Blockchain haben, haben wir beschlossen, keine separate Entität als Smart-Konto zu erstellen, sondern eine neue Transaktion hinzuzufügen, die aus einem regulären Konto ein Smart-Konto macht. Jedes Konto kann zu einem Smart-Konto mit geänderten Transaktionsüberprüfungsregeln werden. Dazu sollte das Konto einfach eine Transaktion vom Typ SetScriptTransaction , die den kompilierten Vertrag enthält.


Bei einem Smart Account ist der Vertrag eine Validierungsregel für jede ausgehende Transaktion.


Und was ist mit dem Gas?


Eine der Hauptaufgaben, die wir uns stellen, ist es, Gas für einfache Operationen loszuwerden. Dies bedeutet nicht, dass keine Provision anfällt. Es wird benötigt, damit Bergleute ein Interesse daran haben, Skripte auszuführen. Wir näherten uns dem Problem von der praktischen Seite und beschlossen, Leistungstests durchzuführen und die Geschwindigkeit verschiedener Vorgänge zu berechnen. Hierzu wurden Benchmarks mit JMH entwickelt. Ergebnisse können hier gesehen werden . Die daraus resultierenden Einschränkungen sind:


  1. Das Skript sollte schneller als 20 Signaturüberprüfungsvorgänge ausgeführt werden. Dies bedeutet, dass die Überprüfung für ein Smart-Konto nicht mehr als 20-mal langsamer ist als für ein reguläres Konto. Die Größe des Skripts sollte 8 KB nicht überschreiten.
  2. Damit Bergleute intelligente Verträge erfüllen können, legen wir eine zusätzliche Mindestprovision für intelligente Konten in Höhe von 0,004 WAVES fest. Die Mindestprovision im Waves-Netzwerk für eine Transaktion beträgt 0,001 WAVES, bei einem Smart Account 0,005 WAVES.

Sprache für intelligente Verträge


Eine der schwierigsten Aufgaben war die Schaffung einer eigenen Sprache für intelligente Verträge. Es scheint, als würde man mit einer Kanone auf Spatzen schießen, wenn man eine vorhandene Turing-vollständige Sprache verwendet und sich an unsere Aufgaben anpasst (trimmt). Außerdem ist es äußerst riskant , abhängig von der Codebasis eines anderen in einem Blockchain-Projekt.


Versuchen wir uns vorzustellen, was die ideale Sprache für intelligente Verträge sein sollte. Meiner Meinung nach sollte jede Programmiersprache das Schreiben von "korrektem" und sicherem Code erzwingen, d. H. Idealerweise sollte es einen richtigen Weg geben. Ja, wenn Sie möchten, können Sie vollständig unlesbaren und nicht unterstützten Code in jeder Sprache schreiben. Dies sollte jedoch schwieriger sein als das korrekte Schreiben (Hallo PHP und JavaScript). Gleichzeitig sollte die Sprache für die Entwicklung geeignet sein. Da die Sprache auf allen Knoten des Netzwerks ausgeführt wird, muss sie so effizient wie möglich sein. Eine verzögerte Ausführung kann eine Menge Ressourcen sparen. Ich hätte auch gerne ein leistungsfähiges Typensystem in der Sprache, vorzugsweise algebraisch, weil es hilft, den Vertrag so klar wie möglich zu beschreiben und dem Traum von "Code is law" näher zu kommen. Wenn wir unsere Anforderungen etwas weiter formalisieren, erhalten wir die folgenden Sprachparameter:


  1. Seien Sie streng und statisch typisiert. Durch starkes Tippen werden viele potenzielle Programmiererfehler automatisch beseitigt.
  2. Haben Sie ein leistungsstarkes Typsystem, das es schwieriger macht, sich in den Fuß zu schießen.
  3. Seien Sie faul, damit Sie keine wertvollen Prozessorzyklen verschwenden.
  4. Verfügen Sie in der Standardbibliothek über bestimmte Funktionen für die Arbeit mit Blockchain, z. B. Hashes. Gleichzeitig sollte die Standard-Sprachbibliothek nicht überlastet werden, da es immer einen richtigen Weg geben sollte.
  5. Haben Sie keine Ausnahmen in der Laufzeit.

In unserer RIDE-Sprache haben wir versucht, diese wichtigen Funktionen zu berücksichtigen. Da wir viel auf Scala entwickeln und die funktionale Programmierung mögen, ähnelt die Sprache in mancher Hinsicht Scala und F #.


Die größten Probleme bei der Implementierung in der Praxis traten bei der letzten Anforderung auf, denn wenn Sie keine Ausnahmen in der Sprache haben, muss der Additionsvorgang beispielsweise eine Option zurückgeben , die auf Überlauf überprüft werden muss, was für Entwickler definitiv unpraktisch ist. Ausnahmen waren ein Kompromiss, aber ohne die Fähigkeit, sie abzufangen - wenn es eine Ausnahme gab, war die Transaktion ungültig. Ein weiteres Problem bestand darin, alle Datenmodelle, die wir in der Blockchain haben, in die Sprache zu übertragen. Ich habe bereits beschrieben, dass es in Waves 13 verschiedene Arten von Transaktionen gibt, die in der Sprache unterstützt werden müssen und Zugriff auf alle ihre Felder erhalten müssen.


Ausführliche Informationen zu verfügbaren Vorgängen und Datentypen in RIDE finden Sie auf der Sprachbeschreibungsseite . Unter den interessanten Merkmalen der Sprache können wir auch die Tatsache hervorheben, dass die Sprache ausdrucksbasiert ist, dh alles ist Ausdruck, sowie das Vorhandensein eines Mustervergleichs, mit dem Sie die Bedingungen für verschiedene Arten von Transaktionen bequem beschreiben können:


 match tx { case t:TransferTransaction => t.recepient case t:MassTransferTransaction => t.transfers case _ => throw } 

Wenn Sie wissen möchten, wie die Arbeit mit RIDE-Code funktioniert, lesen Sie das Whitepaper, in dem alle Phasen der Vertragsarbeit beschrieben sind: Parsen, Kompilieren, Deserialisieren, Berechnen der Skriptkomplexität und -ausführung. Die ersten beiden Phasen - Parsen und Kompilieren - werden außerhalb der Kette ausgeführt. Nur der in base64 kompilierte Vertrag wird in die Blockchain aufgenommen. Deserialisierung, Komplexitätsberechnung und Ausführung werden in der Kette und mehrmals in verschiedenen Phasen durchgeführt:


  1. Wenn Sie eine Transaktion empfangen und zu UTX hinzufügen, kann es vorkommen, dass die Transaktion vom Blockchain-Knoten akzeptiert wird, z. B. über die REST-API, aber niemals in den Block gelangt.
  2. Wenn ein Block gebildet wird, überprüft der Mining-Knoten Transaktionen und das Skript ist erforderlich.
  3. Nach Empfang eines Blocks bei Nicht-Mining-Knoten und Validierung der darin enthaltenen Transaktionen.

Jede Optimierung bei der Arbeit mit Verträgen wird wertvoll, da sie auf vielen Netzwerkknoten mehrmals durchgeführt wird. Jetzt laufen Waves-Knoten bei DigitalOcean für 15 US-Dollar leise auf virtuellen Maschinen, obwohl die Arbeitslast nach der Veröffentlichung von Smart Accounts gestiegen ist.


Was ist das Ergebnis?


Nun wollen wir sehen, was wir bei Waves bekommen haben. Wir werden unseren ersten eigenen Vertrag schreiben, es sei ein Standard-Multisig-2-aus-3-Vertrag. Um einen Vertrag zu schreiben, können Sie die Online-IDE verwenden (Abstimmung auf die Sprache - ein Thema für einen separaten Artikel). Erstellen Sie einen neuen leeren Vertrag (Neu → Leerer Vertrag).


Zunächst werden wir die öffentlichen Schlüssel von Alice, Bob und Cooper bekannt geben, die das Konto kontrollieren werden. Sie benötigen 2 ihrer 3 Unterschriften:


 let alicePubKey = base58'B1Yz7fH1bJ2gVDjyJnuyKNTdMFARkKEpV' let bobPubKey = base58'7hghYeWtiekfebgAcuCg9ai2NXbRreNzc' let cooperPubKey = base58'BVqYXrapgJP9atQccdBPAgJPwHDKkh6A8' 

In der Dokumentation wird die Funktion sigVerify beschrieben, mit der Sie die Transaktionssignatur überprüfen können:



Die Argumente für die Funktion sind der Hauptteil der Transaktion, die verifizierte Signatur und der öffentliche Schlüssel. Im globalen Bereich ist ein tx Objekt im Vertrag verfügbar, in dem Transaktionsinformationen gespeichert sind. Dieses Objekt verfügt über ein Feld tx.bodyBytes , das die Bytes der gesendeten Transaktion enthält. Es gibt auch eine Reihe von tx.proofs , in denen Signaturen tx.proofs , die bis zu 8 tx.proofs können. Es ist erwähnenswert, dass Sie nicht nur Signaturen an tx.proofs senden tx.proofs , sondern auch alle anderen Informationen, die vom Vertrag verwendet werden können.


Mit 3 einfachen Zeilen können wir sicherstellen, dass alle Signaturen in der richtigen Reihenfolge angezeigt werden:


 let aliceSigned = if(sigVerify(tx.bodyBytes, tx.proofs[0], alicePubKey )) then 1 else 0 let bobSigned = if(sigVerify(tx.bodyBytes, tx.proofs[1], bobPubKey )) then 1 else 0 let cooperSigned = if(sigVerify(tx.bodyBytes, tx.proofs[2], cooperPubKey )) then 1 else 0 

Nun, der letzte Schritt besteht darin, zu überprüfen, ob mindestens 2 Unterschriften eingereicht wurden.


 aliceSigned + bobSigned + cooperSigned >= 2 

Der gesamte 2-aus-3-Vertrag mit mehreren Unterschriften sieht folgendermaßen aus:


 #    let alicePubKey = base58'B1Yz7fH1bJ2gVDjyJnuyKNTdMFARkKEpV' let bobPubKey = base58'7hghYeWtiekfebgAcuCg9ai2NXbRreNzc' let cooperPubKey = base58'BVqYXrapgJP9atQccdBPAgJPwHDKkh6A8' #       let aliceSigned = if(sigVerify(tx.bodyBytes, tx.proofs[0], alicePubKey )) then 1 else 0 let bobSigned = if(sigVerify(tx.bodyBytes, tx.proofs[1], bobPubKey )) then 1 else 0 let cooperSigned = if(sigVerify(tx.bodyBytes, tx.proofs[2], cooperPubKey )) then 1 else 0 # ,      2   aliceSigned + bobSigned + cooperSigned >= 2 

Bitte beachten Sie: Der Code enthält keine Schlüsselwörter wie return , da die zuletzt ausgeführte Zeile als Ergebnis des Skripts betrachtet wird und daher immer true oder false


Im Vergleich dazu sieht der gemeinsame Vertrag mit mehreren Unterschriften von Ethereum viel komplizierter aus . Selbst relativ einfache Variationen sehen so aus:


  pragma solidity ^0.4.22; contract SimpleMultiSig { uint public nonce; // (only) mutable state uint public threshold; // immutable state mapping (address => bool) isOwner; // immutable state address[] public ownersArr; // immutable state // Note that owners_ must be strictly increasing, in order to prevent duplicates constructor(uint threshold_, address[] owners_) public { require(owners_.length <= 10 && threshold_ <= owners_.length && threshold_ >= 0); address lastAdd = address(0); for (uint i = 0; i < owners_.length; i++) { require(owners_[i] > lastAdd); isOwner[owners_[i]] = true; lastAdd = owners_[i]; } ownersArr = owners_; threshold = threshold_; } // Note that address recovered from signatures must be strictly increasing, in order to prevent duplicates function execute(uint8[] sigV, bytes32[] sigR, bytes32[] sigS, address destination, uint value, bytes data) public { require(sigR.length == threshold); require(sigR.length == sigS.length && sigR.length == sigV.length); // Follows ERC191 signature scheme: https://github.com/ethereum/EIPs/issues/191 bytes32 txHash = keccak256(byte(0x19), byte(0), this, destination, value, data, nonce); address lastAdd = address(0); // cannot have address(0) as an owner for (uint i = 0; i < threshold; i++) { address recovered = ecrecover(txHash, sigV[i], sigR[i], sigS[i]); require(recovered > lastAdd && isOwner[recovered]); lastAdd = recovered; } // If we make it here all signatures are accounted for. // The address.call() syntax is no longer recommended, see: // https://github.com/ethereum/solidity/issues/2884 nonce = nonce + 1; bool success = false; assembly { success := call(gas, destination, value, add(data, 0x20), mload(data), 0, 0) } require(success); } function () payable public {} } 

Die IDE verfügt über eine integrierte Konsole, mit der Sie einen Vertrag sofort kompilieren, bereitstellen, Transaktionen erstellen und das Ergebnis der Ausführung anzeigen können. Wenn Sie ernsthaft mit Verträgen arbeiten möchten, empfehlen wir Ihnen, sich die Bibliotheken für verschiedene Sprachen und das Plugin für Visual Studio Code anzusehen .


Wenn Ihre Hände jucken, finden Sie am Ende des Artikels die wichtigsten Links, mit denen Sie den Tauchgang beginnen können.


Das System ist leistungsfähiger als die Sprache


Die Waves-Blockchain verfügt über spezielle Datentypen zum Speichern von Daten - Datentransaktionen . Sie arbeiten als Schlüsselwertspeicher, der einem Konto zugeordnet ist, dh in gewissem Sinne ist dies der Status des Kontos.



Das Transaktionsdatum kann Zeichenfolgen, Zahlen, Boolesche Werte und Bytearrays mit bis zu 32 KB pro Schlüssel enthalten. Ein Beispiel für die Arbeit mit Datentransaktionen, mit dem Sie eine Transaktion nur senden können, wenn der Schlüsselwertspeicher des Kontos bereits die Nummer 42 auf dem Schlüsselschlüssel enthält:


 let keyName = "key" match (tx) { case tx:DataTransaction => let x = extract(getInteger(tx.sender, keyName)) x == 42 case _ => false } 

Dank der Datentransaktion werden intelligente Konten zu einem äußerst leistungsstarken Tool, mit dem Sie mit Orakeln arbeiten, den Status verwalten und das Verhalten bequem beschreiben können.


Dieser Artikel beschreibt, wie Sie NFT (Non-fungible Token) mithilfe von Datentransaktionen und einem intelligenten Vertrag implementieren können, der den Status kontrolliert. Infolgedessen enthält der Kontostil Einträge des Formulars:


 +------------+-----------------------------------------------+ | Token Name | Owner Publc Key | +------------+-----------------------------------------------+ | "Token #1" | "6iQaHazE9NVAJfAjMpHifDXMfr1euWcy8fmW6rNcdhr" | | "Token #2" | "3tNLxyJnyxLzDkMkqiZmUjRqXe1UuwFeSyQ14GRYnGL" | | "Token #3" | "3wH7rENpbS78uohErXHq77yKzQwRyKBYhzCR9nKU17q" | | "Token #4" | "6iQaHazE9NVAJfAjMpHifDXMfr1euWcy8fmW6rNcdhr" | | "Token #5" | "6iQaHazE9NVAJfAjMpHifDXMfr1euWcy8fmW6rNcdhr" | +------------+-----------------------------------------------+ 

Der NFT-Vertrag selbst sieht sehr einfach aus:


 match tx { case dt: DataTransaction => let oldOwner = extract(getString(dt.sender, dt.data[0].key)) let newOwner = getBinary(dt.data, 0) size(dt.data) == 1 && sigVerify(dt.bodyBytes, dt.proofs[0], fromBase58String(oldOwner)) case _ => false } 

Was kommt als nächstes?


Die Weiterentwicklung der intelligenten Waves-Verträge sind Ride4DApps , mit denen Verträge für andere Konten aufgerufen werden können, und eine Turing-vollständige Sprache (oder ein System), mit der Sie alle Arten von Aufgaben lösen, andere Aufgaben auslösen usw. können.


Eine weitere interessante Richtung für die Entwicklung intelligenter Verträge im Waves-Ökosystem sind intelligente Vermögenswerte, die nach einem ähnlichen Prinzip arbeiten - unvollständige Turing-Verträge, die sich auf das Token beziehen. Der Vertrag regelt die Bedingungen, unter denen Token-Transaktionen abgeschlossen werden können. Mit ihrer Hilfe wird es beispielsweise möglich sein, Token auf eine bestimmte Blockchain-Höhe einzufrieren oder den Handel mit P2P-Token zu verbieten. Weitere Informationen zu Smart Assets finden Sie im Blog .


Nun, am Ende werde ich noch einmal eine Liste geben, was notwendig ist, um mit intelligenten Verträgen im Waves-Netzwerk zu arbeiten.


  1. Die Dokumentation
  2. IDE mit Konsole
  3. Weißbuch für die Neugierigsten

Source: https://habr.com/ru/post/de440702/


All Articles