Программы на Rust: точка входа, инструкции, аккаунты
Теорию про аккаунты мы разобрали, теперь напишем программу целиком, без единого фреймворка. Anchor из следующего урока многое спрячет за макросами, но чтобы понимать, что он прячет, сначала сделаем это руками. Возьмём задачу проще некуда: счётчик в аккаунте, который умеет обнулиться и прибавить единицу. На нём виден весь скелет любой Solana-программы.
Идея: у всех программ один скелет
Что бы программа ни делала, снаружи она выглядит одинаково. Рантайм зовёт одну функцию и передаёт ей три вещи:
program_id: адрес самой программы;accounts: массив аккаунтов, которые транзакция объявила (помнишь, из урока 32: объявить надо заранее);instruction_data: сырые байты, из которых программа поймёт, что от неё хотят.
Дальше всегда одно и то же: разобрать байты в команду, match по команде, в каждой ветке проверить аккаунты и права, поменять данные. Соберём это по слоям.
Скелет проекта: один Cargo.toml на программу и тесты
Сначала каркас, чтобы всё из этого урока компилировалось у тебя, а не только на экране. Вот Cargo.toml целиком:
[package]
name = "counter"
version = "0.1.0"
edition = "2021"
[lib]
name = "counter"
crate-type = ["cdylib", "lib"]
[features]
# Убрать точку входа при сборке под обычный компьютер (тесты, клиенты).
no-entrypoint = []
[lints.rust]
# Макрос entrypoint! ссылается на cfg сборки под саму цепь (target_os = "solana"
# и внутренние feature-флаги), которых на обычном компьютере нет. На host это
# ожидаемо, поэтому глушим шум от check-cfg.
unexpected_cfgs = { level = "allow" }
[dependencies]
solana-program = "2.1"
borsh = "1.5"
[dev-dependencies]
solana-program-test = "2.1"
solana-sdk = "2.1"
tokio = { version = "1", features = ["macros", "rt"] }
Три решения тут не случайны, и все три встретятся в каждой Solana-программе, которую ты откроешь.
crate-type = ["cdylib", "lib"]: один крейт с двумя лицами. Цепь грузит программу как динамическую библиотеку, этоcdylib. А тесты и любой Rust-код, который зовёт нашу программу, подключают её как обычный крейт, этоlib. Одна кодовая база, два потребителя.- Фича
no-entrypoint: выключатель точки входа. Точка входа нужна ровно одна и только на цепи. Тесты зовут обработчик напрямую, а чужая программа, делающая нам CPI, приносит свою точку входа, и вторая в том же бинарнике вызвала бы конфликт символов. Поэтому все программы экосистемы прячутentrypoint!за#[cfg(not(feature = "no-entrypoint"))], а потребители включают фичу. - Зависимости для тестов:
solana-program-testподнимает рантайм прямо в процессе теста (запустим его в уроке 37),solana-sdkдаёт клиентские типы (транзакция, подпись), аtokioнужен, потому что эти тесты асинхронные.
Секция lints глушит предупреждение unexpected_cfgs: макрос entrypoint! внутри ссылается на конфигурации сборки под цепь, которых на обычной машине нет, и без этой строки компилятор шумит на каждый cargo check.
На чём программа исполняется: SBF
Куда денется этот Rust после компиляции? Не в машинный код твоего процессора. Валидатор исполняет чужой, ничем не проверенный код из интернета, поэтому запускать его нативно нельзя. Программа компилируется в байткод SBF и крутится в виртуальной машине, форке eBPF: той самой технологии из ядра Linux, где проверяемый байткод безопасно исполняют прямо в ядре.
Песочница диктует ограничения, и они жёстче привычного Rust. Бюджет вычислений: каждая операция стоит compute units, и транзакция с исчерпанным бюджетом падает (мы разбирали это в уроке 32). Стек маленький, порядка четырёх килобайт на фрейм, большие структуры на стеке не живут. И нет привычного окружения: ни файлов, ни сети, ни системных часов, ни потоков. Всё общение программы с миром идёт через переданные аккаунты и узкий набор syscalls рантайма.
Отсюда и отдельный таргет компиляции: обычный cargo build собирает под твой процессор, а cargo build-sbf собирает байткод SBF для цепи. Хорошая новость: для этого урока тулчейн сборки под цепь не нужен вовсе. Тесты гоняют обработчик нативно, на твоей машине, и только перед деплоем в уроке 37 мы соберём настоящий .so под SBF.
Состояние: раскладка байтов аккаунта
Программа хранит счётчик не в себе, а в аккаунте. Значит, надо договориться, как число ложится в байты. Берём Borsh, детерминированный бинарный формат: одно значение всегда даёт ровно одни байты. Это та же забота о детерминизме, что и в сериализации блоков собственного блокчейна (урок 14).
use borsh::{BorshDeserialize, BorshSerialize};
/// Всё состояние счётчика: одно число. Оно ляжет в data аккаунта.
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Counter {
pub count: u64,
}
impl Counter {
/// Размер в байтах: столько места клиент выделит под аккаунт заранее.
pub const LEN: usize = 8;
}
LEN понадобится клиенту: аккаунт создают заранее и сразу нужного размера, программа только пишет в готовый буфер.
Инструкции: enum на входе
Как программа поймёт из байтов, что делать? Кодируем набор команд одним enum и сериализуем его тем же Borsh: первый байт это номер варианта, дальше его поля. Это прямое применение перечислений и match из раздела Rust, только теперь enum едет по сети.
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq, Eq)]
pub enum CounterInstruction {
/// Записать в свежий аккаунт счётчик, равный нулю.
/// Аккаунты: [counter (w)].
Init,
/// Увеличить счётчик на единицу.
/// Аккаунты: [counter (w)].
Increment,
}
Комментарий над каждым вариантом это контракт по аккаунтам: какие приходят и в каком порядке. Клиент обязан прислать их именно так, иначе программа не найдёт нужный аккаунт и вернёт ошибку.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.