Composants de serveur et agents pour les applications web3
Introduction
Dans la plupart des cas, une application décentralisée (dapp) utilise un serveur pour distribuer le logiciel, mais toute l'interaction réelle se produit entre le client (généralement, le navigateur web) et la chaîne de blocs.
Cependant, dans certains cas, une application gagnerait à avoir un composant de serveur qui s'exécute de manière indépendante. Un tel serveur serait capable de répondre aux événements, et aux requêtes provenant d'autres sources, telles qu'une API, en émettant des transactions.
Il y a plusieurs tâches possibles qu'un tel serveur pourrait accomplir.
-
Détenteur d'un état secret. Dans les jeux, il est souvent utile de ne pas mettre à la disposition des joueurs toutes les informations que le jeu connaît. Cependant, il n'y a pas de secrets sur la chaîne de blocs, toute information qui se trouve dans la chaîne de blocs est facile à découvrir pour quiconque. Par conséquent, si une partie de l'état du jeu doit être gardée secrète, elle doit être stockée ailleurs (et éventuellement faire vérifier les effets de cet état à l'aide de preuves à divulgation nulle de connaissance).
-
Oracle centralisé. Si les enjeux sont suffisamment faibles, un serveur externe qui lit des informations en ligne puis les publie sur la chaîne peut être suffisant pour être utilisé comme oracle.
-
Agent. Rien ne se passe sur la chaîne de blocs sans une transaction pour l'activer. Un serveur peut agir au nom d'un utilisateur pour effectuer des actions telles que l'arbitrage lorsque l'occasion se présente.
Programme d'exemple
Vous pouvez voir un exemple de serveur sur GitHub (opens in a new tab). Ce serveur écoute les événements provenant de ce contrat (opens in a new tab), une version modifiée du Greeter de Hardhat. Lorsque la salutation est modifiée, il la rétablit.
Pour l'exécuter :
-
Clonez le dépôt.
git clone https://github.com/qbzzt/20240715-server-component.git cd 20240715-server-component -
Installez les paquets nécessaires. Si vous ne l'avez pas déjà fait, installez d'abord Node.js (opens in a new tab).
npm install -
Modifiez
.envpour spécifier la clé privée d'un compte qui possède des ETH sur le réseau de test Holesky. Si vous n'avez pas d'ETH sur Holesky, vous pouvez utiliser ce faucet (opens in a new tab).PRIVATE_KEY=0x <private key goes here> -
Démarrez le serveur.
npm start -
Allez sur un explorateur de blocs (opens in a new tab), et en utilisant une adresse différente de celle qui possède la clé privée, modifiez la salutation. Constatez que la salutation est automatiquement rétablie.
Comment ça marche ?
La façon la plus simple de comprendre comment écrire un composant de serveur est de parcourir l'exemple ligne par ligne.
src/app.ts
La grande majorité du programme est contenue dans src/app.ts (opens in a new tab).
Création des objets préalables
import {
createPublicClient,
createWalletClient,
getContract,
http,
Address,
} from "viem"
Ce sont les entités Viem (opens in a new tab) dont nous avons besoin, les fonctions et le type Address (opens in a new tab). Ce serveur est écrit en TypeScript (opens in a new tab), qui est une extension de JavaScript le rendant fortement typé (opens in a new tab).
import { privateKeyToAccount } from "viem/accounts"
Cette fonction (opens in a new tab) nous permet de générer les informations du portefeuille, y compris l'adresse, correspondant à une clé privée.
import { holesky } from "viem/chains"
Pour utiliser une chaîne de blocs dans Viem, vous devez importer sa définition. Dans ce cas, nous voulons nous connecter à la chaîne de blocs de test Holesky (opens in a new tab).
// Voici comment nous ajoutons les définitions dans .env à process.env.
import * as dotenv from "dotenv"
dotenv.config()
C'est ainsi que nous lisons .env dans l'environnement. Nous en avons besoin pour la clé privée (voir plus loin).
const greeterAddress : Address = "0xB8f6460Dc30c44401Be26B0d6eD250873d8a50A6"
const greeterABI = [
{
"inputs": [
{
"internalType": "string",
"name": "_greeting",
"type": "string"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
.
.
.
{
"inputs": [
{
"internalType": "string",
"name": "_greeting",
"type": "string"
}
],
"name": "setGreeting",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
] as const
Pour utiliser un contrat, nous avons besoin de son adresse et de son . Nous fournissons les deux ici.
En JavaScript (et donc en TypeScript), vous ne pouvez pas assigner une nouvelle valeur à une constante, mais vous pouvez modifier l'objet qui y est stocké. En utilisant le suffixe as const, nous indiquons à TypeScript que la liste elle-même est constante et ne peut pas être modifiée.
const publicClient = createPublicClient({
chain: holesky,
transport: http(),
})
Créez un client public (opens in a new tab) Viem. Les clients publics n'ont pas de clé privée attachée, et ne peuvent donc pas envoyer de transactions. Ils peuvent appeler des fonctions view (opens in a new tab), lire les soldes de compte, etc.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
Les variables d'environnement sont disponibles dans process.env (opens in a new tab). Cependant, TypeScript est fortement typé. Une variable d'environnement peut être n'importe quelle chaîne de caractères, ou vide, donc le type pour une variable d'environnement est string | undefined. Cependant, une clé est définie dans Viem comme 0x${string} (0x suivi d'une chaîne de caractères). Ici, nous indiquons à TypeScript que la variable d'environnement PRIVATE_KEY sera de ce type. Si ce n'est pas le cas, nous obtiendrons une erreur d'exécution.
La fonction privateKeyToAccount (opens in a new tab) utilise ensuite cette clé privée pour créer un objet de compte complet.
const walletClient = createWalletClient({
account,
chain: holesky,
transport: http(),
})
Ensuite, nous utilisons l'objet de compte pour créer un client de portefeuille (opens in a new tab). Ce client possède une clé privée et une adresse, il peut donc être utilisé pour envoyer des transactions.
const greeter = getContract({
address: greeterAddress,
abi: greeterABI,
client: { public: publicClient, wallet: walletClient },
})
Maintenant que nous avons tous les prérequis, nous pouvons enfin créer une instance de contrat (opens in a new tab). Nous utiliserons cette instance de contrat pour communiquer avec le contrat onchain.
Lecture depuis la chaîne de blocs
console.log(`Current greeting:`, await greeter.read.greet())
Les fonctions du contrat qui sont en lecture seule (view (opens in a new tab) et pure (opens in a new tab)) sont disponibles sous read. Dans ce cas, nous l'utilisons pour accéder à la fonction greet (opens in a new tab), qui renvoie la salutation.
JavaScript est mono-thread, donc lorsque nous lançons un processus long, nous devons spécifier que nous le faisons de manière asynchrone (opens in a new tab). Appeler la chaîne de blocs, même pour une opération en lecture seule, nécessite un aller-retour entre l'ordinateur et un nœud de la chaîne de blocs. C'est la raison pour laquelle nous spécifions ici que le code doit attendre (await) le résultat.
Si vous êtes intéressé par la façon dont cela fonctionne, vous pouvez en lire plus ici (opens in a new tab), mais en termes pratiques, tout ce que vous devez savoir est que vous attendez (await) les résultats si vous démarrez une opération qui prend beaucoup de temps, et que toute fonction qui fait cela doit être déclarée comme async.
Émission de transactions
const setGreeting = async (greeting: string): Promise<any> => {
C'est la fonction que vous appelez pour émettre une transaction qui modifie la salutation. Comme il s'agit d'une opération longue, la fonction est déclarée comme async. En raison de l'implémentation interne, toute fonction async doit renvoyer un objet Promise. Dans ce cas, Promise<any> signifie que nous ne spécifions pas ce qui sera exactement renvoyé dans la Promise.
const txHash = await greeter.write.setGreeting([greeting])
Le champ write de l'instance de contrat contient toutes les fonctions qui écrivent dans l'état de la chaîne de blocs (celles qui nécessitent l'envoi d'une transaction), telles que setGreeting (opens in a new tab). Les paramètres, s'il y en a, sont fournis sous forme de liste, et la fonction renvoie le hash de la transaction.
console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)
return txHash
}
Signalez le hash de la transaction (dans le cadre d'une URL vers l'explorateur de blocs pour la visualiser) et renvoyez-le.
Réponse aux événements
greeter.watchEvent.SetGreeting({
La fonction watchEvent (opens in a new tab) vous permet de spécifier qu'une fonction doit s'exécuter lorsqu'un événement est émis. Si vous ne vous intéressez qu'à un seul type d'événement (dans ce cas, SetGreeting), vous pouvez utiliser cette syntaxe pour vous limiter à ce type d'événement.
onLogs: logs => {
La fonction onLogs est appelée lorsqu'il y a des entrées de journal. Dans Ethereum, « journal » et « événement » sont généralement interchangeables.
console.log(
`Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`
)
Il pourrait y avoir plusieurs événements, mais pour des raisons de simplicité, nous ne nous intéressons qu'au premier. logs[0].args sont les arguments de l'événement, dans ce cas sender et greeting.
if (logs[0].args.sender != account.address)
setGreeting(`${account.address} insists on it being Hello!`)
}
})
Si l'expéditeur n'est pas ce serveur, utilisez setGreeting pour modifier la salutation.
package.json
Ce fichier (opens in a new tab) contrôle la configuration de Node.js (opens in a new tab). Cet article n'explique que les définitions importantes.
{
"main": "dist/index.js",
Cette définition spécifie quel fichier JavaScript exécuter.
"scripts": {
"start": "tsc && node dist/app.js",
},
Les scripts sont diverses actions de l'application. Dans ce cas, le seul que nous ayons est start, qui compile puis exécute le serveur. La commande tsc fait partie du paquet typescript et compile TypeScript en JavaScript. Si vous souhaitez l'exécuter manuellement, elle se trouve dans node_modules/.bin. La deuxième commande exécute le serveur.
"type": "module",
Il existe plusieurs types d'applications de nœud JavaScript. Le type module nous permet d'avoir await dans le code de niveau supérieur, ce qui est important lorsque vous effectuez des opérations lentes (et donc asynchrones).
"devDependencies": {
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
},
Ce sont des paquets qui ne sont requis que pour le développement. Ici, nous avons besoin de typescript et parce que nous l'utilisons avec Node.js, nous obtenons également les types pour les variables et objets de nœud, tels que process. La notation ^<version> (opens in a new tab) signifie cette version ou une version supérieure qui ne comporte pas de changements majeurs. Voir ici (opens in a new tab) pour plus d'informations sur la signification des numéros de version.
"dependencies": {
"dotenv": "^16.4.5",
"viem": "2.14.1"
}
}
Ce sont des paquets qui sont requis à l'exécution, lors du lancement de dist/app.js.
Conclusion
Le serveur centralisé que nous avons créé ici fait son travail, qui est d'agir comme un agent pour un utilisateur. Toute autre personne qui souhaite que la dapp continue de fonctionner et qui est prête à dépenser le gaz peut exécuter une nouvelle instance du serveur avec sa propre adresse.
Cependant, cela ne fonctionne que lorsque les actions du serveur centralisé peuvent être facilement vérifiées. Si le serveur centralisé possède des informations d'état secrètes, ou exécute des calculs difficiles, c'est une entité centralisée en laquelle vous devez avoir confiance pour utiliser l'application, ce qui est exactement ce que les chaînes de blocs essaient d'éviter. Dans un prochain article, je prévois de montrer comment utiliser les preuves à divulgation nulle de connaissance pour contourner ce problème.