Сайт, который показывает картинки прямо из блокчейна, не тащит их браузером напрямую: между страницей и цепью всегда стоит тонкий слой JavaScript. В массе старых туториалов этим слоем была библиотека Web3.js, и у неё непростая судьба. Линейка 4.x переписана на TypeScript, финальный релиз носит номер 4.16.0, а с 4 марта 2025 года репозиторий отправлен в архив: команда ChainSafe, поддерживавшая проект три года, официально передала эстафету библиотекам viem и ethers.js. Архив не значит "выключено": пакет по-прежнему ставится одной командой, код читает токены так же, как читал годами раньше, и тысячи работающих сайтов держатся именно на нём. Ниже разобрана полная цепочка: как страница находит кошелёк, почему чтение токена не тратит газ, как из адреса контракта получается картинка на экране и что ломается чаще всего.

Библиотека Web3.js в архиве с 2025 года и что это меняет для проектов

Формально Web3.js - это реализация протокола Ethereum JSON RPC на TypeScript: набор модулей, который разговаривает с узлом сети тем же языком, что и любой другой клиент. Четвёртая линейка переписана с нуля, рассчитана на Node 14 и новее, собирается под стандарт ES2020 и типизирована по всей глубине, что для интеграции во фронтенд-сборку значит больше, чем пара строк в changelog. Старую линейку 1.x с её монолитным пакетом поминают добрым словом: модульная структура 4.x позволила дереву зависимостей похудеть и подтянула покрытие тестами.

Статус проекта стоит выяснить до подключения, а не после. С 4 марта 2025 года библиотека официально архивирована: новых релизов не будет, чужие исправления в код не попадут, документация заморожена в последнем состоянии. Поддерживавшая проект три года команда ChainSafe прямо назвала преемников - активно развивающиеся библиотеки viem и ethers.js. Для уже работающих сайтов это не повод к ночному рефакторингу: код, который читал токены через 4.16.0, будет читать их и дальше, ведь Ethereum JSON RPC - стабильный протокол, а не движущаяся мишень. Для нового проекта выбор решается трезво: если команда уже знает Web3.js и витрину нужно собрать за выходные, цена ошибки невелика; если интеграция закладывается на годы, стартуют с живой библиотеки, а не с архива.

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

Подключение инжектированного кошелька и выбор сети по chainId

Провайдер появляется на странице через расширение кошелька, и контракт этого знакомства описан в EIP-1193: кошелёк кладёт в объект window.ethereum поле request, а сайт обязан спросить разрешение, а не хватать аккаунты молча. Библиотека подхватывает этот объект автоматически, и код получается коротким:


import { Web3 } from "web3";

const web3 = new Web3(window.ethereum); // провайдер, инжектированный по EIP-1193

async function connectWallet() {
  const accounts = await window.ethereum.request({
    method: "eth_requestAccounts" // запрос разрешения у пользователя
  });
  return accounts[0];
}

async function ensureChain() {
  const chainId = await window.ethereum.request({ method: "eth_chainId" });
  if (chainId !== "0xaa36a7") { // тестовая сеть Sepolia, номер 11155111
    try {
      await window.ethereum.request({
        method: "wallet_switchEthereumChain", // EIP-3326
        params: [{ chainId: "0xaa36a7" }]
      });
    } catch (err) {
      console.log(err.code); // 4902: сети нет в кошельке, нужен wallet_addEthereumChain
    }
  }
}

Два правила встроены прямо в этот фрагмент. Первое: аккаунты запрашиваются по клику на кнопку, а не при загрузке страницы, потому что автовыскакивающие окна разрешения приучают людей жать "Отклонить" не глядя. Второе: сеть сверяется по chainId, и если кошелёк смотрит в другую цепь, страница сначала просит переключиться, а при отсутствии сети добавляет её методом wallet_addEthereumChain из EIP-3085. Шестнадцатеричная запись "0xaa36a7" - это Sepolia с номером 11155111, отдельная тестовая цепь, не путать с основной сетью номер 1: контракт реально существует, но не там, и витрина выглядит пустой. Отказ пользователя - легальный исход с кодом 4001, корректная страница спокойно возвращается к состоянию до клика, а не краснеет экраном ошибки.

Чтение метаданных токена по стандартам ERC-721 и ERC-1155

Блокчейн хранит не картинку, а строку-указатель. По стандарту ERC-721 у каждого токена есть метод tokenURI, который возвращает адрес описания: почти всегда это JSON с полями name, description, image и attributes. Сам файл лежит вне цепи - в файловой сети IPFS или обычном хранилище, а в блокчейне закреплён только адрес. Отсюда двухтактная схема чтения: сначала запрос указателя в контракт, потом загрузка JSON по этому указателю. Для вызова нужен минимальный ABI - массив JSON-описаний методов, которые страница собирается дергать; тянуть полный ABI развёрнутого контракта не обязательно:


const abi = [
  {
    constant: true,
    inputs: [{ name: "tokenId", type: "uint256" }],
    name: "tokenURI",
    outputs: [{ name: "", type: "string" }],
    type: "function"
  }
];

const nft = new web3.eth.Contract(abi, COLLECTION_ADDRESS);

async function loadTokenMeta(tokenId) {
  let uri = await nft.methods.tokenURI(tokenId).call();
  if (uri.startsWith("ipfs://")) {
    uri = uri.replace("ipfs://", GATEWAY_PREFIX); // префикс шлюза живёт в конфиге
  }
  const res = await fetch(uri);
  return res.json(); // { name, description, image, attributes }
}

Строка вида ipfs://bafy... - адрес файловой сети, а не привычная ссылка, поэтому префикс меняется на адрес шлюза из конфигурации. У смежного стандарта ERC-1155 схема иначе: один контракт хранит тысячи токенов, и метод uri принимает идентификатор, а возвращает шаблон с подстановкой {id}, которую заменяют на номер токена в шестнадцатеричной записи длиной ровно 64 символа:


async function loadMeta1155(contract, id) {
  const template = await contract.methods.uri(id).call();
  const hexId = BigInt(id).toString(16).padStart(64, "0");
  const uri = template.replaceAll("{id}", hexId);
  const res = await fetch(uri);
  return res.json();
}

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

Проверка владения и вход по подписи сообщения без пароля

Метод balanceOf отвечает на вопрос, сколько токенов этой коллекции у адреса. Для ERC-721 это число от нуля и выше, для ERC-1155 вопрос уточняется идентификатором: balanceOf(owner, id). Дальше включается продуктовая логика: один токен открывает базовый раздел, три - закрытый, пять - ранний доступ к новинкам. Технически проверка умещается в один вызов:


const ERC721_ABI = [
  {
    constant: true,
    inputs: [{ name: "owner", type: "address" }],
    name: "balanceOf",
    outputs: [{ name: "", type: "uint256" }],
    type: "function"
  }
];

const gate = new web3.eth.Contract(ERC721_ABI, GATE_ADDRESS);
const amount = await gate.methods.balanceOf(account).call(); // BigInt, 0n если пусто
if (amount > 0n) {
  // закрытый раздел открыт
}

Второй сценарий - вход без пароля. Сервер выдаёт одноразовый код, кошелёк подписывает сообщение с этим кодом, сервер восстанавливает адрес из подписи и сверяет с заявленным. Приватный ключ не покидает кошелёк, пароли на сервере не хранятся вообще:


// клиент: получили код с сервера и подписали его кошельком
const nonce = await fetch("/api/nonce?address=" + account).then((r) => r.text());
const message = "Вход на сайт. Одноразовый код: " + nonce;
const signature = await web3.eth.personal.sign(message, account);

await fetch("/api/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ account, signature })
});

// сервер на Node.js: адрес поднимается из подписи математически
import { Web3 } from "web3";
const web3 = new Web3(); // сетевой провайдер не нужен

const recovered = web3.eth.accounts.recover(message, signature);
if (recovered.toLowerCase() === account.toLowerCase()) {
  // подпись совпала с адресом, выдаём сессию
}

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

События Transfer в реальном времени через WebSocketProvider

Витрина, которая перечитывает контракт каждые десять секунд, тратит запросы впустую: контракт умеет испускать события, а библиотека умеет их слушать. Событие Transfer по стандарту ERC-721 происходит при каждой смене владельца, и фильтр по получателю собирает поток "что появилось у этого адреса" без единого опроса:


const ws = new Web3(new Web3.providers.WebsocketProvider(WS_ENDPOINT));
const nftWs = new ws.eth.Contract(abi, COLLECTION_ADDRESS);

nftWs.events.Transfer({ fromBlock: "latest", filter: { to: account } })
  .on("data", (event) => {
    // event.returnValues.tokenId только что прибыл на кошелёк
    console.log("новый токен", event.returnValues.tokenId);
  })
  .on("error", console.error);

Здесь обязателен WebSocketProvider: обычный HTTP-запрос однонаправленный, а подписка держит открытое соединение. Есть и тонкость: событие из последнего блока может откатиться при реорганизации цепи, когда сеть переделывает верхушку. Критичные реакции ждут двух-трёх подтверждений, а витрина в браузере чаще вообще не нуждается в живом потоке и остаётся на редком eth_call: странице достаточно узнать о новом токене на минуту позже.

Лимиты публичных RPC и отказоустойчивое чтение витрины

Бесплатные публичные узлы ограничивают частоту запросов, и витрина из 500 токенов, прочитанных подряд в цикле, ловит отказ с кодом 429 раньше, чем загрузится вторая сотня. Лечение процедурное: метаданные кэшируются на стороне сервера хотя бы на сутки, идентификаторы читаются страницами, а не всё сразу, а каждый сетевой вызов оборачивается в повтор с экспоненциальной задержкой.

Второй пласт - арифметика. В линейке 4.x большие числа приходят как BigInt: баланс хранится в вей, у единицы эфира 18 знаков после запятой, и попытка вывести его в лоб рождает строку из девятнадцати цифр:


const balance = await web3.eth.getBalance(account); // BigInt в вей
const ether = Web3.utils.fromWei(balance, "ether");
const pretty = Number(ether).toFixed(3); // 1.500 вместо 1500000000000000000

Третий пласт - адреса. Один и тот же адрес записывается в разном регистре, и сравнение строк без приведения к нижнему регистру ломает проверку владения: утилита toChecksumAddress даёт канонический вид с контрольной суммой, а все сравнения в коде делают в lowercase. Четвёртый - время: у вызова в браузере нет встроенного таймаута, зависший запрос подвешивает интерфейс, поэтому вокруг любого обращения к сети ставят гонку с таймером и понятную деградацию: скрытый блок вместо красной ошибки.

Чек-лист внедрения блокчейн-контента на живом сайте

Перед выкатом витрины в бой короткий чек-лист экономит часы отладки:

  1. Проверить сеть: chainId из кошелька равен ожидаемому, иначе предложить переключение;
  2. Запросить аккаунты по кнопке, а не при загрузке страницы;
  3. Закэшировать метаданные токенов на сутки и читать идентификаторы страницами;
  4. Обернуть каждый вызов RPC в таймаут и повтор с экспоненциальной задержкой;
  5. Прогнать сценарий отказа: кошелёк не установлен, сеть недоступна, подпись отменена.

Механика, описанная выше, переживёт саму библиотеку: EIP-1193, chainId, tokenURI и подписи - свойства сети Ethereum, а не конкретного npm-пакета. Миграция на viem или ethers.js переписывает вызовы, но не логику: та же кнопка подключения, тот же указатель на JSON, та же подпись с одноразовым кодом. Архивное состояние Web3.js лишь напоминает инженерам простую истину: сайт зависит от стандартов, а пакеты - расходный материал, и слой связи с цепью стоит проектировать так, чтобы его замена не трогала саму витрину.