Перейти к содержанию

Solidity и EVM: смарт-контракт реестра публикаций

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

Наша цель — понять границу между сайтом и контрактом. Начнём с EVM, создадим небольшой реестр и разберём, что должен делать окружающий его интерфейс. Для примера выбран Solidity 0.8.30, тот же учебный профиль, который используется в серии о сертификатах. Это зафиксированная версия примера; выбор компилятора для реального выпуска требует отдельного решения.

Как исполняется контракт

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

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

Транзакция задаёт действие, которое предлагается сети. Чтение через eth_call даёт результат исполнения на выбранном состоянии без публикации новой транзакции. Это объясняет распространённое различие: показать текущий курс можно без оплаты сетевой комиссии со стороны читателя, а записать новый выпуск обычно требует транзакции. Само вычисление чтения всё равно использует ресурсы узла, и RPC-провайдер может ограничивать число запросов.

Газ измеряет работу EVM. Сумма комиссии связана с расходом газа и условиями сети. Лимит газа — верхняя граница работы, разрешённой транзакции, а не её окончательная цена. Если исполнение откатывается, изменения состояния отменяются, но уже затраченная работа может оплачиваться. Надпись «ошибка» в интерфейсе поэтому не означает, что комиссия обязательно равна нулю.

Современный Ethereum использует proof of stake. Старое объяснение майнинга Ethereum полезно как история протокола, но не подходит для описания сегодняшнего подтверждения транзакций. Также различаются включение в блок, последующие подтверждения и финальность: это отдельные наблюдения, а не одно событие «отправлено».

Что будет хранить библиотека

Выберем простую модель. Каждый курс имеет целочисленный номер. Редактор фиксирует 32-байтовый отпечаток конкретного выпуска. Он может заменить выпуск и деактивировать курс. Содержимое доступно через сайт или распределённое хранилище, а контракт отвечает лишь на вопрос, какой отпечаток указан издателем сейчас.

Значение Тип Зачем оно нужно
Номер курса uint256 Стабильный ключ реестра
Отпечаток выпуска bytes32 Сопоставление с определённым содержимым
Доступность bool Возможность отключить запись
Адрес издателя address Проверка полномочий на изменение

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

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

Полный небольшой контракт

Создадим файл CourseCatalog.sol. Он не принимает платежи и не вызывает чужие контракты; такая граница помогает сначала разобраться с состоянием и разрешениями.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

contract CourseCatalog {
    error Unauthorized();
    error InvalidCourse();
    error NotActive();

    struct Course {
        bytes32 contentHash;
        bool active;
    }

    address public immutable publisher;
    mapping(uint256 => Course) public courses;

    event Published(uint256 indexed courseId, bytes32 contentHash);
    event Deactivated(uint256 indexed courseId);

    constructor(address publisher_) {
        if (publisher_ == address(0)) revert Unauthorized();
        publisher = publisher_;
    }

    modifier onlyPublisher() {
        if (msg.sender != publisher) revert Unauthorized();
        _;
    }

    function publish(uint256 courseId, bytes32 contentHash)
        external
        onlyPublisher
    {
        if (courseId == 0 || contentHash == bytes32(0)) {
            revert InvalidCourse();
        }
        courses[courseId] = Course(contentHash, true);
        emit Published(courseId, contentHash);
    }

    function deactivate(uint256 courseId) external onlyPublisher {
        if (!courses[courseId].active) revert NotActive();
        courses[courseId].active = false;
        emit Deactivated(courseId);
    }
}

Публичный mapping получает функцию чтения автоматически: запрос courses(17) возвращает отпечаток и флаг для курса 17. Он не превращается в функцию записи для любого пользователя. Слово public определяет доступность чтения через интерфейс контракта; изменение по-прежнему проходит через publish и проверку отправителя.

immutable означает, что адрес издателя определяется при создании экземпляра и затем не меняется обычными вызовами этого контракта. Это намеренное ограничение первого примера. Если ключ издателя потерян, встроенного восстановления здесь нет. Если требуется передача полномочий, её нужно отдельно спроектировать, проверить и объяснить пользователю.

В onlyPublisher сначала проверяется msg.sender, затем символ _ обозначает место исполнения тела функции. Если пришёл другой адрес, возникает собственная ошибка и тело не выполняется. Для проверки прав используем непосредственного отправителя вызова; попытка заменить его на tx.origin создаёт другую, опасную модель полномочий.

Вызов publish полностью заменяет запись. Уже существующий курс получает новый отпечаток и снова становится активным. Такая возможность полезна для исправленного выпуска, но она должна быть явной частью интерфейса: пользователь не должен принять изменяемый указатель за вечную гарантию неизменности содержимого.

Состояние, временные данные и ABI

Постоянные поля живут в storage. Временные значения могут находиться в memory, а параметры внешнего вызова — в calldata, если для их типа выбрана эта область. В нашем коротком примере аргументы являются типами значений. Когда появятся массивы и строки, расположение данных станет важной частью сигнатур и стоимости работы.

Особенно внимательно нужно относиться к ссылкам на структуры. Переменная Course storage item = courses[id] указывает на постоянную запись: изменение item.active меняет состояние. Копия в memory не имеет того же эффекта. Непонимание этой разницы может дать контракт, который выглядит исправным, но изменяет только временную копию.

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

События Published и Deactivated помогают внешней системе строить историю. Они не заменяют постоянные поля: текущий флаг читается из состояния, история выпусков — из журнала событий. В событиях выбран индексированный номер курса, чтобы облегчить поиск. Это не разрешение на бесконечный запрос всей истории одним RPC-вызовом; индексатор должен читать ограниченные диапазоны блоков.

Первое знакомство через Remix

Для ручного разбора можно использовать Remix VM — учебную цепочку в браузере. Сначала создаётся файл контракта, затем в Solidity Compiler выбираются версия 0.8.30 и совместимая целевая EVM. В Deploy & Run выбирается именно Remix VM; подключение браузерного кошелька относится к другому маршруту. В аргумент конструктора передаётся выбранный учебный адрес издателя, а значение отправляемого ETH остаётся нулевым.

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

Разберём ожидаемый результат

Предположим, издатель создал экземпляр, а у подготовленного выпуска есть ненулевой отпечаток H1. Сначала чтение отсутствующего курса 17 даёт нулевой отпечаток и false: стандартные значения mapping. После publish(17, H1) ожидаются H1, true и событие публикации. После деактивации отпечаток остаётся H1, а флаг становится false.

Действие Ожидаемое поведение
Читатель публикует курс Откат с Unauthorized
Издатель публикует номер 0 Откат с InvalidCourse
Издатель передаёт нулевой отпечаток Откат с InvalidCourse
Издатель отключает активный курс Флаг меняется, отпечаток сохраняется
Издатель отключает его повторно Откат с NotActive
Издатель публикует новый выпуск Новый отпечаток, активный флаг

Эта таблица фиксирует требования к будущим проверкам. Компиляция и исполнение примера при подготовке материала не выполнялись. Для лаборатории сначала нужен отдельный локальный экземпляр; учебный реестр нельзя незаметно подменять адресом действующего контракта.

Когда понадобятся библиотека прав и токены

Один издатель подходит первому упражнению. В редакции могут появиться автор, редактор и администратор. Для такой модели применяют явные роли: кто публикует, кто отзывает, кто назначает следующих редакторов. В OpenZeppelin Contracts 5.x следует сверять текущие конструкторы и механизмы администрирования; старое упражнение с Ownable нельзя переносить без учёта аргумента первоначального владельца. Передача владельца в два этапа помогает проверить, что новый адрес действительно готов принять права. Документация OpenZeppelin.

ERC-20 описывает взаимозаменяемые единицы, ERC-721 — индивидуальные токены, ERC-1155 — несколько видов единиц в одном контракте. Эти стандарты решают разные задачи представления и передачи. Покупатель токена не получает автоматически юридическое право на текст, а владение NFT не скрывает закрытые материалы от человека, которому уже известен публичный URL. Для учебной библиотеки токен нужен лишь при определённом требовании; обычный реестр публикаций не обязан становиться NFT-проектом.

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

Последняя важная граница — обновление кода. Новый адрес, миграция реестра и прокси являются разными способами выпуска. Прокси добавляет права на обновление и требования к расположению состояния; он не делает изменения бесплатными или автоматически безопасными. Для первого курса выберем обычный экземпляр и явный новый выпуск. Дальше подготовим проверки его поведения, а затем свяжем реестр с браузером.

Подробности языка можно сверять с руководством Solidity 0.8.30, описанием типов и областей данных и спецификацией ABI. Общий порядок изучения собран в маршруте Bitcoin, Solidity и Web3.