Клиент и деплой: от теста в процессе до devnet
Программу мы написали, но пока не запускали её всерьёз: не тестировали, не деплоили, не звали из внешнего кода. Замкнём цикл. Сначала прогоним программу в тесте, где рантайм Solana поднимается прямо в процессе, без сети. Потом соберём транзакцию из Rust-клиента. Потом задеплоим в тестовую сеть. И подведём итог всему блоку про Solana.
Тест в процессе: рантайм без валидатора
Как проверить программу, не поднимая целый узел? solana-program-test поднимает рантайм в памяти прямо в тесте. Сеть не нужна, но подписи, владение и CPI работают по-настоящему. Так один cargo test проверяет и логику, и то, что программа дружит с рантаймом.
Проект готов ещё с урока 33: в том Cargo.toml уже лежат dev-зависимости solana-program-test, solana-sdk и tokio. Тест кладём в tests/counter.rs, он подключает наш крейт как обычную библиотеку. Точка входа тесту не мешает: processor! зовёт обработчик напрямую, минуя её. А фича no-entrypoint из того же скелета нужна внешним потребителям крейта (например, программе, которая делает нам CPI): они включают её, чтобы не притащить в свою сборку вторую точку входа. Вот тест целиком, со всеми импортами:
use borsh::BorshDeserialize;
use counter::{process_instruction, Counter, CounterInstruction};
use solana_program_test::{processor, ProgramTest};
use solana_sdk::{
account::Account, instruction::{AccountMeta, Instruction},
pubkey::Pubkey, signature::Signer, transaction::Transaction,
};
#[tokio::test]
async fn counter_inits_and_increments() {
let program_id = Pubkey::new_unique();
let counter = Pubkey::new_unique();
// processor! связывает имя программы с её обработчиком: рантайм зовёт его нативно.
let mut pt = ProgramTest::new("counter", program_id, processor!(process_instruction));
// Аккаунт под счётчик кладём заранее, нужного размера и с нашим владельцем.
pt.add_account(counter, Account {
lamports: 1_000_000_000,
data: vec![0u8; Counter::LEN],
owner: program_id,
executable: false,
rent_epoch: 0,
});
// start даёт клиент к встроенному рантайму, плательщика и свежий blockhash.
let (banks, payer, blockhash) = pt.start().await;
let init = Instruction::new_with_bytes(
program_id,
&borsh::to_vec(&CounterInstruction::Init).unwrap(),
vec![AccountMeta::new(counter, false)],
);
let inc = Instruction::new_with_bytes(
program_id,
&borsh::to_vec(&CounterInstruction::Increment).unwrap(),
vec![AccountMeta::new(counter, false)],
);
// Одна транзакция, три инструкции подряд: init и два инкремента.
let tx = Transaction::new_signed_with_payer(
&[init, inc.clone(), inc], Some(&payer.pubkey()), &[&payer], blockhash,
);
banks.process_transaction(tx).await.unwrap();
let acc = banks.get_account(counter).await.unwrap().unwrap();
assert_eq!(Counter::try_from_slice(&acc.data).unwrap().count, 2);
}
Из строк msg!, которые мы сеяли в программе, тут получается настоящий лог: cargo test -- --nocapture покажет Program ... invoke [1] и наши сообщения. Это тот же вывод, что даст реальная транзакция в сети.
Кроме теста в процессе есть solana-test-validator: настоящий локальный узел, к которому клиент подключается как к сети. Тест в процессе быстрее и годится под юнит-тесты, валидатор ближе к бою и годится под сквозные сценарии.
Rust-клиент: собрать и отправить транзакцию
Изнутри теста мы уже собирали транзакции. Из настоящего клиента почти то же, только вместо BanksClient берём RpcClient, который ходит к узлу по сети.
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
commitment_config::CommitmentConfig, instruction::{AccountMeta, Instruction},
signature::{Keypair, Signer}, transaction::Transaction,
};
fn increment_counter(rpc_url: &str, program_id: Pubkey, counter: Pubkey, payer: &Keypair) {
let client = RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed());
let ix = Instruction::new_with_bytes(
program_id,
&borsh::to_vec(&CounterInstruction::Increment).unwrap(),
vec![AccountMeta::new(counter, false)],
);
// Свежий blockhash делает транзакцию одноразовой и недолговечной.
let blockhash = client.get_latest_blockhash().unwrap();
let tx = Transaction::new_signed_with_payer(
&[ix], Some(&payer.pubkey()), &[payer], blockhash,
);
client.send_and_confirm_transaction(&tx).unwrap();
// Читаем состояние обратно прямо из аккаунта.
let data = client.get_account_data(&counter).unwrap();
let counter_state = Counter::try_from_slice(&data).unwrap();
println!("счётчик теперь {}", counter_state.count);
}
Это Rust-аналог того, что в Ethereum делал провайдер alloy (урок 29): собрать вызов, подписать, отправить, прочитать результат. Разница в модели: там мы кодировали calldata под ABI контракта, тут собираем instruction_data и список аккаунтов под нашу программу.
Отдельного взгляда заслуживает CommitmentConfig::confirmed(). Это уровень уверенности, с которым клиент считает транзакцию свершившейся, и у него три ступени. processed: транзакцию исполнил один узел, но блок ещё может отвалиться вместе с веткой. confirmed: за блок проголосовало квалифицированное большинство стейка, откат почти невероятен. finalized: блок укоренён, за ним уже десятки подтверждённых, отката не будет. Это та самая финальность через голосование валидаторов из урока 32, и контраст с Ethereum из урока 31 здесь практический: там финальности ждут порядка минуты, тут confirmed наступает за секунды. Для интерфейса берут confirmed, для необратимых действий вроде зачисления вывода ждут finalized.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.