Vai al contenuto principale

Componenti server e agenti per app web3

agente
server
offchain
dapp
Principiante
Ori Pomerantz
15 luglio 2024
9 minuti di lettura

Introduzione

Nella maggior parte dei casi, un'applicazione decentralizzata (dapp) utilizza un server per distribuire il software, ma tutta l'interazione effettiva avviene tra il client (in genere, il browser web) e la blockchain.

Normal interaction between web server, client, and blockchain

Tuttavia, ci sono alcuni casi in cui un'applicazione trarrebbe vantaggio dall'avere un componente server che viene eseguito in modo indipendente. Un server del genere sarebbe in grado di rispondere agli eventi e alle richieste provenienti da altre fonti, come un'API, emettendo transazioni.

The interaction with the addition of a server

Ci sono diverse possibili attività che un server del genere potrebbe svolgere.

  • Detentore di uno stato segreto. Nel gaming è spesso utile non rendere disponibili ai giocatori tutte le informazioni note al gioco. Tuttavia, non ci sono segreti sulla blockchain, qualsiasi informazione presente nella blockchain è facile da scoprire per chiunque. Pertanto, se parte dello stato del gioco deve essere mantenuta segreta, deve essere archiviata altrove (e possibilmente far verificare gli effetti di tale stato utilizzando prove a conoscenza zero).

  • Oracolo centralizzato. Se la posta in gioco è sufficientemente bassa, un server esterno che legge alcune informazioni online e poi le pubblica sulla catena potrebbe essere sufficiente per essere utilizzato come oracolo.

  • Agente. Non succede nulla sulla blockchain senza una transazione che lo attivi. Un server può agire per conto di un utente per eseguire azioni come l'arbitraggio quando se ne presenta l'opportunità.

Programma di esempio

Puoi vedere un server di esempio su GitHub (si apre in una nuova scheda). Questo server ascolta gli eventi provenienti da questo contratto (si apre in una nuova scheda), una versione modificata del Greeter di Hardhat. Quando il saluto viene modificato, lo ripristina.

Per eseguirlo:

  1. Clona il repository.

    git clone https://github.com/qbzzt/20240715-server-component.git
    cd 20240715-server-component
    
  2. Installa i pacchetti necessari. Se non lo hai già fatto, installa prima Node (si apre in una nuova scheda).

    npm install
    
  3. Modifica .env per specificare la chiave privata di un account che possiede ETH sulla testnet Holesky. Se non hai ETH su Holesky, puoi usare questo faucet (si apre in una nuova scheda).

    PRIVATE_KEY=0x <private key goes here>
    
  4. Avvia il server.

    npm start
    
  5. Vai su un block explorer (si apre in una nuova scheda) e, utilizzando un indirizzo diverso da quello che possiede la chiave privata, modifica il saluto. Vedrai che il saluto viene automaticamente ripristinato.

Come funziona?

Il modo più semplice per capire come scrivere un componente server è esaminare l'esempio riga per riga.

src/app.ts

La stragrande maggioranza del programma è contenuta in src/app.ts (si apre in una nuova scheda).

Creazione degli oggetti prerequisiti
import {
  createPublicClient,
  createWalletClient,
  getContract,
  http,
  Address,
} from "viem"

Queste sono le entità Viem (si apre in una nuova scheda) di cui abbiamo bisogno, le funzioni e il tipo Address (si apre in una nuova scheda). Questo server è scritto in TypeScript (si apre in una nuova scheda), che è un'estensione di JavaScript che lo rende fortemente tipizzato (si apre in una nuova scheda).

import { privateKeyToAccount } from "viem/accounts"

Questa funzione (si apre in una nuova scheda) ci consente di generare le informazioni del portafoglio, incluso l'indirizzo, corrispondenti a una chiave privata.

import { holesky } from "viem/chains"

Per utilizzare una blockchain in Viem è necessario importarne la definizione. In questo caso, vogliamo connetterci alla blockchain di test Holesky (si apre in una nuova scheda).

// Ecco come aggiungiamo le definizioni in .env a process.env.
import * as dotenv from "dotenv"
dotenv.config()

Ecco come leggiamo .env nell'ambiente. Ne abbiamo bisogno per la chiave privata (vedi in seguito).

Per utilizzare un contratto abbiamo bisogno del suo indirizzo e della sua . Li forniamo entrambi qui.

In JavaScript (e quindi in TypeScript) non è possibile assegnare un nuovo valore a una costante, ma puoi modificare l'oggetto in essa memorizzato. Utilizzando il suffisso as const stiamo dicendo a TypeScript che l'elenco stesso è costante e non può essere modificato.

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

Crea un client pubblico (si apre in una nuova scheda) Viem. I client pubblici non hanno una chiave privata associata e pertanto non possono inviare transazioni. Possono chiamare funzioni view (si apre in una nuova scheda), leggere i saldi degli account, ecc.

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

Le variabili d'ambiente sono disponibili in process.env (si apre in una nuova scheda). Tuttavia, TypeScript è fortemente tipizzato. Una variabile d'ambiente può essere una stringa qualsiasi, o vuota, quindi il tipo per una variabile d'ambiente è string | undefined. Tuttavia, una chiave è definita in Viem come 0x${string} (0x seguito da una stringa). Qui diciamo a TypeScript che la variabile d'ambiente PRIVATE_KEY sarà di quel tipo. In caso contrario, otterremo un errore di runtime.

La funzione privateKeyToAccount (si apre in una nuova scheda) utilizza quindi questa chiave privata per creare un oggetto account completo.

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

Successivamente, utilizziamo l'oggetto account per creare un client del portafoglio (si apre in una nuova scheda). Questo client ha una chiave privata e un indirizzo, quindi può essere utilizzato per inviare transazioni.

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

Ora che abbiamo tutti i prerequisiti, possiamo finalmente creare un'istanza del contratto (si apre in una nuova scheda). Utilizzeremo questa istanza del contratto per comunicare con il contratto onchain.

Lettura dalla blockchain
console.log(`Current greeting:`, await greeter.read.greet())

Le funzioni del contratto di sola lettura (view (si apre in una nuova scheda) e pure (si apre in una nuova scheda)) sono disponibili in read. In questo caso, lo utilizziamo per accedere alla funzione greet (si apre in una nuova scheda), che restituisce il saluto.

JavaScript è a thread singolo, quindi quando avviamo un processo di lunga durata dobbiamo specificare che lo facciamo in modo asincrono (si apre in una nuova scheda). Chiamare la blockchain, anche per un'operazione di sola lettura, richiede un viaggio di andata e ritorno tra il computer e un nodo della blockchain. Questo è il motivo per cui specifichiamo qui che il codice deve attendere (await) il risultato.

Se sei interessato a come funziona, puoi leggerne di più qui (si apre in una nuova scheda), ma in termini pratici tutto ciò che devi sapere è che devi usare await per i risultati se avvii un'operazione che richiede molto tempo, e che qualsiasi funzione che lo fa deve essere dichiarata come async.

Emissione di transazioni
const setGreeting = async (greeting: string): Promise<any> => {

Questa è la funzione che chiami per emettere una transazione che modifica il saluto. Poiché si tratta di un'operazione lunga, la funzione è dichiarata come async. A causa dell'implementazione interna, qualsiasi funzione async deve restituire un oggetto Promise. In questo caso, Promise<any> significa che non specifichiamo cosa verrà esattamente restituito nella Promise.

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

Il campo write dell'istanza del contratto contiene tutte le funzioni che scrivono nello stato della blockchain (quelle che richiedono l'invio di una transazione), come setGreeting (si apre in una nuova scheda). I parametri, se presenti, vengono forniti come elenco e la funzione restituisce l'hash della transazione.

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

    return txHash
}

Segnala l'hash della transazione (come parte di un URL al block explorer per visualizzarlo) e restituiscilo.

Risposta agli eventi
greeter.watchEvent.SetGreeting({

La funzione watchEvent (si apre in una nuova scheda) ti consente di specificare che una funzione deve essere eseguita quando viene emesso un evento. Se ti interessa solo un tipo di evento (in questo caso, SetGreeting), puoi utilizzare questa sintassi per limitarti a quel tipo di evento.

    onLogs: logs => {

La funzione onLogs viene chiamata quando ci sono voci di log. In Ethereum "log" ed "evento" sono solitamente intercambiabili.

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

Potrebbero esserci più eventi, ma per semplicità ci interessa solo il primo. logs[0].args sono gli argomenti dell'evento, in questo caso sender e greeting.

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

Se il mittente non è questo server, usa setGreeting per modificare il saluto.

package.json

Questo file (si apre in una nuova scheda) controlla la configurazione di Node.js (si apre in una nuova scheda). Questo articolo spiega solo le definizioni importanti.

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

Questa definizione specifica quale file JavaScript eseguire.

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

Gli script sono varie azioni dell'applicazione. In questo caso, l'unico che abbiamo è start, che compila e poi esegue il server. Il comando tsc fa parte del pacchetto typescript e compila TypeScript in JavaScript. Se vuoi eseguirlo manualmente, si trova in node_modules/.bin. Il secondo comando esegue il server.

  "type": "module",

Esistono diversi tipi di applicazioni Node JavaScript. Il tipo module ci consente di avere await nel codice di livello superiore, il che è importante quando si eseguono operazioni lente (e quindi asincrone).

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

Questi sono pacchetti richiesti solo per lo sviluppo. Qui abbiamo bisogno di typescript e, poiché lo stiamo utilizzando con Node.js, stiamo ottenendo anche i tipi per le variabili e gli oggetti di Node, come process. La notazione ^<version> (si apre in una nuova scheda) indica quella versione o una versione successiva che non presenta modifiche incompatibili (breaking changes). Vedi qui (si apre in una nuova scheda) per maggiori informazioni sul significato dei numeri di versione.

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

Questi sono i pacchetti richiesti in fase di esecuzione, quando si esegue dist/app.js.

Conclusione

Il server centralizzato che abbiamo creato qui fa il suo lavoro, ovvero agire come agente per un utente. Chiunque altro desideri che la dapp continui a funzionare e sia disposto a spendere il gas può eseguire una nuova istanza del server con il proprio indirizzo.

Tuttavia, questo funziona solo quando le azioni del server centralizzato possono essere facilmente verificate. Se il server centralizzato possiede informazioni di stato segrete o esegue calcoli complessi, è un'entità centralizzata di cui devi fidarti per utilizzare l'applicazione, che è esattamente ciò che le blockchain cercano di evitare. In un articolo futuro ho intenzione di mostrare come utilizzare le prove a conoscenza zero per aggirare questo problema.

Vedi qui per altri miei lavori (si apre in una nuova scheda).