Розробка смарт-контрактів на Rust (Solana)
Solana привертає розробників субсекундною фінальністю та вартістю транзакції $0.00025. Але за цією швидкістю стоїть принципово інша модель виконання: accounts model замість contract storage, stateless programs, PDA-дериватів замість mapping. Розробник, який прийшов з EVM, перші два тижні думає, що Solana сломана — тому що звичні паттерни Solidity тут або не працюють, або приводять до уразливостей іншого класу.
Чому Solana-програми ламають EVM-розробника
У EVM контракт зберігає свій стан всередині себе. У Solana програма — це stateless executable, а дані живуть в окремих accounts, які програма не володіє — вона тільки авторизує операції над ними. Це означає, що кожний виклик інструкції вимагає явної передачі всіх accounts, які будуть задіяні.
Перша грабля: missing signer check. Програма отримує account через AccountInfo, але не перевіряє, що переданий authority дійсно підписав транзакцію. У Anchor це ловиться через атрибут #[account(signer)] або тип Signer<'info>. У нативному Rust — через явну перевірку if !authority.is_signer { return Err(...) }. Легко пропустити, коли пишеш перші програми.
Друга класична уразливість — account substitution. Програма приймає token_account та authority, але не перевіряє, що token_account.owner збігається з переданим authority. Атакуючий передає свій token_account та чужого authority — програма виконується без помилок та дренує чужі токени.
Третя біль — PDA derivation mismatch. Program Derived Address обчислюється з seeds + program_id. Якщо seeds не перевірені явно через find_program_address з constraint в Anchor (seeds = [b"vault", user.key().as_ref()]), атакуючий може передати довільний account, який випадково збігається з PDA по адресі, але не є легітимним.
Як Anchor змінює рівняння безпеки
Працюємо переважно через Anchor framework (поточна версія 0.30.x). Anchor генерує discriminator для кожного типу account та перевіряє його при десеріалізації — це автоматично закриває цілий клас type confusion атак, де атакуючий передає account не того типу.
Типова структура інструкції в нашій кодовій базі:
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump,
constraint = vault.authority == user.key() @ ErrorCode::Unauthorized
)]
pub vault: Account<'info, VaultState>,
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = user
)]
pub user_token_account: Account<'info, TokenAccount>,
pub user: Signer<'info>,
pub mint: Account<'info, Mint>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
Constraints у #[account(...)] — це не просто синтаксичний цукор. Вони компілюються у явні перевірки перед виконанням інструкції. Якщо constraint порушено — транзакція откатується до того, як логіка інструкції виконалася.
Тестування: Bankrun vs Localnet
Для більшості unit-тестів використовуємо solana-bankrun — він поднімає синтетичний runtime у пам'яті без запуску validator-а. Тест, який на localnet займав би 3 секунди (400мс на слот), виконується за 50мс. Критично для fuzzing.
Інтеграційні тесты — на localnet через anchor test. Додаємо --skip-local-validator, де тестовий сценарій вимагає реальних програм (Associated Token Program, Metaplex) — клонуємо їх стан з mainnet через --clone.
Для фаззингу SPL-програм використовуємо Trident (fuzzer від Ackee Blockchain). Він генерує випадкові послідовності інструкцій та шукає panics, несподіване стан account, integer overflow. На одному проекті Trident за 4 години знайшла сценарій, де init → close → reinit приводив до повторної ініціалізації з чужими даними — Anchor discriminator при цьому проходив, тому що reinit використав той же тип.
Оптимізація: Compute Units
Solana обмежує кожну транзакцію до 1.4M compute units за замовчуванням (можна запросити до 1.4M через SetComputeUnitLimit). Серіалізація/десеріалізація через Borsh — дороговизна для великих структур.
Практика: розбиваємо великі state структури на кілька accounts. Замість одного ProgramState з 50 полями — кілька спеціалізованих accounts. Менше дані десеріалізуються на кожен виклик — менше CU витрачається.
Другий прийом: zero_copy accounts через #[account(zero_copy)] у Anchor. Дані читаються прямо з пам'яті без Borsh-десеріалізації. На структурах >1KB економія 30-50% CU.
| Підхід | CU на десеріалізацію 1KB | Мутабельність |
|---|---|---|
| Стандартний Borsh | ~8000 CU | Повна |
| zero_copy (bytemuck) | ~500 CU | Обмежена (repr(C)) |
Процес розробки
Аналітика та проектування (2-5 днів). Розбираємо accounts model під завдання: які PDA потрібні, які seeds, де потрібен CPI до Token Program або Associated Token Program. Проектуємо state до написання коду — переробка accounts структури на етапі тестування дорога.
Розробка (3-10 днів залежно від складності). Anchor + Rust stable. Покриваємо кожну інструкцію тестами через Bankrun. Складні сценарії (PDA lifecycle, CPI chains) — на localnet.
Security review. Прогоняємо через Soteria (статичний аналіз Solana-програм) та ручний review за чеклістом: missing signer, ownership checks, PDA validation, integer arithmetic (використовуємо checked_add, checked_mul скрізь).
Деплой. anchor deploy з multisig upgrade authority (Squads Protocol). Upgrade authority не повинна бути EOA — якщо приватник витече, програму можна переписати.
Терміни: 3-5 днів для стандартного SPL-сумісного контракту, до 3 тижнів для складного протоколу з кількома програмами та CPI-ланцюжками.







