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

Комментарии (0)