Сайт, который показывает картинки прямо из блокчейна, не тащит их браузером напрямую: между страницей и цепью всегда стоит тонкий слой 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. Четвёртый - время: у вызова в браузере нет встроенного таймаута, зависший запрос подвешивает интерфейс, поэтому вокруг любого обращения к сети ставят гонку с таймером и понятную деградацию: скрытый блок вместо красной ошибки.
Чек-лист внедрения блокчейн-контента на живом сайте
Перед выкатом витрины в бой короткий чек-лист экономит часы отладки:
- Проверить сеть: chainId из кошелька равен ожидаемому, иначе предложить переключение;
- Запросить аккаунты по кнопке, а не при загрузке страницы;
- Закэшировать метаданные токенов на сутки и читать идентификаторы страницами;
- Обернуть каждый вызов RPC в таймаут и повтор с экспоненциальной задержкой;
- Прогнать сценарий отказа: кошелёк не установлен, сеть недоступна, подпись отменена.
Механика, описанная выше, переживёт саму библиотеку: EIP-1193, chainId, tokenURI и подписи - свойства сети Ethereum, а не конкретного npm-пакета. Миграция на viem или ethers.js переписывает вызовы, но не логику: та же кнопка подключения, тот же указатель на JSON, та же подпись с одноразовым кодом. Архивное состояние Web3.js лишь напоминает инженерам простую истину: сайт зависит от стандартов, а пакеты - расходный материал, и слой связи с цепью стоит проектировать так, чтобы его замена не трогала саму витрину.