Серверные компоненты и агенты для web3-приложений
Введение
В большинстве случаев децентрализованное приложение (dapp) использует сервер для распространения программного обеспечения, но все фактическое взаимодействие происходит между клиентом (обычно веб-браузером) и блокчейном.
Однако бывают случаи, когда приложению было бы полезно иметь серверный компонент, работающий независимо. Такой сервер мог бы реагировать на события и на запросы, поступающие из других источников, таких как API, путем отправки транзакций.
Существует несколько возможных задач, которые мог бы выполнять такой сервер.
-
Хранитель секретного состояния. В играх часто бывает полезно, чтобы не вся информация, известная игре, была доступна игрокам. Однако в блокчейне нет секретов, любую информацию, находящуюся в блокчейне, легко может узнать любой желающий. Поэтому, если часть состояния игры должна храниться в секрете, она должна храниться в другом месте (и, возможно, эффекты этого состояния должны проверяться с использованием доказательств с нулевым разглашением).
-
Централизованный оракул. Если ставки достаточно низки, внешний сервер, который считывает некоторую информацию в сети, а затем публикует ее в цепь, может быть достаточно хорош для использования в качестве оракула.
-
Агент. В блокчейне ничего не происходит без транзакции, которая бы это активировала. Сервер может действовать от имени пользователя для выполнения таких действий, как арбитраж, когда представляется такая возможность.
Пример программы
Вы можете посмотреть пример сервера на GitHub (opens in a new tab). Этот сервер прослушивает события, исходящие от этого контракта (opens in a new tab), модифицированной версии Greeter от Hardhat. Когда приветствие изменяется, он меняет его обратно.
Чтобы запустить его:
-
Клонируйте репозиторий.
git clone https://github.com/qbzzt/20240715-server-component.git cd 20240715-server-component -
Установите необходимые пакеты. Если у вас его еще нет, сначала установите Node (opens in a new tab).
npm install -
Отредактируйте
.env, чтобы указать приватный ключ аккаунта, на котором есть ETH в тестовой сети Холески. Если у вас нет ETH в Холески, вы можете использовать этот кран (opens in a new tab).PRIVATE_KEY=0x <private key goes here> -
Запустите сервер.
npm start -
Перейдите в обозреватель блоков (opens in a new tab) и, используя адрес, отличный от того, которому принадлежит приватный ключ, измените приветствие. Убедитесь, что приветствие автоматически меняется обратно.
Как это работает?
Самый простой способ понять, как написать серверный компонент — разобрать пример построчно.
src/app.ts
Подавляющая часть программы содержится в src/app.ts (opens in a new tab).
Создание необходимых объектов
import {
createPublicClient,
createWalletClient,
getContract,
http,
Address,
} from "viem"
Это необходимые нам сущности Viem (opens in a new tab), функции и тип Address (opens in a new tab). Этот сервер написан на TypeScript (opens in a new tab), который является расширением JavaScript, делающим его строго типизированным (opens in a new tab).
import { privateKeyToAccount } from "viem/accounts"
Эта функция (opens in a new tab) позволяет нам генерировать информацию о кошельке, включая адрес, соответствующий приватному ключу.
import { holesky } from "viem/chains"
Чтобы использовать блокчейн в Viem, вам нужно импортировать его определение. В данном случае мы хотим подключиться к тестовому блокчейну Холески (opens in a new tab).
// Вот так мы добавляем определения из .env в process.env.
import * as dotenv from "dotenv"
dotenv.config()
Так мы считываем .env в окружение. Это нужно нам для приватного ключа (см. далее).
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
Для использования контракта нам нужен его адрес и для него. Мы предоставляем и то, и другое здесь.
В JavaScript (и, следовательно, в TypeScript) вы не можете присвоить новое значение константе, но вы можете изменить объект, который в ней хранится. Используя суффикс as const, мы сообщаем TypeScript, что сам список является константой и не может быть изменен.
const publicClient = createPublicClient({
chain: holesky,
transport: http(),
})
Создайте публичный клиент (opens in a new tab) Viem. Публичные клиенты не имеют привязанного приватного ключа и поэтому не могут отправлять транзакции. Они могут вызывать функции view (opens in a new tab), считывать балансы аккаунтов и т. д.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
Переменные окружения доступны в process.env (opens in a new tab). Однако TypeScript строго типизирован. Переменная окружения может быть любой строкой или пустой, поэтому тип для переменной окружения — string | undefined. Однако ключ определяется в Viem как 0x${string} (0x, за которым следует строка). Здесь мы сообщаем TypeScript, что переменная окружения PRIVATE_KEY будет иметь этот тип. Если это не так, мы получим ошибку во время выполнения.
Затем функция privateKeyToAccount (opens in a new tab) использует этот приватный ключ для создания полного объекта аккаунта.
const walletClient = createWalletClient({
account,
chain: holesky,
transport: http(),
})
Далее мы используем объект аккаунта для создания клиента кошелька (opens in a new tab). У этого клиента есть приватный ключ и адрес, поэтому его можно использовать для отправки транзакций.
const greeter = getContract({
address: greeterAddress,
abi: greeterABI,
client: { public: publicClient, wallet: walletClient },
})
Теперь, когда у нас есть все необходимое, мы наконец можем создать экземпляр контракта (opens in a new tab). Мы будем использовать этот экземпляр контракта для связи с ончейн-контрактом.
Чтение из блокчейна
console.log(`Current greeting:`, await greeter.read.greet())
Функции контракта, доступные только для чтения (view (opens in a new tab) и pure (opens in a new tab)), доступны в read. В данном случае мы используем его для доступа к функции greet (opens in a new tab), которая возвращает приветствие.
JavaScript является однопоточным, поэтому, когда мы запускаем длительный процесс, нам нужно указать, что мы делаем это асинхронно (opens in a new tab). Вызов блокчейна, даже для операции только для чтения, требует двустороннего обмена данными между компьютером и узлом блокчейна. По этой причине мы указываем здесь, что коду нужно использовать await для ожидания результата.
Если вам интересно, как это работает, вы можете прочитать об этом здесь (opens in a new tab), но на практике все, что вам нужно знать, это то, что вы используете await для результатов, если запускаете операцию, которая занимает много времени, и что любая функция, которая это делает, должна быть объявлена как async.
Отправка транзакций
const setGreeting = async (greeting: string): Promise<any> => {
Это функция, которую вы вызываете для отправки транзакции, изменяющей приветствие. Поскольку это длительная операция, функция объявлена как async. Из-за внутренней реализации любая функция async должна возвращать объект Promise. В данном случае Promise<any> означает, что мы не указываем, что именно будет возвращено в Promise.
const txHash = await greeter.write.setGreeting([greeting])
Поле write экземпляра контракта содержит все функции, которые записывают в состояние блокчейна (те, которые требуют отправки транзакции), такие как setGreeting (opens in a new tab). Параметры, если они есть, предоставляются в виде списка, и функция возвращает хеш транзакции.
console.log(`Working on a fix, see https://eth-holesky.blockscout.com/tx/${txHash}`)
return txHash
}
Сообщите хеш транзакции (как часть URL-адреса обозревателя блоков для его просмотра) и верните его.
Реагирование на события
greeter.watchEvent.SetGreeting({
Функция watchEvent (opens in a new tab) позволяет указать, что функция должна запускаться при генерации события. Если вас интересует только один тип события (в данном случае SetGreeting), вы можете использовать этот синтаксис, чтобы ограничиться этим типом события.
onLogs: logs => {
Функция onLogs вызывается при наличии записей логов. В Эфириуме «лог» и «событие» обычно взаимозаменяемы.
console.log(
`Address ${logs[0].args.sender} changed the greeting to ${logs[0].args.greeting}`
)
Событий может быть несколько, но для простоты нас интересует только первое. logs[0].args — это аргументы события, в данном случае sender и greeting.
if (logs[0].args.sender != account.address)
setGreeting(`${account.address} insists on it being Hello!`)
}
})
Если отправителем не является этот сервер, используйте setGreeting для изменения приветствия.
package.json
Этот файл (opens in a new tab) управляет конфигурацией Node.js (opens in a new tab). В этой статье объясняются только важные определения.
{
"main": "dist/index.js",
Это определение указывает, какой файл JavaScript следует запустить.
"scripts": {
"start": "tsc && node dist/app.js",
},
Скрипты — это различные действия приложения. В данном случае у нас есть только start, который компилирует, а затем запускает сервер. Команда tsc является частью пакета typescript и компилирует TypeScript в JavaScript. Если вы хотите запустить ее вручную, она находится в node_modules/.bin. Вторая команда запускает сервер.
"type": "module",
Существует несколько типов приложений узлов JavaScript. Тип module позволяет нам использовать await в коде верхнего уровня, что важно при выполнении медленных (и, следовательно, асинхронных) операций.
"devDependencies": {
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
},
Это пакеты, которые требуются только для разработки. Здесь нам нужен typescript, и поскольку мы используем его с Node.js, мы также получаем типы для переменных и объектов узла, таких как process. Обозначение ^<version> (opens in a new tab) означает эту версию или более высокую версию, которая не содержит критических изменений. Смотрите здесь (opens in a new tab) для получения дополнительной информации о значении номеров версий.
"dependencies": {
"dotenv": "^16.4.5",
"viem": "2.14.1"
}
}
Это пакеты, которые требуются во время выполнения, при запуске dist/app.js.
Заключение
Созданный нами здесь централизованный сервер выполняет свою работу, которая заключается в том, чтобы действовать в качестве агента для пользователя. Любой другой, кто хочет, чтобы dapp продолжало функционировать, и готов тратить газ, может запустить новый экземпляр сервера со своим собственным адресом.
Однако это работает только тогда, когда действия централизованного сервера можно легко проверить. Если централизованный сервер обладает какой-либо секретной информацией о состоянии или выполняет сложные вычисления, он становится централизованной сущностью, которой вам нужно доверять для использования приложения, а это именно то, чего блокчейны пытаются избежать. В будущей статье я планирую показать, как использовать доказательства с нулевым разглашением, чтобы обойти эту проблему.