Вступление

Всем привет!
Сегодня я хотел бы разобрать, как можно взаимодействовать с блокчейном в javascript приложениях. Мы будем двигаться к решению этой задачи планомерно, чтобы разобрать весь процесс взаимодействия с блокчейном. Разбирать будет на примере библиотеки ethers. Чуть ниже вы можете ознакомиться с содержанием и выбрать интересующий вас раздел.

Кому подойдет эта статья?

Если вы знакомы с устройством блокчейна, у вас установлен метамаск и вы хорошо владеете javascript, то можете смело читать дальше. В другом случае материал может быть сложен для восприятия.

Содержание

  • Взаимодействие с блокчейном

  • Cущность Provider

  • Подключение кошелька MetaMask

  • Cущность Contract

  • Взаимодействие c смарт-контрактом ERC-20 токена

  • Оптимизация множественных вызовов

Взаимодействие с блокченом

Для того чтобы взаимодействовать с блокчейном в наших javascript приложениях, будь то react, vue или же ванильный javascript, мы можем использовать библиотеку ethers, что и следует из документации:

Библиотека ethers.js призвана стать полной и компактной библиотекой для взаимодействия с блокчейном Ethereum и его экосистемой.

Само взаимодействие происходит, благодаря JSON-RPC запросам.

JSON-RPC - протокол удалённого вызова процедур, использующий JSON для кодирования сообщений. Это очень простой протокол (очень похожий на XML-RPC), определяющий только несколько типов данных и команд. JSON-RPC поддерживает уведомления (информация, отправляемая на сервер, не требует ответа) и множественные вызовы.

Если по простому, то идет то же взаимодействие с сервером, но вместо кучи енд-поинтов есть только один и мы лишь описываем, что нам нужно выполнить или какие данные нужны получить.

Cущность Provider

Provider - это абстракция для доступа к данным блокчейна.

Для того чтобы начать "стучаться" к блокчейну через rpc-запросы нам для начала нужно создать экземпляр провайдера.

В ethers реализовано множество провайдеров. Каждый является абстракцией для лучшего решения определенной задачи, но сегодня разберем только основные - JsonRpcProvider, Web3Provider.

Инициализировать провайдеры можно следующим образом:

new ethers.providers.JsonRpcProvider(UrlORConnectionInfo)

JsonRpcProvider принимает в качестве аргумента ссылку поставщика данных через JSON-RPC HTTP API или необходимую информацию для подключения к нему ( api ключ и т. д. ). Обычно этот провайдер используют для чтения данных с блокчейна. Например: получение баланса токенов, стоимости выпуска токенов и т. д.

new ethers.providers.Web3Provider(externalProvider)

Web3Provider - это абстракция над другим провайдером, чтобы облегчить взаимодействие с ними. Обычно его применяют для получения необходимых данных об учетной записи кошелька. Например: публичный адрес, активная сеть.

Подключение кошелька MetaMask

Для того чтобы подключить кошелек пользователя реализуем функцию connect:

import { ethers } from 'ethers'

let web3 = null
let provider = null

async connect () {
    if (window.ethereum) { // проверяем есть ли кошелек
      // сохраняем экземпляр для дальнейшей инициализации web3Provider
      provider = window.ethereum 
      try {
        // получаем учетные записи кошелька
        await window.ethereum.request({ method: 'eth_requestAccounts' })
      } catch (e) {
        console.error(e)
        if (e.code === 4001) return
      }
    } else if (window.web3) {
      provider = window.web3.currentProvider
    }
    if (provider) {
        // инициалзируем web3 provider
        web3 = new ethers.providers.Web3Provider(provider, 'any')
      }
  }

window.ethereum - это глобальный объект, который доступен в браузере, если у пользователя установлен MetaMask или другой кошелек совместимый с evm(ethereum virtual machine) блокчейнами.

window.web3 — более старая реализация, которую все еще могут использовать некоторые клиенты.

На данный момент мы подключили кошелек, но мы все еще не получили основные данные необходимые для отображения пользователю (публичный ключ или адрес кошелька и выбранную сеть).
Для это реализуем еще одну функцию loadProvider:

let userAccount = null // активная учетная запись в кошельке
let userAccounts = [] // все учетные записи кошелька
let userChain = null // активная сеть
async loadProvider () {
    try {
      // отписываем от всех слушателей если они уже есть
      if (provider.removeAllListeners) provider.removeAllListeners()

      // проверяем доступна ли подписка на события
      if (provider.on) {
        // следим за изменением выбранной сети
        provider.on('chainChanged', async (chainId) => {
          /* 
           если есть заранее подготовленные пресеты необходимых сетей можно
           менять по айди
          */
          userChain = await provider.getNetwork();
        })
        // следим за изменением аккаунта
        provider.on('accountsChanged', async (accounts) => {
          if (accounts.length !== 0) {
            userAccount = accounts[0]
            userAccounts = accounts
            // вызываем ранее созданную функцию для инициализации провайдера
            await connect()
          } else {
            // если нет учетных записей обнуляем наши данные
            userAccount = ''
            userAccounts = []
          }
        })
      }
      let network, accounts
      try {
        [chain, accounts] = await Promise.all([
          web3.getNetwork(), // получаем активную сеть
          web3.listAccounts() // получаем учетные записи кошелька
        ])
        userAccounts = accounts
        userChain = chain
      } catch (e) {
        console.log(e)
      }
    } catch (e) {
      account = ''
      return Promise.reject(e)
    }
  }

Как мы выяснили выше у провайдера есть возможность подписки на события, а именно на смену учетных записей и смену активной сети

Теперь мы храним и отслеживаем изменение всех необходимых нам данных. Для отображения cети пользователю можно взять userChain.name. Так же объект содержит id сети, который возможно пригодится в приложении. Как и упомяналось ранее, у вас могут быть заранее подготовленные пресеты сетей в виде объектов:

const networks = {
    1: {
      name: 'Ethereum Mainnet',
      shortName: 'ethereum',
      chainId: 1,
      network: 'ethereum',
      rpc: 'https://rpc.ankr.com/eth',
      explorer: 'https://etherscan.io'
    }
    //...
}

rpc - это ссылка на поставщика данных, необходимая для инициализации провайдера.
explorer - будет полезным если необходимо отображать ссылки на транзакции в etherscan.

Чтобы обрабатывать смену сети с пресетами достаточно будет реализовать функцию handleChainChanged и передавать в нее chain.id в местах где мы явно записывали все в переменную chain:

async handleChainChanged (chainId) {
    // если пресета нет, то получаем необходимые данные
    if (!networks[chainId]) {
      const chain = (await web3.getNetwork())
      networks[chain.id] = chain
    }
    userChain = networks[chainId]
}

Cущность Contract

Contract - это абстракция кода, которая была развернута в блокчейне.

Простыми словами все что существует в блокчейне можно воспринимать как смарт-контракты и соответственно взаимодействовать с ними. Например: Токены, фармилки, и т.д.

Ethers js предлагает нам реализацию абстракции под данные сущности:

new ethers.Contract( address , abi , signerOrProvider )

Конструктор смарт-контракта принимает следующие аргументы:

  1. address - публичный адрес смарт-контракта, для эфириума его можно посмотреть на https://etherscan.io или в другом explorer для других сетей. Например адрес смарт-контракта токена USDT в сети эфириум выглядит так:

0xdAC17F958D2ee523a2206206994597C13D831ec7
  1. abi - Сущность необходимая, чтобы описать какие существуют методы, события и ошибки, чтобы библиотека могла обрабатывать кодирование и декодирование данных из сети и в сеть. Например abi для описания поля name выглядит так:

[
    {
        "constant": true,
        "inputs": [], // входные параметры
        "name": "name", // имя свойства смарт-контракта
        "outputs": [ // возвращаемые параметры
            {
                "name": "",
                "type": "string"
            }
        ],
        "payable": false, // если true - может взымать эфир для платежей
        "stateMutability": "view",
        "type": "function"
    }
]
  1. signerOrProvider - signer, если нужно выполнить действие в смарт-контракте (отправить, снять токены и т д), или provider, если нужно прочитать данные (получить баланс и т д ).

    signer - это это абстракция учетной записи Ethereum, которую можно использовать для подписи сообщений и транзакций, а также для отправки подписанных транзакций в сеть Ethereum, другими для выполнения операций по изменению данных в блокчейне.

    provider - мы разбирали выше.

Взаимодействие c смарт-контрактом ERC-20 токена

ERC-20 - это стандарт токена, который гарантирует, что у данного смарт-контракта будут реализованы определенные методы. Например:

  • balanceOf - метод для получения баланса определенной учетной записи

  • approve - метод для выдачи разрешения на использование токенов другому смарт-контракту

  • transfer - метод для отправки токенов на другой адрес

    И многие другие.

Попробуем разобраться как взаимодействовать со смарт-контрактом ERC-20 токена на примере получения баланса токена DAI в сети polygon.

Разберем код ниже:

import { ethers } from 'ethers'

const BALANCEOF_ABI =[
  {
        inputs: [
            {
                "internalType": "address",
                "name": "owner",
                "type": "address"
            }
        ],
        name: "balanceOf",
        outputs: [
            {
                internalType: "uint256",
                name: "",
                type: "uint256"
            }
        ],
        stateMutability: "view",
        type: "function"
    },
]
const DAI_ADDRESS = "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063"
const DAI_DECIMALS = 18
const POLYGON_RPC_URL = "https://polygon-rpc.com"

const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL)
const web3 = new ethers.providers.Web3Provider(window.ethereum, 'any')

const userAccount = (await web3.listAccounts())[0]

const DAI = new ethers.Contract(DAI_ADDRESS, BALANCEOF_ABI, provider)

const DAIBalanceBigInt = await DAI.balanceOf(owner.address)
const DAIBalanceNumber = ethers.utils.formatUnits(
  DAIBalanceBigInt.toString(),
  DAI_DECIMALS
)

Для начала мы задаем abi, address смарт-контракта DAI и decimals (кол-во десятичных дробей, необходимое чтобы привести баланс к читаемому виду).
Все эти необходимые данные обычно предоставляет разработчик смарт-контракта или их можно найти на etherscan.io или в другом explorer для других сетей.

Далее мы инициализируем provider для чтения данных и web3 провайдер для получения текущей учетной записи кошелька. Затем получаем учетную запись пользователя. Как это завернуть, более красиво, с обработкой ошибкой мы рассматривали чуть выше.

Затем мы инициализируем экземпляр нашего смарт-контракта для токена DAI. Вызываем у смарт-контрактка метод balanceOf, который принимает аргументом адрес учетной записи кошелька для которого мы хотим узнать баланс. А возвращает BigInt.

И в конце преобразуем BigInt в читаемый вид c помощью утилиты ethers formatUnits.

Оптимизация множественных вызовов

Иногда, возникает ситуация, когда нам необходимо взаимодействовать с большим кол-вом смарт-контрактов. Мы разберем как это можно сделать на примере получения балансов ERC-20 токенов в сети polygon.

Практически в каждой сети есть мульти-колл смарт-контракт, который позволяет взаимодействовать сразу с несколькими смарт-контрактами в одном запросе. Чтобы реализовывать общение с ним вручную нужно потратить время на поиск такого смарт-контракта в каждой сети и настроить взаимодействие с нужным в зависимости от активной сети. Мы не будем переизобретать велосипед и воспользуемся готовой библиотекой, которая называется ethcall.

Ethcall имеет схожие сущности с ethers - Contract и Provider. На примере данного кода мы разберемся как взаимодействовать с ней:

import { Provider, Contract } from 'ethcall'
import { ethers } from 'ethers'

const BALANCEOF_ABI =[
  {
        inputs: [
            {
                "internalType": "address",
                "name": "owner",
                "type": "address"
            }
        ],
        name: "balanceOf",
        outputs: [
            {
                internalType: "uint256",
                name: "",
                type: "uint256"
            }
        ],
        stateMutability: "view",
        type: "function"
    },
]

const POLYGON_RPC_URL = "https://polygon-rpc.com"

const tokens = [
  {
    symbol: 'USDT',
    address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
    decimals: 6
  },
  {
    symbol: 'USDC',
    address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
    decimals: 6
  },
  {
    symbol: 'DAI',
    address: '0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063',
    decimals: 18
  }
]


const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL)
const web3 = new ethers.providers.Web3Provider(window.ethereum, 'any')

const multicallProvider = new Provider()
await multicallProvider.init(provider)

const userAccount = (await web3.listAccounts())[0]

const calls = tokens.map((token) => {
 const tokenContract = new Contract(token.address, BALANCEOF_ABI)
 return tokenContract.balanceOf(userAccount)
})

const tokenBalancesBigInt = await multicallProvider.all(calls)

const tokenBalancesNumber = tokenBalancesBigInt.map((tokenBalanceBI, index) => {
  return ethers.utils.formatUnits(
    tokenBalanceBI.toString(),
    tokens[index].decimals
  )
})

Частично код соответствует, рассмотренному ранее способу получения баланса. Но все же есть определенные отличия.

Сущность Provider ethcall инициализируется немного иначе. Мы сначала создали провайдер без параметров, а затем уже обратились к методу init с параметром в виде провайдера ethers.

Сущность Contract ethcall также отличается инициализацией и в отличии от ethers Contract не требует 3 параметра.

Затем мы перебрали весь наш список токенов и создали экземпляр контракта для каждого из них, у этого экземпляра вызвали метод для получения баланса с аргументом в виде адреса учетной записи пользователя.

Затем воспользовались методом all провайдера ethcall, который "под капотом" стучится к смарт-контракту мульти-кол и обрабатывает все данные. Параметром передали массив всех наших обращений к смарт-контрактам.

И в конце преобразовали все полученные ответы в читабельный вид.

Вывод

Ethers.js - это мощная библиотека для работы с Ethereum совместимыми блокчейнами, которая предоставляет удобный интерфейс для взаимодействия с смарт-контрактами, отправки и получения транзакций, работы с кошельками и многим другим.

В статье мы рассмотрели основные возможности ethers.js и научились работать с смарт-контрактами и кошельками.

Благодаря удобному интерфейсу и широкому функционалу, ethers.js может быть использована для создания различных приложений - от децентрализованных финансовых до игр и маркетплейсов.

Надеюсь, что данная статья была для вас полезной.

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


  1. PaulIsh
    00.00.0000 00:00

    Вот эта строчка в connect точно что-то инициализирует или фрагмент кода потерялся?

    provider = window.ethereum // инициализируем provider


    1. AlanNabiev Автор
      00.00.0000 00:00

      Да, действительно там опечатка в комментарии.

      В этой строчке сохранили экземпляр window.ethereum для дальнейшей инициализации web3Provider.

      Это может быть полезно если мы хотим реализовать адаптер для подключения к разным кошелькам по условию. Например walletConnect ( библиотека для подключения мобильных кошельков).

      provider = new WalletConnectProvider(options)

      И в итоге у нас логика по подключению кошелька не изменится, а изменится только провайдер и флоу пользователя в интерфейсе.