Server components and agents for web3 apps
Introduction
In most cases, a decentralized app uses a server to distribute the software, but all the actual interaction happens between the client (typically, web browser) and the blockchain.
However, there are some cases where an application would benefit from having a server component that runs independently. Such a server would be able to respond to events, and to requests that come from other sources, such as an API, by issuing transactions.
There are several possible tasks for such a server could fulfill.
Holder of secret state. In gaming it is often useful not to have all the information that the game knows available to the players. However, there are no secrets on the blockchain, any information that is in the blockchain is easy for anybody to figure out. Therefore, if part of the game state is to be kept secret, it has to be stored elsewhere (and possibly have the effects of that state verified using zero-knowledge proofs).
Centralized oracle. If the stakes are sufficiently low, an external server that reads some information online and then posts it to the chain may be good enough to use as an oracle.
Agent. Nothing happens on the blockchain without a transaction to activate it. A server can act on behalf of a user to perform actions such as arbitrage when the opportunity presents itself.
Sample program
You can see a sample server on github(opens in a new tab). This server listens to events coming from this contract(opens in a new tab), a modified version of Hardhat's Greeter. When the greeting is changed, it changes it back.
To run it:
Clone the repository.
1git clone https://github.com/qbzzt/20240715-server-component.git2cd 20240715-server-componentInstall the necessary packages. If you don't have it already, install Node first(opens in a new tab).
1npm installEdit
.env
to specify the private key of an account that has ETH on the Holesky testnet. If you do not have ETH on Holesky, you can use this faucet(opens in a new tab).1PRIVATE_KEY=0x <private key goes here>Start the server.
1npm startGo to a block explorer(opens in a new tab), and using a different address than the one that has the private key modify the greeting. See that the greeting is automatically modified back.
How does it work?
The easiest way to understand how to write a server component is to go over the sample one line by line.
src/app.ts
The vast majority of the program is contained in src/app.ts
(opens in a new tab).
Creating the prerequisite objects
1import { createPublicClient, createWalletClient, getContract, http, Address } from 'viem'
These are the Viem(opens in a new tab) entities we need, functions and the Address
type(opens in a new tab). This server is written in TypeScript(opens in a new tab), which is an extension to JavaScript that makes it strongly typed(opens in a new tab).
1import { privateKeyToAccount } from 'viem/accounts'
This function(opens in a new tab) lets us generate the wallet information, including address, corresponding to a private key.
1import { holesky } from 'viem/chains'
To use a blockchain in Viem you need to import its definition. In this case, we want to connect to the Holesky(opens in a new tab) test blockchain.
1// This is how we add the definitions in .env to process.env.2import * as dotenv from "dotenv";3dotenv.config()
This is how we read .env
into the environment. We need it for the private key (see later).
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 constShow all
To use a contract we need its address and the ABI for it. We provide both here.
In JavaScript (and therefore TypeScript) you can't assign a new value to a constant, but you can modify the object that is stored in it. By using the suffix as const
we are telling TypeScript that the list itself is constant and may not be changed.
1const publicClient = createPublicClient({2 chain: holesky,3 transport: http(),4})
Create a Viem public client(opens in a new tab). Public clients do not have an attached private key, and therefore cannot send transactions. They can call view
functions(opens in a new tab), read account balances, etc.
1const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
The environment variables are available in process.env
(opens in a new tab). However, TypeScript is strongly typed. An environment variable can be be any string, or empty, so the type for an environment variable is string | undefined
. However, a key is defined in Viem as 0x${string}
(0x
followed by a string). Here we tell TypeScript that the PRIVATE_KEY
environment variable will be of that type. If it isn't, we'll get a runtime error.
The privateKeyToAccount
(opens in a new tab) function then uses this private key to create a full account object.
1const walletClient = createWalletClient({2 account,3 chain: holesky,4 transport: http(),5})
Next, we use the account object to create a wallet client(opens in a new tab). This client has a private key and an address, so it can be used to send transactions.
1const greeter = getContract({2 address: greeterAddress,3 abi: greeterABI,4 client: { public: publicClient, wallet: walletClient }5})
Now that we have all the prerequisites, we can finally create a contract instance(opens in a new tab). We will use this contract instance to communicate with the on-chain contract.
Reading from the blockchain
1console.log(`Current greeting:`, await greeter.read.greet())
The contract functions that are read only (view
(opens in a new tab) and pure
(opens in a new tab)) are available under read
. In this case, we use it to access the greet
(opens in a new tab) function, which returns the greeting.
JavaScript is single-threaded, so when we fire off a long running process we need to specify we do it asynchronously(opens in a new tab). Calling the blockchain, even for a read only operation, requires a round-trip between the computer and a blockchain node. That is the reason we specify here the code needs to await
for the result.
If you are interested in how this work you can read about it here(opens in a new tab), but in practical terms all you need to know is that you await
the results if you start an operation that takes a long time, and that any function that does this has to be declared as async
.
Issuing transactions
1const setGreeting = async (greeting: string): Promise<any> => {
This is the function you call to issue a transaction that changes the greeting. As this is a long operation, the function is declared as async
. Because of the internal implementation, any async
function needs to return a Promise
object. In this case, Promise<any>
means that we don't specify what exactly will be returned in the Promise
.
1 const txHash = await greeter.write.setGreeting([greeting]);
The write
field of the contract instance has all the functions that write to the blockchain state (those that require sending a transaction), such as setGreeting
(opens in a new tab). The parameters, if any, are provided as a list, and the function returns the hash of the transaction.
1 console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)23 return txHash4}
Report the hash of the transaction (as part of a URL to the block explorer to view it) and return it.
Responding to events
1greeter.watchEvent.SetGreeting({
The watchEvent
function(opens in a new tab) lets you specify that a function is to run when an event is emitted. If you only care about one type of event (in this case, SetGreeting
), you can use this syntax to limit yourself to that event type.
1 onLogs: logs => {
The onLogs
function is called when there are log entries. In Ethereum "log" and "event" are usually interchangeable.
1 console.log(`Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`)
There could be multiple events, but foir simplicity we only care about the first one. logs[0].args
are the arguments of the event, in this case sender
and greeting
.
1 if (logs[0].args.sender != account.address)2 setGreeting(`${account.address} insists on it being Hello!`)3 }4})
If the sender is not this server, use setGreeting
to change the greeting.
package.json
This file(opens in a new tab) controls the Node.js(opens in a new tab) configuration. This article only explains the important definitions.
1{2 "main": "dist/index.js",Copy
This definition specifies which JavaScript file to run.
1 "scripts": {2 "start": "tsc && node dist/app.js",3 },Copy
The scripts are various application actions. In this case, the only one we have is start
, which compiles and then runs the server. The tsc
command is part of the typescript
package and compiles TypeScript into JavaScript. If you want to run it manually, it is located in node_modules/.bin
. The second command runs the server.
1 "type": "module",Copy
There are multiple types of JavaScript node applications. The module
type lets us have await
in the top level code, which is important when you do slow (and there asynchronous) operations.
1 "devDependencies": {2 "@types/node": "^20.14.2",3 "typescript": "^5.4.5"4 },Copy
These are packages that are only required for development. Here we need typescript
and the because we are using it with Node.js, we are also getting the types for node variables and objects, such as process
. The ^<version>
notation(opens in a new tab) means that version or a higher version that doesn't have breaking changes. See here(opens in a new tab) for more information about the meaning of version numbers.
1 "dependencies": {2 "dotenv": "^16.4.5",3 "viem": "2.14.1"4 }5}Copy
These are packages that are required at runtime, when running dist/app.js
.
Conclusion
The centralized server we created here does its job, which is to act as an agent for a user. Anybody else who wants the dapp to continue functioning and is willing to spend the gas can run a new instance of the server with their own address.
However, this only works when the centralized server's actions can be easily verified. If the centralized server has any secret state information, or runs difficult calculations, it is a centralized entity that you need trust to use the application, which is exactly what blockchains try to avoid. In a future article I plan to show how to use zero-knowledge proofs to get around this problem.
Last edit: @(opens in a new tab), September 16, 2024