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 (öffnet in einem neuen Tab). Dieser Server lauscht auf Ereignisse, die von diesem Vertrag (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen Tab).

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

    npm start
    
  5. Gehe zu einem Block-Explorer (öffnet in einem neuen 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 (öffnet in einem neuen Tab) enthalten.

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

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

import { privateKeyToAccount } from "viem/accounts"

Diese Funktion (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen Tab) in Viem. Öffentliche Clients haben keinen angehängten privaten Schlüssel und können daher keine Transaktionen senden. Sie können view-Funktionen (öffnet in einem neuen Tab) aufrufen, Kontostände lesen usw.

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

Die Umgebungsvariablen sind in process.env (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen Tab) und pure (öffnet in einem neuen Tab)), sind unter read verfügbar. In diesem Fall verwenden wir es, um auf die Funktion greet (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen 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 (öffnet in einem neuen Tab) steuert die Node.js (öffnet in einem neuen 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> (öffnet in einem neuen Tab) bedeutet diese Version oder eine höhere Version, die keine Breaking Changes (inkompatible Änderungen) aufweist. Siehe hier (öffnet in einem neuen 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 (öffnet in einem neuen Tab).