Przejdź do głównej treści

Komponenty serwerowe i agenci dla aplikacji web3

agent
serwer
offchain
dapps
Początkujący
Ori Pomerantz
15 lipca 2024
8 minut czytania

Wprowadzenie

W większości przypadków zdecentralizowana aplikacja (dapp) używa serwera do dystrybucji oprogramowania, ale cała rzeczywista interakcja odbywa się między klientem (zazwyczaj przeglądarką internetową) a blockchainem.

Normal interaction between web server, client, and blockchain

Istnieją jednak przypadki, w których aplikacja zyskałaby na posiadaniu niezależnie działającego komponentu serwerowego. Taki serwer byłby w stanie reagować na zdarzenia oraz na żądania pochodzące z innych źródeł, takich jak API, poprzez wysyłanie transakcji.

The interaction with the addition of a server

Istnieje kilka możliwych zadań, które taki serwer mógłby realizować.

  • Przechowywanie tajnego stanu. W grach często przydatne jest, aby nie wszystkie informacje znane grze były dostępne dla graczy. Jednak na blockchainie nie ma tajemnic, każda informacja znajdująca się na blockchainie jest łatwa do odczytania dla każdego. Dlatego też, jeśli część stanu gry ma pozostać tajna, musi być przechowywana gdzie indziej (a efekty tego stanu mogą być weryfikowane za pomocą dowodów z wiedzą zerową).

  • Scentralizowana wyrocznia. Jeśli stawki są wystarczająco niskie, zewnętrzny serwer, który odczytuje pewne informacje z internetu, a następnie publikuje je w łańcuchu, może być wystarczająco dobry, aby pełnić rolę wyroczni.

  • Agent. Nic nie dzieje się na blockchainie bez transakcji, która to aktywuje. Serwer może działać w imieniu użytkownika, wykonując działania takie jak arbitraż, gdy nadarzy się ku temu okazja.

Przykładowy program

Przykładowy serwer możesz zobaczyć na GitHubie (opens in a new tab). Serwer ten nasłuchuje zdarzeń pochodzących z tego kontraktu (opens in a new tab), zmodyfikowanej wersji Greeter z Hardhata. Kiedy powitanie zostanie zmienione, serwer zmienia je z powrotem.

Aby go uruchomić:

  1. Sklonuj repozytorium.

    git clone https://github.com/qbzzt/20240715-server-component.git
    cd 20240715-server-component
    
  2. Zainstaluj niezbędne pakiety. Jeśli jeszcze go nie masz, najpierw zainstaluj Node.js (opens in a new tab).

    npm install
    
  3. Edytuj plik .env, aby podać klucz prywatny konta, które posiada ETH w sieci testowej Holesky. Jeśli nie masz ETH w sieci Holesky, możesz skorzystać z tego kranika (opens in a new tab).

    PRIVATE_KEY=0x <private key goes here>
    
  4. Uruchom serwer.

    npm start
    
  5. Przejdź do eksploratora bloków (opens in a new tab) i używając innego adresu niż ten, do którego masz klucz prywatny, zmodyfikuj powitanie. Zobaczysz, że powitanie zostanie automatycznie zmienione z powrotem.

Jak to działa?

Najprostszym sposobem na zrozumienie, jak napisać komponent serwerowy, jest przeanalizowanie przykładowego kodu linijka po linijce.

src/app.ts

Zdecydowana większość programu znajduje się w pliku src/app.ts (opens in a new tab).

Tworzenie wymaganych obiektów
import {
  createPublicClient,
  createWalletClient,
  getContract,
  http,
  Address,
} from "viem"

Są to potrzebne nam encje Viem (opens in a new tab), funkcje oraz typ Address (opens in a new tab). Ten serwer jest napisany w języku TypeScript (opens in a new tab), który jest rozszerzeniem języka JavaScript, wprowadzającym silne typowanie (opens in a new tab).

import { privateKeyToAccount } from "viem/accounts"

Ta funkcja (opens in a new tab) pozwala nam wygenerować informacje o portfelu, w tym adres, odpowiadające kluczowi prywatnemu.

import { holesky } from "viem/chains"

Aby użyć blockchaina w Viem, musisz zaimportować jego definicję. W tym przypadku chcemy połączyć się z testowym blockchainem Holesky (opens in a new tab).

// W ten sposób dodajemy definicje z .env do process.env.
import * as dotenv from "dotenv"
dotenv.config()

W ten sposób wczytujemy plik .env do środowiska. Potrzebujemy go do klucza prywatnego (zobacz poniżej).

Aby użyć kontraktu, potrzebujemy jego adresu oraz . Podajemy tutaj oba te elementy.

W języku JavaScript (a tym samym w TypeScript) nie można przypisać nowej wartości do stałej, ale można modyfikować obiekt, który jest w niej przechowywany. Używając sufiksu as const, mówimy TypeScriptowi, że sama lista jest stała i nie może zostać zmieniona.

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

Utwórz klienta publicznego (opens in a new tab) Viem. Klienci publiczni nie mają dołączonego klucza prywatnego, a zatem nie mogą wysyłać transakcji. Mogą wywoływać funkcje view (opens in a new tab), odczytywać salda kont itp.

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

Zmienne środowiskowe są dostępne w process.env (opens in a new tab). Jednak TypeScript jest silnie typowany. Zmienna środowiskowa może być dowolnym ciągiem znaków lub być pusta, więc typem zmiennej środowiskowej jest string | undefined. Z kolei klucz jest zdefiniowany w Viem jako 0x${string} (0x z następującym po nim ciągiem znaków). W tym miejscu mówimy TypeScriptowi, że zmienna środowiskowa PRIVATE_KEY będzie tego typu. Jeśli tak nie będzie, otrzymamy błąd w czasie wykonywania.

Funkcja privateKeyToAccount (opens in a new tab) używa następnie tego klucza prywatnego do utworzenia pełnego obiektu konta.

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

Następnie używamy obiektu konta do utworzenia klienta portfela (opens in a new tab). Ten klient posiada klucz prywatny i adres, więc może być używany do wysyłania transakcji.

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

Teraz, gdy mamy już wszystkie wymagane elementy, możemy w końcu utworzyć instancję kontraktu (opens in a new tab). Użyjemy tej instancji kontraktu do komunikacji z kontraktem onchain.

Odczyt z blockchaina
console.log(`Current greeting:`, await greeter.read.greet())

Funkcje kontraktu, które służą tylko do odczytu (view (opens in a new tab) i pure (opens in a new tab)), są dostępne pod read. W tym przypadku używamy go, aby uzyskać dostęp do funkcji greet (opens in a new tab), która zwraca powitanie.

JavaScript jest jednowątkowy, więc kiedy uruchamiamy długotrwały proces, musimy określić, że robimy to asynchronicznie (opens in a new tab). Wywołanie blockchaina, nawet w przypadku operacji tylko do odczytu, wymaga komunikacji w obie strony między komputerem a węzłem blockchaina. Z tego powodu określamy tutaj, że kod musi poczekać (await) na wynik.

Jeśli interesuje Cię, jak to działa, możesz przeczytać o tym tutaj (opens in a new tab), ale w praktyce wszystko, co musisz wiedzieć, to to, że używasz await dla wyników, jeśli rozpoczynasz operację, która zajmuje dużo czasu, a każda funkcja, która to robi, musi być zadeklarowana jako async.

Wysyłanie transakcji
const setGreeting = async (greeting: string): Promise<any> => {

Jest to funkcja, którą wywołujesz, aby wysłać transakcję zmieniającą powitanie. Ponieważ jest to długa operacja, funkcja jest zadeklarowana jako async. Ze względu na wewnętrzną implementację, każda funkcja async musi zwracać obiekt Promise. W tym przypadku Promise<any> oznacza, że nie określamy, co dokładnie zostanie zwrócone w Promise.

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

Pole write instancji kontraktu zawiera wszystkie funkcje, które zapisują do stanu blockchaina (te, które wymagają wysłania transakcji), takie jak setGreeting (opens in a new tab). Parametry, jeśli występują, są podawane jako lista, a funkcja zwraca hash transakcji.

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

    return txHash
}

Zgłoś hash transakcji (jako część adresu URL do eksploratora bloków, aby go wyświetlić) i zwróć go.

Reagowanie na zdarzenia
greeter.watchEvent.SetGreeting({

Funkcja watchEvent (opens in a new tab) pozwala określić, że funkcja ma zostać uruchomiona po wyemitowaniu zdarzenia. Jeśli interesuje Cię tylko jeden typ zdarzenia (w tym przypadku SetGreeting), możesz użyć tej składni, aby ograniczyć się do tego typu zdarzenia.

    onLogs: logs => {

Funkcja onLogs jest wywoływana, gdy pojawiają się wpisy logów. W Ethereum terminy „log” i „zdarzenie” są zazwyczaj używane zamiennie.

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

Może wystąpić wiele zdarzeń, ale dla uproszczenia interesuje nas tylko pierwsze z nich. logs[0].args to argumenty zdarzenia, w tym przypadku sender i greeting.

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

Jeśli nadawcą nie jest ten serwer, użyj setGreeting, aby zmienić powitanie.

package.json

Ten plik (opens in a new tab) kontroluje konfigurację Node.js (opens in a new tab). Ten artykuł wyjaśnia tylko najważniejsze definicje.

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

Ta definicja określa, który plik JavaScript ma zostać uruchomiony.

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

Skrypty to różne akcje aplikacji. W tym przypadku jedynym, jaki mamy, jest start, który kompiluje, a następnie uruchamia serwer. Polecenie tsc jest częścią pakietu typescript i kompiluje TypeScript do JavaScriptu. Jeśli chcesz uruchomić je ręcznie, znajduje się ono w node_modules/.bin. Drugie polecenie uruchamia serwer.

  "type": "module",

Istnieje wiele typów aplikacji węzła JavaScript. Typ module pozwala nam na użycie await w kodzie najwyższego poziomu, co jest ważne, gdy wykonujesz powolne (i tym samym asynchroniczne) operacje.

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

Są to pakiety, które są wymagane tylko do programowania. Tutaj potrzebujemy typescript, a ponieważ używamy go z Node.js, pobieramy również typy dla zmiennych i obiektów węzła, takich jak process. Notacja ^<version> (opens in a new tab) oznacza tę wersję lub wyższą wersję, która nie wprowadza zmian psujących kompatybilność. Zobacz tutaj (opens in a new tab), aby uzyskać więcej informacji na temat znaczenia numerów wersji.

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

Są to pakiety, które są wymagane w czasie wykonywania, podczas uruchamiania dist/app.js.

Podsumowanie

Scentralizowany serwer, który tutaj stworzyliśmy, spełnia swoje zadanie, którym jest działanie jako agent dla użytkownika. Każdy inny, kto chce, aby dapp nadal funkcjonował i jest skłonny wydać gaz, może uruchomić nową instancję serwera z własnym adresem.

Działa to jednak tylko wtedy, gdy działania scentralizowanego serwera można łatwo zweryfikować. Jeśli scentralizowany serwer posiada jakiekolwiek tajne informacje o stanie lub wykonuje trudne obliczenia, jest to scentralizowany podmiot, któremu musisz zaufać, aby korzystać z aplikacji, a tego właśnie blockchainy starają się unikać. W przyszłym artykule planuję pokazać, jak używać dowodów z wiedzą zerową, aby obejść ten problem.

Zobacz tutaj więcej moich prac (opens in a new tab).