Разработка смарт-контрактов на Rust (Solana)
Solana привлекает разработчиков субсекундными финальностями и стоимостью транзакции в $0.00025. Но за этой скоростью стоит принципиально другая модель исполнения: accounts model вместо contract storage, stateless programs, PDA-дериваты вместо mapping. Разработчик, пришедший с EVM, первые две недели думает, что Solana сломана — потому что привычные паттерны Solidity здесь либо не работают, либо приводят к уязвимостям другого класса.
Почему Solana-программы ломают EVM-разработчика
В EVM контракт хранит своё состояние внутри себя. В Solana программа — это stateless executable, а данные живут в отдельных account-ах, которые программа не владеет — она только авторизует операции над ними. Это означает, что каждый вызов инструкции требует явной передачи всех 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 и проверяет его при десериализации — это автоматически закрывает целый класс 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>,
}
Constraint-ы в #[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 (Ackee Blockchain's fuzzer). Он генерирует случайные последовательности инструкций и ищет panics, unexpected account state, integer overflow. На одном из проектов Trident за 4 часа нашёл сценарий, где последовательность init → close → reinit приводила к повторной инициализации account с чужими данными — Anchor discriminator при этом проходил, потому что reinit использовал тот же тип.
Оптимизация compute units
Solana ограничивает каждую транзакцию в 1.4M compute units по умолчанию (запросить можно до 1.4M через SetComputeUnitLimit). Serialization/deserialization через Borsh — дорогое удовольствие для больших структур.
Практика: разбиваем крупные state структуры на несколько account-ов. Вместо одного ProgramState с 50 полями — несколько специализированных account-ов. Меньше данных десериализуется на каждый вызов — меньше CU расходуется.
Второй приём: zero_copy аккаунты через #[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-цепочками.







