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