Всем привет! В предыдущих статьях (1 и 2) я рассказывал про концепцию индексирования данных смарт-контрактов на блокчейне в общем и в частности через средства The Graph, а также про то, как использовать готовые "сабграфы" на The Graph Hosted Service, чтобы, не написав ни строки кода, делать к ним GraphQL запросы и получать данные популярных децентрализованных приложений. Однако, если вы присматриваетесь к Web3 разработке, то вероятно вам и самим придется разрабатывать такие сабграфы для своего приложения. Эту тему (разработка собственных сабграфов стандарта The Graph) я бы и хотел осветить в данном материале. Чтобы пример был не сферический и в вакууме, будем рассматривать существующий смарт-контракт проекта TornadoCash.
Прежде всего, что такое The Graph?
Во-первых, The Graph - это децентрализованный протокол, который позволяет получать доступ к данным смарт контрактов на блокчейне, индексируемым децентрализованными так называемыми «индексаторами», курируемым - «кураторами» и спонсируемым - «делегаторами». Простыми словами, это децентрализованная сеть, в которой есть три роли, которые действуют в своих интересах, вследствие чего вы как внешний пользователь можете подключиться к сети, заплатить денег (в токенах) и делать GraphQL-запросы как к "базе данных" с готовыми данными. Детально можно прочитать о протоколе на thegraph.com.
Во-вторых, The Graph также является технологией, которая помогает создать ETL-процесс (Extract-Transform-Load), или так называемый «сабграф», который будет собирать необходимые данные, хранит их в базе данных и делает их доступными с помощью GraphQL.
Кроме того, чем еще хороша эта технологи - это ее принятие индустрией. В частности, вам не нужно запускать какое-либо специальную инфраструктуру, архивные блокчейн-ноды, индексаторы и т. д., потому что вы можете использовать уже существующих провайдеров инфраструктуры The Graph и деплоить свои сабграфы в один из них, получая взамен GraphQL эндпоинт.
Все это делает сабграфы привлекательным и удобным инструментом для разработчиков Web3, а также аналитиков или исследователей. Итак, приступим.
Как начать работу со сабграфами
Шаблон кода сабграфа можно создать с помощью командной утилиты graph-cli. Чтобы установить ее, выполните следующую команду:
npm install -g @graphprotocol/graph-cli
или
yarn global add @graphprotocol/graph-cli
Далее нужно запустить команду инициализации проекта "graph init", в которую удобно сразу передать все необходимые параметры (хотя можно вводить их по запросу):
graph init tornado_subgraph /path/to/new/project/tornado \
--protocol=ethereum --product=hosted-service \
--allow-simple-name --contract-name TornadoContract \
--from-contract=0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF \
--index-events --start-block=17000000 --network=mainnet
Вы также можете изменить параметр «start-block» на блок, с которого вы действительно хотели бы начать. Например, это может быть блок, когда контракт был задеплоен. Вы можете перейти на Etherscan, найти этот контракт по адресу 0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF (адрес смарт-контракта как правило можно найти на сайте проекта) и пролистать до первой транзакции. Номер блока в данном случае будет 9117720. Кроме того, здеcь указано, что логгировать будем данные с ethereum mainnet (поля --protocol и --network).
В результате выполнения этой команды мы получим папку проекта, которую можно развернуть на любом хостинге сабграфов. Но в этом случае данные будут ограничены только переменными, которые логгируются событиями (events в коде смарт-контрактов на Solidity).
Что означают «переменные, логгируемые событиями»
Если вы откроете код этого смарт-контракта на языке Solidity, вы увидите несколько классов Contract, включающих некоторые функции, такие как deposit или withdraw:
function deposit(bytes32 _commitment) external payable nonReentrant {
require(!commitments[_commitment], "The commitment has been submitted");
uint32 insertedIndex = _insert(_commitment);
commitments[_commitment] = true;
_processDeposit();
emit Deposit(_commitment, insertedIndex, block.timestamp);
}
function withdraw(bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund) external payable nonReentrant {
require(_fee <= denomination, "Fee exceeds transfer value");
require(!nullifierHashes[_nullifierHash], "The note has been already spent");
require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
require(verifier.verifyProof(_proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]), "Invalid withdraw proof");
nullifierHashes[_nullifierHash] = true;
_processWithdraw(_recipient, _relayer, _fee, _refund);
emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
}
Вы можете заметить, что в конце этих функций генерируется событие Deposit/Withdrawal (строки emit Deposit... и emit Withdrawal...). Это означает, что переменные в скобках будут сохранены в журнале блокчейна, к которому легко можно получить доступ с помощью нашего проекта сабграфа (который мы только что создали). Эти события в коде контракта описаны следующим образом:
event Deposit(bytes32 indexed commitment, uint32 leafIndex, uint256 timestamp);
event Withdrawal(address to, bytes32 nullifierHash, address indexed relayer, uint256 fee);
Если вам нужны только эти переменные, вы просто можете развернуть сабграф на платформе хостинга сабграфов, и все - вы получите GraphQL-эндпоинт, который можно вызвать с помощью GraphQL следующим образом (пример для события Withdrawal):
{
withdrawals(first: 10) {
id
to
nullifierHash
relayer
fee
blockNumber
blockTimestamp
transactionHash
}
}
Так, когда вы имеете код сабграфа и понимание, как делать к нему запросы, остается только задеплоить его на хостинг и дождаться, когда он будет синхронизирован. Здесь вы можете посмотреть туториал и демо о том, как развернуть сабграф на платформе Chainstack, однако вы также можете использовать любую другую платформу.
Теперь пришло время посмотреть, что мы фактически сгенерировали с помощью команды "graph init". Чтобы управлять поведением сабграфа, вам понадобится работать только с 3 файлами.
Первый файл - "subgraph.yaml" - называется манифестом. Этот код будет выглядеть следующим образом:
specVersion: 0.0.5
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum
name: TornadoContract
network: mainnet
source:
address: "0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF"
abi: TornadoContract
startBlock: 17000000
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
entities:
- Deposit
- Withdrawal
abis:
- name: TornadoContract
file: ./abis/TornadoContract.json
eventHandlers:
- event: Deposit(indexed bytes32,uint32,uint256)
handler: handleDeposit
- event: Withdrawal(address,bytes32,indexed address,uint256)
handler: handleWithdrawal
file: ./src/tornado-contract.ts
Важными вещами являются (упоминали при генерировании сабграфа командой graph init): chain/network, startBlock, и, кроме того, названия событий и пути к исходным файлам. Все понятно интуитивно. Вы можете оставить этот файл без изменений.
Второй файл, schema.graphql, описывает, как будут храниться наши данные из событий. Файл по умолчанию для этого смарт-контракта будет выглядеть следующим образом:
type Deposit @entity(immutable: true) {
id: Bytes!
commitment: Bytes! # bytes32
leafIndex: BigInt! # uint32
timestamp: BigInt! # uint256
blockNumber: BigInt!
blockTimestamp: BigInt!
transactionHash: Bytes!
}
type Withdrawal @entity(immutable: true) {
id: Bytes!
to: Bytes! # address
nullifierHash: Bytes! # bytes32
relayer: Bytes! # address
fee: BigInt! # uint256
blockNumber: BigInt!
blockTimestamp: BigInt!
transactionHash: Bytes!
}
Если вы хотите добавить что-то, вы можете изменить это прямо в этом файле. Но это только описание того, как сохранить данные, а не о том, как их получить. Здесь вы можете найти материал, объясняющий как устроены схемы сабграфов.
Третий файл, src/tornado-contract.ts - содержит фактическую логику того, как получить данные из событий (и не только из событий!) и как поместить их в таблицы, которые мы только что описали выше. Этот файл выглядит так:
import {
Deposit as DepositEvent,
Withdrawal as WithdrawalEvent
} from "../generated/TornadoContract/TornadoContract"
import { Deposit, Withdrawal } from "../generated/schema"
import { Address, BigInt } from "@graphprotocol/graph-ts"
import { TornadoContract } from "../generated/TornadoContract/TornadoContract"
export function handleDeposit(event: DepositEvent): void {
let entity = new Deposit(
event.transaction.hash.concatI32(event.logIndex.toI32())
)
entity.commitment = event.params.commitment
entity.leafIndex = event.params.leafIndex
entity.timestamp = event.params.timestamp
entity.blockNumber = event.block.number
entity.blockTimestamp = event.block.timestamp
entity.transactionHash = event.transaction.hash
entity.save()
}
export function handleWithdrawal(event: WithdrawalEvent): void {
let entity = new Withdrawal(
event.transaction.hash.concatI32(event.logIndex.toI32())
)
entity.to = event.params.to
entity.nullifierHash = event.params.nullifierHash
entity.relayer = event.params.relayer
entity.fee = event.params.fee
entity.blockNumber = event.block.number
entity.blockTimestamp = event.block.timestamp
entity.transactionHash = event.transaction.hash
entity.save()
}
Как видите, в этом примере данные просто копируются данные из полей переменной "event" в объект Deposit/Withdrawal в соответствующие заполнители. Этот код был сгенерирован и может быть развернут без внесения изменений.
Но что если для каждой транзакции, связанной с Tornado Cash, нам необходима дополнительная информация? Например, в информации отсутствует адрес, который отправляет свои "деньги" на контракт Tornado Cash. Давайте добавим его в несколько строк кода.
Одна вещь, которую вам нужно знать здесь. Когда вы получаете переменную "event", она также содержит гораздо больше информации помимо переданных параметров. Полные сущности данных, которые могут быть легко извлечены, включают:
class Event {
address: Address
logIndex: BigInt
transactionLogIndex: BigInt
logType: string | null
block: Block
transaction: Transaction
parameters: Array<EventParam>
receipt: TransactionReceipt | null
}
class Block {
hash: Bytes
parentHash: Bytes
unclesHash: Bytes
author: Address
stateRoot: Bytes
transactionsRoot: Bytes
receiptsRoot: Bytes
number: BigInt
gasUsed: BigInt
gasLimit: BigInt
timestamp: BigInt
difficulty: BigInt
totalDifficulty: BigInt
size: BigInt | null
baseFeePerGas: BigInt | null
}
class Transaction {
hash: Bytes
index: BigInt
from: Address
to: Address | null
value: BigInt
gasLimit: BigInt
gasPrice: BigInt
input: Bytes
nonce: BigInt
}
class TransactionReceipt {
transactionHash: Bytes
transactionIndex: BigInt
blockHash: Bytes
blockNumber: BigInt
cumulativeGasUsed: BigInt
gasUsed: BigInt
contractAddress: Address
logs: Array<Log>
status: BigInt
root: Bytes
logsBloom: Bytes
}
class Log {
address: Address
topics: Array<Bytes>
data: Bytes
blockHash: Bytes
blockNumber: Bytes
transactionHash: Bytes
transactionIndex: BigInt
logIndex: BigInt
transactionLogIndex: BigInt
logType: string
removed: bool | null
}
Допустим, я хочу добавить поля “from” и “value” из структуры Transaction. Чтобы это сделать, нужно добавить несколько строк кода в src/tornado-contract.ts:
entity.blockNumber = event.block.number
entity.blockTimestamp = event.block.timestamp
entity.transactionHash = event.transaction.hash
// LINE#1 The address that triggered the event can be accessed via event.transaction.from
entity.from_ = event.transaction.from
// LINE#2 The value of the transaction in Wei can be accessed via event.transaction.value
entity.value_ = event.transaction.value
entity.save()
Также нужно добавить пару строк в schema.graphql:
type Deposit @entity(immutable: true) {
id: Bytes!
from_: Bytes! # LINE#1
value_: BigInt! # LINE#2
commitment: Bytes! # bytes32
leafIndex: BigInt! # uint32
timestamp: BigInt! # uint256
blockNumber: BigInt!
blockTimestamp: BigInt!
transactionHash: Bytes!
}
Осталось задеплоить сабграф на хостинг. Команда будет выглядеть вот так:
graph deploy \
--node https://api.graph-eu.p2pify.com/3a57099edc73524c2807cafeefaa82e1/deploy --ipfs https://api.graph-eu.p2pify.com/3a57099ec3635c2807cafeefaa82e1/ipfs \
tornado_subgraph
Далее данные можно получать через GraphQL такими запросами (в UI):
{
deposits(first: 10) {
id
commitment
leafIndex
timestamp
transactionHash
from_
value_
}
}
или через командную строку:
curl -g \\
-X POST \\
-H "Content-Type: application/json" \\
-d '{"query":"{deposits(first: 10) { id commitment leafIndex timestamp transactionHash from_ value_}}"}' \\
https://ethereum-mainnet.graph-eu.p2pify.com/3c6e0b8ac43232a8228b9a98ca1531d/tornado_subgraph
Но что, если вам нужно сохранить результаты вызова смарт-контракта как значение? Это тоже можно сделать из сабграфа, запустив eth_call прямо из кода. Вы можете самостоятельно протестировать эту возможность, если пройдете туториал «Индексирование баланса токенов ERC-20».
Список доп. материалов по теме:
Кроме того, много ссылок, видео и мануалов собрано в репозитории awesome-subgraphs.
Если у вас есть вопросы по разработке/использованию сабграфов, их можно задать их в Telegram-чате комьюнити разработчиков сабграфов Subgraphs Experience Sharing.