Komponenty serwera i agenty dla aplikacji web3
Wprowadzenie
W większości przypadków zdecentralizowana aplikacja wykorzystuje serwer do dystrybucji oprogramowania, ale cała faktyczna interakcja zachodzi między klientem (zazwyczaj przeglądarką internetową) a łańcuchem bloków.
Istnieją jednak przypadki, w których aplikacja odniosłaby korzyść z posiadania niezależnie działającego komponentu serwera. Taki serwer byłby w stanie reagować na zdarzenia i żądania pochodzące z innych źródeł, takich jak API, emitując transakcje.
Istnieje kilka możliwych zadań, które taki serwer mógłby spełniać.
-
Posiadacz tajnego stanu. W grach często przydatne jest, aby nie wszystkie informacje znane grze były dostępne dla graczy. Jednakże w łańcuchu bloków nie ma żadnych tajemnic, każdą informację, która się w nim znajduje, każdy może łatwo poznać. Dlatego też, jeśli część stanu gry ma pozostać tajna, musi być przechowywana gdzie indziej (a ewentualne skutki tego stanu zweryfikowane za pomocą dowodów o wiedzy zerowej).
-
Scentralizowana wyrocznia. Jeśli stawki są wystarczająco niskie, zewnętrzny serwer, który odczytuje pewne informacje online, a następnie publikuje je w łańcuchu, może być wystarczająco dobry, aby użyć go jako wyroczni.
-
Agent. W łańcuchu bloków nic się nie dzieje 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ę okazja.
Przykładowy program
Przykładowy serwer można zobaczyć na GitHubie (opens in a new tab). Ten serwer nasłuchuje zdarzeń pochodzących z tego kontraktu (opens in a new tab), zmodyfikowanej wersji Greeter od Hardhat. Gdy powitanie zostanie zmienione, zmienia je z powrotem.
Aby go uruchomić:
-
Sklonuj repozytorium.
1git clone https://github.com/qbzzt/20240715-server-component.git2cd 20240715-server-component -
Zainstaluj niezbędne pakiety. Jeśli jeszcze go nie masz, najpierw zainstaluj Node (opens in a new tab).
1npm install -
Edytuj plik
.env, aby określić klucz prywatny konta, które posiada ETH w sieci testowej Holesky. Jeśli nie masz ETH na Holesky, możesz użyć tego kraniku (opens in a new tab).1PRIVATE_KEY=0x <tutaj wklej klucz prywatny> -
Uruchom serwer.
1npm start -
Przejdź do eksploratora bloków (opens in a new tab) i używając innego adresu niż ten, który posiada klucz prywatny, zmodyfikuj powitanie. Zobacz, że powitanie jest automatycznie przywracane.
Jak to działa?
Najłatwiejszym sposobem na zrozumienie, jak napisać komponent serwera, jest przeanalizowanie przykładu linijka po linijce.
src/app.ts
Zdecydowana większość programu znajduje się w src/app.ts (opens in a new tab).
Tworzenie wymaganych obiektów
1import {2 createPublicClient,3 createWalletClient,4 getContract,5 http,6 Address,7} from "viem"Są to potrzebne nam encje Viem (opens in a new tab), funkcje i 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, czyniącym go silnie typowanym (opens in a new tab).
1import { privateKeyToAccount } from "viem/accounts"Ta funkcja (opens in a new tab) pozwala nam wygenerować informacje o portfelu, w tym adres, odpowiadające kluczowi prywatnemu.
1import { holesky } from "viem/chains"Aby używać blockchaina w Viem, musisz zaimportować jego definicję. W tym przypadku chcemy połączyć się z testowym łańcuchem bloków Holesky (opens in a new tab).
1// W ten sposób dodajemy definicje z .env do process.env.2import * as dotenv from "dotenv"3dotenv.config()W ten sposób wczytujemy plik .env do środowiska. Potrzebujemy go do klucza prywatnego (zobacz później).
1const greeterAddress : Address = "0xB8f6460Dc30c44401Be26B0d6eD250873d8a50A6"2const greeterABI = [3 {4 "inputs": [5 {6 "internalType": "string",7 "name": "_greeting",8 "type": "string"9 }10 ],11 "stateMutability": "nonpayable",12 "type": "constructor"13 },14 .15 .16 .17 {18 "inputs": [19 {20 "internalType": "string",21 "name": "_greeting",22 "type": "string"23 }24 ],25 "name": "setGreeting",26 "outputs": [],27 "stateMutability": "nonpayable",28 "type": "function"29 }30] as constPokaż wszystkoAby użyć kontraktu, potrzebujemy jego adresu i . Podajemy oba tutaj.
W języku JavaScript (a więc i w TypeScript) nie można przypisać nowej wartości do stałej, ale można zmodyfikować obiekt, który jest w niej przechowywany. Używając sufiksu as const, informujemy TypeScript, że sama lista jest stała i nie może być zmieniana.
1const publicClient = createPublicClient({2 chain: holesky,3 transport: http(),4})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 widoku (opens in a new tab), odczytywać salda kont itp.
1const 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. Jednak klucz jest zdefiniowany w Viem jako 0x${string} (0x po którym następuje ciąg znaków). W tym miejscu informujemy TypeScript, że zmienna środowiskowa PRIVATE_KEY będzie tego typu. Jeśli tak nie będzie, otrzymamy błąd wykonania.
Funkcja privateKeyToAccount (opens in a new tab) następnie używa tego klucza prywatnego do utworzenia pełnego obiektu konta.
1const walletClient = createWalletClient({2 account,3 chain: holesky,4 transport: http(),5})Następnie używamy obiektu konta do utworzenia klienta portfela (opens in a new tab). Ten klient ma klucz prywatny i adres, więc może być używany do wysyłania transakcji.
1const greeter = getContract({2 address: greeterAddress,3 abi: greeterABI,4 client: { public: publicClient, wallet: walletClient },5})Teraz, gdy mamy już wszystkie wymagania wstępne, możemy wreszcie utworzyć instancję kontraktu (opens in a new tab). Będziemy używać tej instancji kontraktu do komunikacji z kontraktem onchain.
Odczytywanie z łańcucha bloków
1console.log(`Current greeting:`, await greeter.read.greet())Funkcje kontraktu, które są tylko do odczytu (view (opens in a new tab) i pure (opens in a new tab)), są dostępne w read. W tym przypadku używamy jej do uzyskania dostępu do funkcji greet (opens in a new tab), która zwraca powitanie.
JavaScript jest jednowątkowy, więc gdy uruchamiamy długo działający proces, musimy określić, że robimy to asynchronicznie (opens in a new tab). Wywołanie łańcucha bloków, nawet w przypadku operacji tylko do odczytu, wymaga komunikacji w obie strony między komputerem a węzłem łańcucha bloków. Dlatego w tym miejscu określamy, że kod musi await (oczekiwać) na wynik.
Jeśli interesuje Cię, jak to działa, możesz przeczytać o tym tutaj (opens in a new tab), ale w praktyce wystarczy wiedzieć, że należy await (oczekiwać) na wyniki, jeśli rozpoczynasz operację, która trwa długo, a każda funkcja, która to robi, musi być zadeklarowana jako async.
Emitowanie transakcji
1const 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.
1const txHash = await greeter.write.setGreeting([greeting])Pole write instancji kontraktu zawiera wszystkie funkcje, które zapisują do stanu łańcucha bloków (te, które wymagają wysłania transakcji), takie jak setGreeting (opens in a new tab). Parametry, jeśli istnieją, są podawane w postaci listy, a funkcja zwraca hasz transakcji.
1 console.log(`Pracuję nad poprawką, zobacz https://eth-holesky.blockscout.com/tx/${txHash}`)23 return txHash4}Zgłoś hasz transakcji (jako część adresu URL do eksploratora bloków, aby go wyświetlić) i zwróć go.
Reagowanie na zdarzenia
1greeter.watchEvent.SetGreeting({Funkcja watchEvent (opens in a new tab) pozwala określić, że funkcja ma być uruchamiana 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.
1 onLogs: logs => {Funkcja onLogs jest wywoływana, gdy pojawiają się wpisy w dzienniku. W Ethereum „log” i „zdarzenie” są zwykle używane zamiennie.
1console.log(2 `Adres ${logs[0].args.sender} zmienił powitanie na ${logs[0].args.greeting}`3)Może być wiele zdarzeń, ale dla uproszczenia interesuje nas tylko pierwsze z nich. logs[0].args to argumenty zdarzenia, w tym przypadku sender i greeting.
1 if (logs[0].args.sender != account.address)2 setGreeting(`${account.address} nalega, aby było to Hello!`)3 }4})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). W tym artykule wyjaśniono tylko ważne definicje.
1{2 "main": "dist/index.js",Ta definicja określa, który plik JavaScript ma być uruchomiony.
1 "scripts": {2 "start": "tsc && node dist/app.js",3 },Skrypty to różne działania 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 JavaScript. Jeśli chcesz uruchomić go ręcznie, znajduje się on w node_modules/.bin. Drugie polecenie uruchamia serwer.
1 "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 wolne (i asynchroniczne) operacje.
1 "devDependencies": {2 "@types/node": "^20.14.2",3 "typescript": "^5.4.5"4 },Są to pakiety, które są wymagane tylko do programowania. W tym miejscu potrzebujemy typescript, a ponieważ używamy go z Node.js, otrzymujemy również typy dla zmiennych i obiektów węzła, takich jak process. Notacja ^<wersja> (opens in a new tab) oznacza tę wersję lub wyższą wersję, która nie zawiera przełomowych zmian. Więcej informacji na temat znaczenia numerów wersji można znaleźć tutaj (opens in a new tab).
1 "dependencies": {2 "dotenv": "^16.4.5",3 "viem": "2.14.1"4 }5}Są to pakiety wymagane w czasie wykonywania, podczas uruchamiania dist/app.js.
Wnioski
Scentralizowany serwer, który tu stworzyliśmy, wykonuje swoje zadanie, którym jest działanie jako agent dla użytkownika. Każdy, kto chce, aby dapka nadal funkcjonowała i jest gotów 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 ma jakiekolwiek informacje o tajnym stanie lub wykonuje trudne obliczenia, jest to scentralizowany podmiot, któremu trzeba zaufać, aby korzystać z aplikacji, a tego właśnie starają się unikać łańcuchy bloków. W przyszłym artykule planuję pokazać, jak używać dowodów o wiedzy zerowej, aby obejść ten problem.
Zobacz więcej mojej pracy tutaj (opens in a new tab).
Strona ostatnio zaktualizowana: 25 lutego 2026