Всем привет! В предыдущих статьях (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.
