Раздел 30 · Web3 на Rust

Клиент и деплой: от теста в процессе до devnet

middle-senior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Клиент и деплой: от теста в процессе до 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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.