Розроблення смарт-контрактів на 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-смислі — не socket 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 вважає газ операційно. 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 |
Frontend-інтеграція |
Тестуємо на трьох рівнях:
-
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.







