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

Что именно хранит смарт-контракт когда сайт называют децентрализованным

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

Смарт-контракт в этой конструкции выполняет роль реестра и правил публикации. Он хранит не страницы, а указатель: текущий идентификатор контента, историю версий и условие, кто вправе публиковать новую версию. Браузер читает контракт, получает идентификатор, тянет файлы из сети и рендерит страницу. Контракт нельзя выключить, отредактировать задним числом или тихо переписать: код разворачивается один раз, и любая правка - это новая транзакция, которая остаётся в истории навсегда.

Практический вывод скромнее, чем кажется на слух. Подход годится для витрин, документации, страниц с публичной историей изменений и любых материалов, где ценна доказуемая неизменность. Не годится он для динамических сервисов с базой на миллионы строк, пользовательским вводом в реальном времени и тяжёлыми медиа: хранилище цепи под такие задачи не создавалось.

Ещё два свойства достаются конструкции бесплатно. Первое - открытость: байткод контракта публичен, исходник верифицируется рядом с ним, и любой инженер может убедиться, что реестр делает ровно то, что заявлено, не веря словам. Второе - судьба прав на публикацию: их можно передать другому контракту, другому владельцу или намеренно обнулить, после чего обновления станут невозможны в принципе, и такой исход тоже выбирают осознанно.

Анатомия цепочки от имени сайта до файла который отдаёт шлюз

Путь запроса складывается из четырёх звеньев: имя, реестр, идентификатор контента, файловая сеть. Человекочитаемое имя вида site.eth через сервис ENS резолвится в поле contenthash, где лежит указатель на актуальную версию. Указатель - это CID, идентификатор контента IPFS. Версия CIDv0 начинается с пары букв Qm, занимает 46 символов в кодировке base58 и неявно предполагает хеш sha2-256 длиной 32 байта. Версия CIDv1 кодируется base32, начинается с буквы b и описывает себя явно: версия, кодек, алгоритм и длина хеша упакованы в сам идентификатор. Префикс подсказывает тип содержимого даже без декодера: идентификаторы одиночных файлов начинаются с bafkrei, обёртки каталогов - с bafybei, и глаз быстро учится их различать.

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

Файловая сеть отдаёт контент через узлы, сохранившие у себя копию. Чтобы файлы не пропали, узлы держат их добровольно или по договору пиннинга. Есть и сети с другой моделью: протокол Arweave рассчитан на бессрочное хранение после однократной платы за запись. Через публичные шлюзы страницу открывает любой браузер, а клиенты с встроенной поддержкой сети ходят в неё напрямую и обходят шлюз стороной.

Минимальный контракт реестра контента на Solidity и его публичный интерфейс

Контракт, который решает всю задачу реестра, умещается в несколько десятков строк. Язык Solidity линейки 0.8 компилируется в байткод виртуальной машины EVM:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SiteRegistry {
    address public owner;
    mapping(uint256 => bytes32) public content;
    uint256 public latest;

    event Published(uint256 indexed version, bytes32 cid);

    constructor() {
        owner = msg.sender;
    }

    function publish(uint256 version, bytes32 cid) external {
        require(msg.sender == owner, "not the owner");
        content[version] = cid;
        latest = version;
        emit Published(version, cid);
    }
}

Разбор по сущностям короткий. Отображение mapping сопоставляет номеру версии 32-байтовый указатель. Функция publish открыта для всех, но строка require сверяет отправителя с сохранённым владельцем, поэтому чужая транзакция отвалится с ошибкой и не изменит состояние. Событие Published попадает в логи транзакции, и клиенты узнают о новой версии без перебора хранилища. Вызов функции адресуется селектором - первыми четырьмя байтами от keccak256 подписи вида publish(uint256,bytes32), поэтому имя функции и типы аргументов входят в бинарный протокол буквально.

Тип bytes32 выбран не из вкуса: хеш из идентификатора CIDv0 ложится в ячейку хранилища без упаковки. Держать в реестре полный идентификатор строкой дороже - газ уйдёт и на длину, и на каждое изменение. Компактная запись из тридцати двух байт дешевле в хранении и достаточна для проверки целостности.

Рядом с развёрнутым байткодом живёт ABI - JSON-описание функций и событий, по которому библиотека собирает вызовы и разбирает ответы. Именно поэтому клиентский код не знает про селекторы: обёртка Contract прячет эту механику внутри. Подписка на событие укладывается в одну строку:

registry.on('Published', (version, cid) => loadVersion(cid));

Хранилище EVM по слотам и настоящая цена одной записи

Хранилище контракта - таблица из 32-байтовых слотов, ключом служит вычисленный адрес слота. Запись в пустой слот стоит 20 000 газа, перезапись заполненного дешевле - 5 000, первое касание слота в транзакции добавляет 2 100, повторное чтение в той же транзакции - 100. Транзакция стартует с базовой цены 21 000 газа, каллдата тарифицируется побайтно: 16 газа за ненулевой байт и 4 за нулевой. Размещение собственного байткода тарифицируется отдельно, 200 газа за ненулевой байт кода.

На этих числах можно считать без калькулятора. Строка из ста ненулевых байт в каллдате обойдётся в 1 600 газа. HTML-страница на десять килобайт, почти целиком из ненулевых байт, потянет примерно 163 840 газа только за данные, не считая базовой цены и обработки. Целевой размер блока равен половине лимита, лимит ограничен протоколом, и любой контракт расходует из этого общего бюджета. Вот почему в цепи не хранят сайты целиком: каталог из сотни файлов забил бы бюджет блока и сделал публикацию непрактичной. В реестре лежат десятки байт, а не мегабайты.

Реальная запись версии в такой реестр стоит скромных единиц газа. Прикинем на салфетке: базовая цена транзакции 21 000, каллдата вызова с двумя аргументами около тысячи, чтение слота владельца 2 100, запись указателя версии в пустой слот 22 100, обновление счётчика версий 7 100, лог события добавляет остаток. Порядок величины - пятьдесят-шестьдесят тысяч газа, то есть доли процента бюджета блока. Полный HTML в той же транзакции умножил бы счёт в десятки раз.

Блобы для больших объёмов и почему они не годятся для вечного хранения

В марте 2024 года сеть Ethereum включила предложение EIP-4844 из обновления Dencun, и появились транзакции с блобами - вложениями по 128 килобайт, которые не занимают обычное хранилище, а распространяются параллельно основному блоку. Целевой объём задали в три блоба на блок при потолке в шесть, позже планку подняли до шести целевых и девяти максимальных. Блобы резко удешевили выгрузку данных в сети второго уровня, публикующие доказательства в основной цепи.

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

Чтение реестра из браузера через ethers.js без расхода газа

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

import { BrowserProvider, Contract } from 'ethers';

const provider = new BrowserProvider(window.ethereum);
const registry = new Contract(registryAddress, abi, provider);

const version = await registry.latest();
const cidHex = await registry.content(version);

Для страниц, которым нужна только правда о текущей версии, хватает публичного RPC через JsonRpcProvider: ключи не нужны, кошелёк не запрашивается. BrowserProvider подключается там, где посетитель подписывает транзакцию публикации. Разделение читающего и пишущего провайдера - обычная гигиена: чтение живёт на статической странице, запись - в закрытой панели владельца.

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

Клиент может не опрашивать реестр, а подписаться на событие Published: новые версии прилетают сами, и интерфейсу достаточно перерисовать ссылку на свежую копию файлов.

Пайплайн публикации и проверка неизменяемости на локальной ноде

Публикация версии - это конвейер, и каждый шаг отрабатывают локально до отправки в основную сеть. Инструменты Foundry дают сборку, тесты и собственную ноду:

forge build && forge test
ipfs add -r ./dist

Первая команда собирает контракт и прогоняет тесты, вторая упаковывает каталог сборки в файловую сеть и печатает идентификатор. Развёртывание уходит отдельной командой, ключ читается из переменной окружения и не попадает в репозиторий:

forge create src/SiteRegistry.sol:SiteRegistry --rpc-url "$RPC" --private-key "$DEPLOY_KEY"

Дальше цикл фиксируют в сценарии:

  1. собрать статический сайт и получить из сборки каталог файлов для публикации;
  2. добавить каталог в IPFS и зафиксировать вернувшийся идентификатор как адрес версии;
  3. развернуть контракт реестра один раз и записать его адрес в конфигурацию фронтенда;
  4. отправить транзакцию publish с новым идентификатором и подписью владельца;
  5. прочитать реестр на локальной ноде и убедиться, что указатель ведёт на рабочую версию.

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

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