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

Anchor: проверки как декларация

senior~45 мин

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

Anchor: проверки как декларация

В прошлом уроке мы держали в голове гору проверок: владение, размеры, кто создаёт аккаунт, скоро добавится подпись. Всё это ручное, и каждую забытую проверку злоумышленник превращает в дыру. Anchor это фреймворк, который говорит: опиши, каким должен быть аккаунт, а проверки я сгенерирую сам. Перепишем на него тот же счётчик и посмотрим, что уходит из кода и куда.

Идея: описать, а не проверять

Anchor переносит центр тяжести с императивного «проверь то, проверь это» на декларативное «аккаунт должен быть таким». Ты пишешь структуру аккаунтов с атрибутами, а макрос #[derive(Accounts)] разворачивает её в код, который до вызова твоего обработчика уже сверил владельца, подпись, размер и что аккаунт вообще тот. Твоя функция получает готовые, проверенные аккаунты.

Сравним лоб в лоб. Вот наш счётчик из прошлого урока, но на Anchor. Примеры проверены на Anchor 0.31 (актуальная мажорная версия на момент написания), API в этом виде стабилен начиная с 0.30.

Программа: модуль вместо match

use anchor_lang::prelude::*;

declare_id!("Cnt1111111111111111111111111111111111111111");

#[program]
pub mod counter {
    use super::*;

    pub fn init(ctx: Context<Init>) -> Result<()> {
        ctx.accounts.counter.count = 0;
        msg!("счётчик инициализирован нулём");
        Ok(())
    }

    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.count = counter.count.checked_add(1).ok_or(CounterError::Overflow)?;
        msg!("счётчик теперь {}", counter.count);
        Ok(())
    }
}

Пара слов про declare_id!, пока адрес выглядит игрушечным. Настоящий адрес не выдумывают: при первом anchor build Anchor генерирует keypair программы в target/deploy/counter-keypair.json, и публичный ключ этой пары станет адресом деплоя. Команда anchor keys sync подставляет его в declare_id! и в Anchor.toml, чтобы код, конфиг и реальный деплой указывали на один адрес.

Заметь, что пропало. Нет match по инструкции: каждый публичный метод модуля #[program] это отдельная команда, Anchor сам разберёт байты и позовёт нужный метод. Нет ручной десериализации состояния: ctx.accounts.counter уже готовый типизированный аккаунт. Нет проверки owner: она уедет в описание аккаунтов.

Context<Init> это те самые три аргумента нативной программы, только причёсанные: вместо сырого массива accounts у тебя типизированная структура полей.

Аккаунты: где живут проверки

Вот куда ушли проверки. Каждая инструкция объявляет свою структуру аккаунтов с атрибутами.

#[derive(Accounts)]
pub struct Init<'info> {
    // init: создать аккаунт (Anchor сам зовёт System Program create_account).
    // payer: кто платит rent-exempt залог. space: сколько байт выделить
    // (8 байт дискриминатор Anchor + 8 байт под u64).
    #[account(init, payer = signer, space = 8 + 8)]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
}

// Состояние аккаунта. #[account] добавляет 8-байтовый дискриминатор,
// по которому Anchor отличает Counter от других типов и ловит подмену.
#[account]
pub struct Counter {
    pub count: u64,
}

Разберём, что каждый атрибут делает за тебя.

  • Account<'info, Counter> это не просто ссылка. Сам тип Account при разборе проверяет, что owner аккаунта равен нашей программе, и десериализует данные в Counter. Ручная проверка владения из прошлого урока встроена в тип.
  • init, payer = signer, space = ... делает то, что в нативной программе было отдельной инструкцией create_account к System Program и следующей за ней Init. Anchor вызывает System Program сам, кладёт залог от signer и выделяет space байт. Оттого рядом обязателен system_program.
  • Signer<'info> проверяет, что этот аккаунт подписал транзакцию. Проверку is_signer, которую в токене мы будем писать руками, тут даёт сам тип.
  • #[account(mut)] говорит, что аккаунт изменится, и Anchor пометит его как writable и запишет обратно после обработчика.

8-байтовый дискриминатор это то, ради чего space = 8 + 8: первые восемь байт Anchor тратит на метку типа и потом ловит подмену одного типа состояния другим.

Ограничения посерьёзнее: PDA, has_one, свои условия

На счётчике проверок мало. Настоящая сила видна на связанных аккаунтах. Несколько частых ограничений (пригодятся в уроке 35 и 36). Это эскиз, а не полный листинг: типы Vault, Config и Receipt тут не объявлены, смотри только на атрибуты.

#[derive(Accounts)]
pub struct Withdraw<'info> {
    // seeds + bump: аккаунт обязан быть PDA, выведенным из этих сидов.
    // Anchor сам проверит адрес и положит bump в ctx.bumps.
    #[account(
        mut,
        seeds = [b"vault", owner.key().as_ref()],
        bump,
    )]
    pub vault: Account<'info, Vault>,

    // has_one: поле vault.owner обязано равняться аккаунту owner ниже.
    // Одна строка вместо ручного сравнения ключей.
    #[account(has_one = owner)]
    pub config: Account<'info, Config>,

    pub owner: Signer<'info>,

    // constraint: любое своё условие, с адресным сообщением об ошибке.
    // Атрибут висит на receipt, но условие внутри может смотреть на поля
    // любого аккаунта структуры: здесь оно проверяет поле vault.balance.
    #[account(constraint = vault.balance > 0 @ CounterError::EmptyVault)]
    pub receipt: Account<'info, Receipt>,
}

Каждая строка тут это проверка, которую иначе ты писал бы в теле обработчика руками и мог бы забыть. Anchor превращает забытую проверку в отсутствующий атрибут, а не в тихую дыру.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

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