Разработка смарт-контрактов на Ink! (Polkadot)
Substrate-цепи — это не EVM. Разработчики, которые приходят в экосистему Polkadot с опытом Solidity, первым делом натыкаются на принципиально другую модель исполнения: WebAssembly-рантайм вместо EVM, Rust вместо Solidity, и cargo-contract вместо Hardhat. Ink! — это embedded DSL поверх Rust, который компилируется в Wasm и разворачивается на паллете pallet-contracts. Переносить ментальные модели из EVM напрямую сюда опасно.
Чем Ink! принципиально отличается от Solidity
Первое, что режет глаз — модель хранилища. В Solidity mapping(address => uint256) — это просто слот в storage с keccak256-ключом. В Ink! каждое поле #[ink(storage)] транслируется в отдельные Lazy-записи в дереве Merkel storage Substrate. Это означает:
- Нет понятия «слот» в EVM-смысле — нет slot packing
- Доступ к
Mapping<AccountId, Balance>— этоgetиз off-chain state, не арифметика над 32-байтовым словом -
StorageVecв Ink! 5.x ленив по умолчанию: элементы загружаются только при явном чтении
Второе принципиальное отличие — модель вызовов. В EVM msg.sender — всегда непосредственный вызывающий. В Ink! self.env().caller() возвращает предыдущий вызывающий в цепочке. Reentrancy в Ink! физически отключён по умолчанию через ReentrancyGuard на уровне среды исполнения, если не передан флаг --allow-reentrant-calls явно. Но это не значит, что можно расслабиться — cross-contract вызовы с CallBuilder всё ещё требуют аккуратного управления состоянием.
Третья особенность — жизненный цикл контракта. Ink! поддерживает #[ink(message, payable)] для приёма нативного токена, #[ink(constructor)] для инициализации, и — уникально для Polkadot-экосистемы — set_code_hash() для обновления кода контракта без смены адреса. Это аналог UUPS proxy из EVM-мира, но встроенный в протокол.
Типичные ошибки при написании Ink!-контрактов
Неправильное использование Mapping vs StorageHashMap
В Ink! 4.x был ink::storage::Mapping, который не реализует итерацию по ключам (это сделано намеренно — off-chain индексирование через события, не on-chain). Разработчики, привыкшие к EnumerableMap из OpenZeppelin, начинают хранить ключи в Vec<AccountId> рядом с Mapping, и это ломается при попытке масштабирования: Vec загружается целиком при каждом чтении, что делает вызов O(n) по gas weight.
Правильное решение — индексировать через ink::env::emit_event! и строить off-chain state через Subsquid или SubQuery. Не пытаться воссоздать on-chain итерируемые структуры.
Weight vs Gas: принципиально другая модель стоимости
EVM считает gas операционно. Substrate считает weight — это двумерный ресурс: ref_time (наносекунды CPU) и proof_size (байты доказательства для light client). При деплое через cargo-contract нужно явно указывать --gas-limit в weight-единицах, или использовать dry_run для оценки.
Паттерн, который регулярно приводит к проблемам: разработчик делает cargo-contract call без предварительного dry_run, контракт падает с OutOfGas, и команда начинает гадать, что не так — хотя достаточно было запустить:
cargo contract call --dry-run --contract <address> --message transfer --args <args>
Обновление через set_code_hash
Ink! позволяет обновить код контракта через self.env().set_code_hash(&new_code_hash). Но storage layout нового кода должен быть совместим со старым. Если поменять порядок полей в #[ink(storage)] — данные в хранилище будут читаться неверно. В EVM proxy-паттерне это называется storage collision — здесь та же проблема, но без bytecode-level инструментов для проверки.
Мы используем ink_storage_traits::StorageLayout derive-макрос и вручную проверяем storage layout через cargo-contract info --output-json перед каждым апгрейдом.
Как мы строим Ink!-проекты
Стек и инструменты
| Инструмент | Роль |
|---|---|
cargo-contract 4.x |
Компиляция, деплой, вызовы |
substrate-contracts-node |
Локальная нода для разработки |
drink! |
Unit-тестирование без ноды (mock runtime) |
openbrush |
Библиотека стандартов (PSP22, PSP34) |
| Subsquid | Индексирование событий контракта |
polkadot.js API |
Фронтенд-интеграция |
Тестируем на трёх уровнях:
-
Unit-тесты через
#[ink::test]— синхронный, mock-окружение, быстро -
Integration-тесты через
drink!— реальный runtime Substrate без сети, можно тестировать cross-contract вызовы -
E2E-тесты на
substrate-contracts-node— полный стек с реальными транзакциями
Стандарты токенов в экосистеме Polkadot
PSP22 — аналог ERC-20. PSP34 — аналог ERC-721. Оба стандарта реализованы через openbrush, который предоставляет trait-based систему расширений — похоже на миксины в Solidity через diamond proxy, но без selector clashing проблем, потому что Ink! использует blake2b-хэши для диспетчеризации сообщений.
Одна тонкость: PSP22::transfer принимает data: Vec<u8> — это хук для получателя (аналог ERC-777 hook). Если контракт-получатель реализует PSP22Receiver, он может отреагировать на входящий перевод. Если не реализует — вызов всё равно проходит. Это отличается от ERC-777, где tokensReceived обязателен для контрактных адресов.
Процесс работы
Аналитика. Изучаем целевую Substrate-цепь: какая версия pallet-contracts, есть ли кастомные chain extensions, какой нативный токен, нужна ли интеграция с XCM для кросс-чейн вызовов.
Проектирование. Определяем storage layout (изменить после деплоя без миграции нельзя), события для индексирования, message-интерфейс. На этом этапе закладывается возможность апгрейда через set_code_hash — если нужна.
Разработка. Пишем контракт с тестами на drink!. Покрытие логики — 90%+. Cross-contract взаимодействия тестируем отдельно на substrate-contracts-node.
Аудит и деплой. Статический анализ через cargo clippy + ручной просмотр критических путей. Деплой на тестнет (Rococo Contracts), верификация через polkadot.js Apps.
Ориентиры по срокам
Простой контракт (PSP22 токен, 1-2 кастомных сообщения): 3-5 дней включая тесты. Контракт средней сложности с cross-contract вызовами и апгрейдом: 1-2 недели. Сложный протокол с XCM-интеграцией и кастомными chain extensions: от 1 месяца.
Конкретные сроки зависят от целевой цепи — на контрактных парачейнах типа Astar или Shiden могут быть свои особенности конфигурации pallet-contracts.







