ethers.js – TypeScript библиотека для доступа к EVM блокчейнам. Я использую ее в своих проектах, и другими авторами по ней уже написаны хорошие статьи на хабре (основы, отправка транзакций). В текущей статье хочу поделиться опытом оптимизации запросов ethers js при работе с различными облачными провайдерами JSON-RPC (Alchemy, Infura) и self-hosted нодой. Расскажу о случаях, когда приложение может просто перестать работать, и что с этим делать. Приведу прикидки производительности.

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

Проблема

Нужно было реализовать фичу, которая выгружает в CSV балансы в USDT 0xc2132D05D31c914a87C6611C10748AEb04B58e8F на Полигоне некоторого количества кошельков (в тестовых данных было около 30ти). Первая реализация была примерно такая (wallets – кошельки для проверки):

const provider = new ethers.JsonRpcProvider(rpcUrl);
 
wallets.map(async (wallet) => {
  const contract = new ethers.Contract(
    "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
    abi,
    provider
  );
  const bal = await contract.balanceOf(wallet);

  console.log(`Wallet: ${wallet}, Balance: ${bal}`);
});

В качестве rpcUrl использовался бесплатный от Infura. В итоге получил зависание на 5 минут (!) и потом ошибку (сокращено для наглядности):

JsonRpcProvider failed to detect network and cannot start up; retry in 1s (perhaps the URL is wrong or the node is not started)

Error: exceeded maximum retry limit (request={ }, response={ }, error=null, code=SERVER_ERROR, version=6.6.7) at makeError

Сразу возникли 2 вопроса:

  • Почему так долго?

  • Почему появился retry limit на 30ти запросах?

Решение 1 – самостоятельный батчинг

Первой попыткой решения был батчинг запросов. Ethereum JSON-RPC поддерживает отправку нескольких Ethereum запросов в одном HTTP запросе. Так это выглядит через вызов curl – запрос баланса USDT на Полигоне двух кошельков 0x2066fc1cac710806c8ffcc8539ec69d6ce2ee489 и 0x5f4d4b6b59a88fd83aa754bdd12afc73f53830ec.

curl https://ethnode.mydomain.com \
  -X POST \
  -H "Content-Type: application/json" \
  -d '[
  {
    "jsonrpc": "2.0",
    "method": "eth_call",
    "params": [
      {
        "to": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
        "data": "0x70a082310000000000000000000000002066fc1cac710806c8ffcc8539ec69d6ce2ee489"
      },
      "latest"
    ],
    "id": 1
  },
  {
    "jsonrpc": "2.0",
    "method": "eth_call",
    "params": [
      {
        "to": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
        "data": "0x70a082310000000000000000000000005f4d4b6b59a88fd83aa754bdd12afc73f53830ec"
      },
      "latest"
    ],
    "id": 1
  }
]
'

В ethers.js это можно сделать так (некоторые детали и импорты опущены):

const createPayload = async (
  token: string,
  wallet: string,
  provider: ethers.JsonRpcProvider
) => {
  const contract = new ethers.Contract(token, abi, provider);
  const byContract = await contract.balanceOf.populateTransaction(wallet);

  return {
    method: "eth_call",
    id: 1,
    params: [byContract, "latest"],
    jsonrpc: "2.0",
  } as JsonRpcPayload;
};

const provider = new ethers.JsonRpcProvider(rpcUrl);

const payloads = await Promise.all(
  wallets.map(async (wallet) => {
    return {
      payload: createPayload(
        "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
        wallet,
        provider
      ),
      wallet: wallet,
    };
  })
);

const res = await provider._send(
  await Promise.all(payloads.map((p) => p.payload))
);

// print res

Используется функция JsonRpcProvider._send, которая принимает массив ethers.JsonRpcPayload и отправляет это в одном HTTP запросе. По сути, это то же самое, что вызов curl выше, просто создано программно.

contract.balanceOf.populateTransaction(wallet) – это удобный способ, чтобы сконструировать calldata, состоящая из сигнатуры функции balanceOf (0x70a08231) и адреса кошелька.

Запустив код выше с 30ю кошельками, получил ту же самую ошибку ("exceeded maximum retry limit") с зависанием на 5 минут. Путем сокращения количества кошельков было найдено ограничение в 10 JSON-RPC запросов в одном HTTP запросе на бесплатном плане Infura.

Решил попробовать сравнить с бесплатным Alchemy и self-hosted нодой эфира на Geth:

Infura free

Alchemy free

Собственная нода (в моем городе)

Лимит кол-ва запросов в батче

10

1000

1000

Ошибка при превышении лимита

HTTP 429 Too Many Requests

-32005 batch item count exceeded

HTTP 400 Bad Request

-32600 Sorry, the maximum batch request size is 1000

HTTP 200

-32600 batch too large

Поведение ethers.js по умолчанию

Зависание 5 минут

Мгновенное исключение

Мгновенное исключение

Время отклика на лимитном размере батча, сек.

0,85

3,1

0,75

Время отклика на одном батче в 10 запросов, сек.

0,85

0,6

0,1

Также, когда отправлял на свою ноду большой батч (более 5200 запросов), получал HTTP 413 Request Entity Too Large.

Решение 2 – батчинг от ethers

Стало понятно, что разные провайдеры по-разному отправляют ошибки и на них по-разному реагирует ethers. Начал думать, как можно разобраться со всем этим безобразием. Ведь не хочется, чтобы программа зависла на 5 минут, а также не хотелось писать свой код, чтобы разбивать запросы на батчи или что-то подобное. Начал смотреть, есть ли что-то на эту тему в ethers. Тестировал на Infura, тк самые строгие лимиты.

Оказывается, по умолчанию ethers делает батчинг запросов через JsonRpcApiProviderOptions, которые можно указать третьим параметром при создании JsonRpcProvider. Интересные параметры это:

  • batchStallTime – сколько времени (в мс) добавлять запросы в текущий батч (и потом отправлять его). По умолчанию 10 мс.

  • batchMaxSize – максимальный размер батч запроса в байтах (по умолчанию 1 МБ).

  • batchMaxCount – максимальное кол-во JSON-RPC запросов в одном HTTP батче (по умолчанию 100).

Думаю, вы уже догадались, что нужно сделать, чтобы Infura начала работать. batchMaxCount установить в 10 (макс число запросов в одном батче), и увеличить batchStallTime, чтобы реже бомбардировать сервер запросами. У меня получалось обработать 1000 кошельков с параметрами batchMaxCount: 10, batchStallTime от 100. Непредсказуемо, какое значение batchStallTime может вызвать throttling (429 код), нужно подбирать исходя из текущей ситуации / ноды, также оно зависит от других параллельных запросов. Время выполнения balanceOf на 1000 кошельков варьировалось от 2 до 60 секунд (немалый разбег!).

  const req = new ethers.FetchRequest(rpcUrl);

  req.timeout = 10000;
  req.setThrottleParams({
    maxAttempts: 12,
  });

  const provider = new ethers.JsonRpcProvider(req, undefined, {
    batchMaxCount: 10,
    batchStallTime: 100,
  });

  await Promise.all(
    wallets.map(async (wallet) => {
      const contract = new ethers.Contract(
        "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
        abi,
        provider
      );
      const bal = await contract.balanceOf(wallet);

      console.log(`Wallet: ${wallet}, Balance: ${bal}`);
    })
  );

Еще параметры, которые влияют на выполнение HTTP запросов, их можно менять через FetchRequest:

  • timeout (в мс) - по истечению этого параметра запрос генерируется исключение. По умолчанию он 300 000 (5 минут).

  • FetchThrottleParams

    • maxAttempts – максимальное количество попыток запроса к серверу в случае 429 ошибки (throttling) (по умолчанию 12). Если это 1, то HTTP запрос не повторяется в случае 429 ошибки. Интересно, что в ethers 6.11 был баг – этот параметр не учитывался, и только я полез сделать PR, как обнаружил, что на гитхабе это уже поправлено, и выйдет, видимо, в 6.12 :)

Думаю, стало понятно, почему в начале было зависание на примерно 5 минут и ошибки exceeded maximum retry limit. ethers пытался отправить батчи, получал 429, повторял еще раз, и так 12 раз с определенным backoff.

Выводы

Главный вывод – разные облачные сервисы имеют разные параметры (в платной версии будет то же самое), и, если вы разрабатываете серьезное приложение, нужно определиться с инфраструктурой и затачивать приложение под нее. Быстро менять поставщиков не получится, несмотря на, кажется, универсальный интерфейс и абстракцию в виде ethers.

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

Также, в приложении, вероятно, потребуется конфигурирование разных ethers провайдеров для разных юзкейсов, это тоже стоит учесть в архитектуре.

Конечно, self-hosted нода имеет самый лучшие времена отклика за счет размещения в своем датацентре, что может быть полезно для высоконагруженных систем, но косты ее поддержки довольно высоки.

Код на Github https://github.com/TechGeorgii/ethers.js-performance

Минутка PR. Веду тг‑канал Web3 разработчик. Пишу небольшие заметки (не часто) о задачах по блокчейну/крипте, которые решаю. Буду рад видеть среди подписчиков!

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


  1. Dmi1988
    17.04.2024 04:33

    Почему бы для начала из map не убрать Коннект к контракту?


    1. root85 Автор
      17.04.2024 04:33

      Вы имеете в виду вызов new ethers.Contract(...) ? Это конструктор, который инициализирует объект контракта, создает его свойства, но не обращается к RPC, поэтому картина не поменяется.


  1. vicsoftware
    17.04.2024 04:33

    "Путем сокращения количества кошельков было найдено ограничение в 10 JSON-RPC запросов в одном HTTP запросе на бесплатном плане Infura."

    У Infura ограничение на 10 запросов в течение, кажется, 10 секунд. Потом она начинает сыпать ошибками, пока временной период не закончится.


    1. root85 Автор
      17.04.2024 04:33

      Да, throttling тоже есть. Количество HTTP запросов в секунду не тестил, писал про ограничение в 10 JSON-RPC запросов в одном HTTP запросе (батче).


  1. pnaydanovgoo
    17.04.2024 04:33

    Стоит такую работу уносить на бекенд и пусть он там синхронизирует в фоне? А на клиента отдавать готовые данные. Или если нет бекенда в проекте, то использовать индексер, например тот же TheGraph.