Zum Hauptinhalt springen

Serverkomponenten und Agenten für Web3-Apps

Agent
Server
Offchain
Dapps
Anfänger
Ori Pomerantz
15. Juli 2024
9 Minuten Lesezeit

Einführung

In den meisten Fällen verwendet eine dezentrale App einen Server, um die Software zu verteilen, aber die gesamte eigentliche Interaktion findet zwischen dem Client (typischerweise dem Webbrowser) und der Blockchain statt.

Normal interaction between web server, client, and blockchain

Es gibt jedoch einige Fälle, in denen eine Anwendung von einer unabhängig laufenden Serverkomponente profitieren würde. Ein solcher Server wäre in der Lage, auf Ereignisse und auf Anfragen aus anderen Quellen, wie z. B. einer API, durch das Ausgeben von Transaktionen zu reagieren.

The interaction with the addition of a server

Es gibt mehrere mögliche Aufgaben, die ein solcher Server erfüllen könnte.

  • Halter eines geheimen Zustands. Beim Gaming ist es oft nützlich, den Spielern nicht alle Informationen zur Verfügung zu stellen, die das Spiel kennt. Jedoch gibt es keine Geheimnisse auf der Blockchain; jede Information, die sich in der Blockchain befindet, ist für jeden leicht herauszufinden. Wenn also ein Teil des Spielzustands geheim gehalten werden soll, muss er woanders gespeichert werden (und die Auswirkungen dieses Zustands können möglicherweise mithilfe von Zero-Knowledge-Beweisen verifiziert werden).

  • Zentralisiertes Orakel. Wenn die Einsätze niedrig genug sind, kann ein externer Server, der einige Informationen online liest und sie dann auf der Chain veröffentlicht, gut genug sein, um als Orakel verwendet zu werden.

  • Agent. Nichts passiert auf der Blockchain ohne eine Transaktion, die es aktiviert. Ein Server kann im Namen eines Benutzers handeln, um Aktionen wie Arbitrage durchzuführen, wenn sich die Gelegenheit dazu bietet.

Beispielprogramm

Du kannst dir einen Beispielserver auf GitHub ansehen (opens in a new tab). Dieser Server lauscht auf Ereignisse, die von diesem Vertrag (opens in a new tab) kommen, einer modifizierten Version von Hardhats Greeter. Wenn die Begrüßung geändert wird, ändert er sie wieder zurück.

Um ihn auszuführen:

  1. Klone das Repository.

    git clone https://github.com/qbzzt/20240715-server-component.git
    cd 20240715-server-component
    
  2. Installiere die notwendigen Pakete. Falls du es noch nicht hast, installiere zuerst Node (opens in a new tab).

    npm install
    
  3. Bearbeite .env, um den privaten Schlüssel eines Kontos anzugeben, das ETH im Holesky-Testnetz hat. Wenn du keine ETH auf Holesky hast, kannst du dieses Faucet verwenden (opens in a new tab).

    PRIVATE_KEY=0x <private key goes here>
    
  4. Starte den Server.

    npm start
    
  5. Gehe zu einem Block-Explorer (opens in a new tab) und ändere die Begrüßung mit einer anderen Adresse als der, die den privaten Schlüssel hat. Du wirst sehen, dass die Begrüßung automatisch wieder zurückgeändert wird.

Wie funktioniert das?

Der einfachste Weg zu verstehen, wie man eine Serverkomponente schreibt, ist, das Beispiel Zeile für Zeile durchzugehen.

src/app.ts

Der weitaus größte Teil des Programms ist in src/app.ts (opens in a new tab) enthalten.

Erstellen der vorausgesetzten Objekte
import {
  createPublicClient,
  createWalletClient,
  getContract,
  http,
  Address,
} from "viem"

Dies sind die Viem (opens in a new tab)-Entitäten, die wir benötigen: Funktionen und der Typ Address (opens in a new tab). Dieser Server ist in TypeScript (opens in a new tab) geschrieben, einer Erweiterung von JavaScript, die es streng typisiert (opens in a new tab) macht.

import { privateKeyToAccount } from "viem/accounts"

Diese Funktion (opens in a new tab) ermöglicht es uns, die Wallet-Informationen, einschließlich der Adresse, passend zu einem privaten Schlüssel zu generieren.

import { holesky } from "viem/chains"

Um eine Blockchain in Viem zu verwenden, musst du ihre Definition importieren. In diesem Fall möchten wir uns mit der Holesky (opens in a new tab)-Test-Blockchain verbinden.

// So fügen wir die Definitionen in .env zu process.env hinzu.
import * as dotenv from "dotenv"
dotenv.config()

So lesen wir .env in die Umgebung ein. Wir benötigen es für den privaten Schlüssel (siehe später).

Um einen Vertrag zu verwenden, benötigen wir seine Adresse und die dafür. Wir stellen hier beides zur Verfügung.

In JavaScript (und damit auch in TypeScript) kannst du einer Konstante keinen neuen Wert zuweisen, aber du kannst das darin gespeicherte Objekt modifizieren. Durch die Verwendung des Suffixes as const teilen wir TypeScript mit, dass die Liste selbst konstant ist und nicht geändert werden darf.

const publicClient = createPublicClient({
  chain: holesky,
  transport: http(),
})

Erstelle einen öffentlichen Client (opens in a new tab) in Viem. Öffentliche Clients haben keinen angehängten privaten Schlüssel und können daher keine Transaktionen senden. Sie können view-Funktionen (opens in a new tab) aufrufen, Kontostände lesen usw.

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)

Die Umgebungsvariablen sind in process.env (opens in a new tab) verfügbar. TypeScript ist jedoch streng typisiert. Eine Umgebungsvariable kann ein beliebiger String oder leer sein, daher ist der Typ für eine Umgebungsvariable string | undefined. Ein Schlüssel ist in Viem jedoch als 0x${string} definiert (0x gefolgt von einem String). Hier teilen wir TypeScript mit, dass die Umgebungsvariable PRIVATE_KEY von diesem Typ sein wird. Wenn dies nicht der Fall ist, erhalten wir einen Laufzeitfehler.

Die Funktion privateKeyToAccount (opens in a new tab) verwendet dann diesen privaten Schlüssel, um ein vollständiges Konto-Objekt zu erstellen.

const walletClient = createWalletClient({
  account,
  chain: holesky,
  transport: http(),
})

Als Nächstes verwenden wir das Konto-Objekt, um einen Wallet-Client (opens in a new tab) zu erstellen. Dieser Client verfügt über einen privaten Schlüssel und eine Adresse, sodass er zum Senden von Transaktionen verwendet werden kann.

const greeter = getContract({
  address: greeterAddress,
  abi: greeterABI,
  client: { public: publicClient, wallet: walletClient },
})

Da wir nun alle Voraussetzungen haben, können wir endlich eine Vertragsinstanz (opens in a new tab) erstellen. Wir werden diese Vertragsinstanz verwenden, um mit dem Onchain-Vertrag zu kommunizieren.

Lesen von der Blockchain
console.log(`Current greeting:`, await greeter.read.greet())

Die Vertragsfunktionen, die nur lesend sind (view (opens in a new tab) und pure (opens in a new tab)), sind unter read verfügbar. In diesem Fall verwenden wir es, um auf die Funktion greet (opens in a new tab) zuzugreifen, die die Begrüßung zurückgibt.

JavaScript ist Single-Threaded. Wenn wir also einen lang laufenden Prozess anstoßen, müssen wir angeben, dass wir dies asynchron tun (opens in a new tab). Der Aufruf der Blockchain, selbst für eine reine Leseoperation, erfordert einen Roundtrip zwischen dem Computer und einem Blockchain-Knoten. Das ist der Grund, warum wir hier angeben, dass der Code auf das Ergebnis await (warten) muss.

Wenn du dich dafür interessierst, wie das funktioniert, kannst du hier darüber lesen (opens in a new tab). In der Praxis musst du jedoch nur wissen, dass du auf die Ergebnisse await musst, wenn du eine Operation startest, die lange dauert, und dass jede Funktion, die dies tut, als async deklariert werden muss.

Ausgeben von Transaktionen
const setGreeting = async (greeting: string): Promise<any> => {

Dies ist die Funktion, die du aufrufst, um eine Transaktion auszugeben, die die Begrüßung ändert. Da dies eine lange Operation ist, wird die Funktion als async deklariert. Aufgrund der internen Implementierung muss jede async-Funktion ein Promise-Objekt zurückgeben. In diesem Fall bedeutet Promise<any>, dass wir nicht genau spezifizieren, was im Promise zurückgegeben wird.

const txHash = await greeter.write.setGreeting([greeting])

Das Feld write der Vertragsinstanz enthält alle Funktionen, die in den Blockchain-Zustand schreiben (diejenigen, die das Senden einer Transaktion erfordern), wie z. B. setGreeting (opens in a new tab). Die Parameter, falls vorhanden, werden als Liste bereitgestellt, und die Funktion gibt den Hash der Transaktion zurück.

    console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)

    return txHash
}

Melde den Hash der Transaktion (als Teil einer URL zum Block-Explorer, um sie anzusehen) und gib ihn zurück.

Reagieren auf Ereignisse
greeter.watchEvent.SetGreeting({

Die Funktion watchEvent (opens in a new tab) ermöglicht es dir anzugeben, dass eine Funktion ausgeführt werden soll, wenn ein Ereignis ausgelöst wird. Wenn du dich nur für einen Ereignistyp interessierst (in diesem Fall SetGreeting), kannst du diese Syntax verwenden, um dich auf diesen Ereignistyp zu beschränken.

    onLogs: logs => {

Die Funktion onLogs wird aufgerufen, wenn es Log-Einträge gibt. In Ethereum sind „Log“ und „Ereignis“ (Event) normalerweise austauschbar.

console.log(
  `Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`
)

Es könnte mehrere Ereignisse geben, aber der Einfachheit halber kümmern wir uns nur um das erste. logs[0].args sind die Argumente des Ereignisses, in diesem Fall sender und greeting.

        if (logs[0].args.sender != account.address)
            setGreeting(`${account.address} insists on it being Hello!`)
    }
})

Wenn der Absender nicht dieser Server ist, verwende setGreeting, um die Begrüßung zu ändern.

package.json

Diese Datei (opens in a new tab) steuert die Node.js (opens in a new tab)-Konfiguration. Dieser Artikel erklärt nur die wichtigen Definitionen.

{
  "main": "dist/index.js",

Diese Definition gibt an, welche JavaScript-Datei ausgeführt werden soll.

  "scripts": {
    "start": "tsc && node dist/app.js",
  },

Die Skripte sind verschiedene Anwendungsaktionen. In diesem Fall haben wir nur start, das den Server kompiliert und dann ausführt. Der Befehl tsc ist Teil des Pakets typescript und kompiliert TypeScript zu JavaScript. Wenn du ihn manuell ausführen möchtest, befindet er sich in node_modules/.bin. Der zweite Befehl führt den Server aus.

  "type": "module",

Es gibt mehrere Arten von JavaScript-Node-Anwendungen. Der Typ module ermöglicht es uns, await im Top-Level-Code zu haben, was wichtig ist, wenn du langsame (und daher asynchrone) Operationen durchführst.

  "devDependencies": {
    "@types/node": "^20.14.2",
    "typescript": "^5.4.5"
  },

Dies sind Pakete, die nur für die Entwicklung benötigt werden. Hier benötigen wir typescript und da wir es mit Node.js verwenden, holen wir uns auch die Typen für Node-Variablen und -Objekte, wie z. B. process. Die Notation ^<version> (opens in a new tab) bedeutet diese Version oder eine höhere Version, die keine Breaking Changes (inkompatible Änderungen) aufweist. Siehe hier (opens in a new tab) für weitere Informationen über die Bedeutung von Versionsnummern.

  "dependencies": {
    "dotenv": "^16.4.5",
    "viem": "2.14.1"
  }
}

Dies sind Pakete, die zur Laufzeit benötigt werden, wenn dist/app.js ausgeführt wird.

Fazit

Der zentralisierte Server, den wir hier erstellt haben, erfüllt seine Aufgabe, nämlich als Agent für einen Benutzer zu fungieren. Jeder andere, der möchte, dass die Dapp weiterhin funktioniert, und bereit ist, das Gas auszugeben, kann eine neue Instanz des Servers mit seiner eigenen Adresse ausführen.

Dies funktioniert jedoch nur, wenn die Aktionen des zentralisierten Servers leicht verifiziert werden können. Wenn der zentralisierte Server geheime Zustandsinformationen hat oder schwierige Berechnungen durchführt, ist er eine zentralisierte Entität, der du vertrauen musst, um die Anwendung zu nutzen, was genau das ist, was Blockchains zu vermeiden versuchen. In einem zukünftigen Artikel plane ich zu zeigen, wie man Zero-Knowledge-Beweise verwendet, um dieses Problem zu umgehen.

Siehe hier für weitere meiner Arbeiten (opens in a new tab).