
Введение в Anchor: руководство для начинающих по созданию программ Solana
Содержание
- О чём эта статья?
- Необходимые знания
- Установка Anchor
- Установка Rust
- Установка Solana Tool Suite
- Установка Yarn
- Установка Anchor с помощью AVM
- Установка Anchor из бинарных файлов и сборка из исходного кода
- Solana Playground
- Hello, World!
- Создание нового проекта с локальной установкой Anchor
- Создание нового проекта в Solana Playground
- Написание Hello, World!
- Локальная сборка и развёртывание
- Развёртывание в Devnet
- Сборка и развёртывание в Solana Playground
- Эффективная абстракция: IDL и макросы
- Структура программы Anchor
- Типы аккаунтов
- Ограничения аккаунтов
- Анализ ограничений программы
- Пространство аккаунта
- Ошибки
- Межпрограммные вызовы (CPI)
- Повышение привилегий
- Выполнение CPI
- Адреса, производные от программы (PDA)
- Заключение
- Дополнительные ресурсы
Большое спасибо Noah, Mike, Jonas, Ryan, Prames и bl0ckpain за рецензирование этой статьи.
О чём эта статья?
Rust часто называют лингва франка разработки программ Solana. Но правильнее было бы так охарактеризовать Anchor, поскольку большинство разработчиков на Rust используют этот фреймворк. Anchor — мощный фреймворк с определённым набором подходов, предназначенный для быстрой разработки безопасных программ Solana. Он упрощает разработку: сокращает шаблонный код для (де)сериализации аккаунтов и данных инструкций, выполняет необходимые проверки безопасности, автоматически генерирует клиентские библиотеки и предоставляет обширную среду тестирования.
В этой статье мы рассмотрим разработку программ Anchor. Вы узнаете, как установить Anchor, использовать Solana Playground, а также создать, собрать и развернуть простую программу Hello, World! Затем мы подробнее разберём, как Anchor упрощает разработку с помощью IDL, макросов, структуры программ Anchor, типов и ограничений аккаунтов, а также обработки ошибок. Мы также кратко рассмотрим межпрограммные вызовы и адреса, производные от программы. Эта статья даст вам все знания, необходимые, чтобы уже сегодня начать работать с Anchor.
Необходимые знания
Эта статья предполагает знание программной модели Solana. Если вы только начинаете разрабатывать на Solana, рекомендую прочитать мою предыдущую статью Программная модель Solana: введение в разработку на Solana.
Не переживайте, если вы только начинаете изучать Rust: для разработки с Anchor углублённые знания не нужны. В документации Anchor указано, что разработчикам достаточно знать основы Rust, то есть первые девять глав книги по Rust. Рекомендую посмотреть Руководство по выживанию в Rust, где хорошо разобраны основные концепции программирования на Rust. Также крайне важно понимать правила Rust по работе с памятью, владением и заимствованием.
Чтобы облегчить обучение, я рекомендую разработчикам, которые не знакомы с низкоуровневыми языками, изучить концепции системного программирования, часто пропускаемые в материалах по Rust. Например, стоит разобраться в таких темах, как размеры переменных, указатели и утечки памяти. Я также рекомендую Rust By Example и мой репозиторий с различными структурами данных и алгоритмами на Rust, где можно увидеть Rust в действии.
Предпочитаете TypeScript? Узнайте, как писать программы Solana на TypeScript, используя фреймворк Poseidon для транспиляции TypeScript в Rust и генерации корректных программ Anchor.
Эта статья посвящена исключительно разработке с Anchor. Мы не будем рассматривать разработку программ на чистом Rust и не предполагаем, что вы знакомы с ней. Кроме того, в статье не рассматривается клиентская разработка с Anchor. О тестировании программ Anchor и взаимодействии с ними через TypeScript мы расскажем в следующей статье.
Итак, приступим к работе с Anchor!
Установка Anchor
Для настройки Anchor достаточно выполнить несколько простых шагов и установить необходимые инструменты и пакеты. В этом разделе описана установка Rust, Solana Tool Suite, Yarn и Anchor Version Manager.
Установка Rust
Rust можно установить с официального сайта Rust или через командную строку:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shУстановка Solana Tool Suite
Для Anchor также требуется Solana Tool Suite. Последнюю на момент написания статьи версию (1.17.16) можно установить в macOS и Linux следующей командой:
sh -c "$(curl -sSfL https://release.solana.com/v1.17.16/install)"Пользователи Windows могут установить Solana Tool Suite следующей командой:
cmd /c "curl https://release.solana.com/v1.17.16/solana-install-init-x86_64-pc-windows-msvc.exe --output C:\solana-install-tmp\solana-install-init.exe --create-dirs"Однако настоятельно рекомендуется вместо этого использовать Подсистему Windows для Linux (WSL). Она позволяет запускать среду Linux на компьютере с Windows без двойной загрузки или отдельной виртуальной машины. В этом случае следуйте инструкциям по установке для Linux, то есть используйте команду curl.
Разработчики также могут заменить v1.17.16 тегом нужной версии или указать канал stable, beta либо edge. После установки выполните solana –-version, чтобы убедиться, что установлена нужная версия solana.
Установка Yarn
Для Anchor также требуется Yarn. Его можно использовать через Corepack, который входит во все официальные выпуски Node.js, начиная с Node.js 14.9 / 16.9. Однако на текущем экспериментальном этапе его нужно включать вручную. Поэтому перед использованием необходимо выполнить corepack enable. Некоторые сторонние дистрибутивы могут не включать Corepack по умолчанию. В таком случае перед corepack enable потребуется выполнить npm install -g corepack.
Установка Anchor с помощью AVM
Документация Anchor рекомендует устанавливать Anchor через Anchor Version Manager (AVM). AVM упрощает управление несколькими установленными версиями бинарного файла anchor-cli и переключение между ними. Это может потребоваться для создания проверяемых сборок или работы с разными версиями в различных программах. AVM можно установить с помощью Cargo командой cargo install --git [https://github.com/coral-xyz/anchor](https://github.com/coral-xyz/anchor) avm --locked --force. Затем установите и активируйте последнюю версию:
avm install latest
avm use latest
# Verify the installation
avm --versionЧтобы получить список доступных версий anchor-cli, используйте команду avm list. Для выбора конкретной версии выполните avm use <version>. Эта версия будет использоваться до следующего переключения. Удалить конкретную версию можно командой avm uninstall <version>.
Установка Anchor из бинарных файлов и сборка из исходного кода
В Linux бинарные файлы Anchor доступны через пакет npm @coral-xyz/anchor-cli. Сейчас поддерживается только Linux на x86_64. Для других операционных систем разработчикам потребуется собрать Anchor из исходного кода. CLI можно установить напрямую с помощью Cargo. Например:
cargo install --git https://github.com/coral-xyz/anchor --tag v0.29.0 anchor-cli --lockedИзмените аргумент --tag, чтобы установить другую версию Anchor. Если установка через Cargo завершится ошибкой, возможно, потребуется установить дополнительные зависимости. Например, в Ubuntu:
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install -y pkg-config build-essential libudev-devПосле этого проверить установку Anchor можно командой anchor --version.
Solana Playground
Кроме того, начать работу с Anchor можно в Solana Playground (Solpg). Solana Playground — браузерная IDE для быстрой разработки, тестирования и развёртывания программ Solana.
При первом использовании Solana Playground разработчикам необходимо создать Playground Wallet. Нажмите красный индикатор состояния Не подключено в левом нижнем углу экрана. Откроется следующее модальное окно:
Перед нажатием Продолжить рекомендуется сохранить файл пары ключей кошелька в качестве резервной копии. Playground Wallet хранится в локальном хранилище браузера. При очистке кеша браузера кошелёк будет удалён.
Нажмите Продолжить, чтобы создать кошелёк devnet, готовый к использованию в IDE.
Чтобы пополнить кошелёк, выполните в терминале Playground команду solana airdrop <amount>, заменив <amount> на нужное количество SOL в devnet. Также SOL для devnet можно получить через этот кран. Рекомендую ознакомиться с руководством по получению SOL в devnet.
Обратите внимание, что может возникнуть следующая ошибка:
Error: unable to confirm transaction. This can happen in situations such as transaction expiration and insufficient fee-payer fundsЧасто это происходит из-за отсутствия средств в кране devnet и/или запроса слишком большого количества SOL. Текущий лимит составляет 5 SOL, чего более чем достаточно для развёртывания этой программы. Поэтому рекомендуется запросить в кране 5 SOL или выполнить команду solana airdrop 5. Последовательные запросы небольших сумм могут привести к ограничению частоты запросов.
Hello, World!
Программы Hello, World! считаются отличным введением в новые фреймворки и языки программирования. Благодаря простоте они понятны разработчикам с любым уровнем подготовки. Такие программы также демонстрируют базовую структуру и синтаксис новой программной модели без сложной логики и функций. Hello, World! давно стала стандартной программой для начинающих, поэтому вполне естественно написать её и для Anchor. В этом разделе описано, как собрать и развернуть программу Hello, World! с локальной установкой Anchor и в Solana Playground.
Создание нового проекта с локальной установкой Anchor
Создать новый проект с установленным Anchor очень просто:
anchor init hello-world
cd hello-worldЭти команды инициализируют новый проект Anchor с именем hello-world и откроют его каталог. В нём перейдите к файлу hello-world/programs/hello-world/src/lib.rs. Он содержит следующий начальный код:
use anchor_lang::prelude::*;
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");
#[program]
pub mod hello-world {
use super::*;
pub fn initialize(ctx: Context) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}Anchor подготовил для нас несколько файлов и каталогов, а именно:
- Пустой каталог app для клиента программы
- Каталог programs, в котором будут храниться все наши программы Solana
- Каталог tests для тестирования на JavaScript. Он содержит автоматически созданный тестовый файл для начального кода
- Конфигурационный файл Anchor.toml. Если вы только начинаете работать с Rust, файл TOML — это минималистичный формат конфигурации с понятной семантикой. Файл Anchor.toml определяет, как Anchor будет взаимодействовать с программой. Например, в какой кластер её нужно развернуть.
Создание нового проекта в Solana Playground
Создать новый проект в Solana Playground очень просто. Перейдите в левый верхний угол и нажмите Создать новый проект:
Откроется следующее модальное окно:
Введите имя программы, выберите Anchor(Rust) и нажмите Создать. Новый проект Anchor будет создан прямо в браузере. В разделе Program слева появится каталог src. В нём находится файл lib.rs со следующим начальным кодом:
use anchor_lang::prelude::*;
// This is your program's public key and it will update
// automatically when you build the project.
declare_id!("11111111111111111111111111111111");
#[program]
mod hello_anchor {
use super::*;
pub fn initialize(ctx: Context, data: u64) -> Result<()> {
ctx.accounts.new_account.data = data;
msg!("Changed data to: {}!", data); // Message will show up in the tx logs
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
// We must specify the space in order to initialize an account.
// First 8 bytes are default account discriminator,
// next 8 bytes come from NewAccount.data being type u64.
// (u64 = 64 bits unsigned integer = 8 bytes)
#[account(init, payer = signer, space = 8 + 8)]
pub new_account: Account<'info, NewAccount>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct NewAccount {
data: u64
}Обратите внимание, что Solana Playground создаёт только файлы client.ts и anchor.test.ts. Рекомендую прочитать раздел о локальном создании программы с Anchor, чтобы узнать, какие файлы обычно генерируются для нового проекта Anchor.
Написание Hello, World!
Независимо от того, используете вы Anchor локально или через Solana Playground, для простейшей программы Hello, World! замените начальный код следующим:
use anchor_lang::prelude::*;
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");
#[program]
mod hello_world {
use super::*;
pub fn hello(_ctx: Context<Hello>) -> Result<()> {
msg!("Hello, World!");
Ok(())
}
#[derive(Accounts)]
pub struct Hello {}
}В следующих разделах мы подробно разберём каждую часть. Пока важно обратить внимание на использование макросов и типажей, упрощающих разработку. Макрос declare_id! задаёт публичный ключ программы. При локальной разработке команда anchor init для настройки программы создаст пару ключей в каталоге target/deploy и заполнит этот макрос. Solana Playground также сделает это автоматически.
В основном модуле hello_world мы создаём функцию, которая записывает в журнал Hello, World!. Она также возвращает Ok(()), сигнализируя об успешном выполнении программы. Обратите внимание, что перед ctx стоит символ подчёркивания, чтобы избежать предупреждений о неиспользуемой переменной в консоли. Hello — это структура аккаунта, которая не требует передачи аккаунтов, поскольку программа только записывает новое сообщение в журнал.
Готово! Не нужно принимать аккаунты или реализовывать сложную логику. Приведённый выше код создаёт программу, которая записывает Hello, World! в журнал.
Локальная сборка и развёртывание
В этом разделе мы сосредоточимся на развёртывании в Localhost. Хотя Solana Playground по умолчанию использует devnet, локальная среда разработки значительно удобнее. Она не только быстрее, но и позволяет избежать распространённых проблем при тестировании в devnet. Например, нехватки SOL для транзакций, медленного развёртывания и невозможности тестировать при недоступности devnet. Локальная разработка, напротив, гарантирует чистое состояние при каждом тесте. Это делает среду разработки более контролируемой и эффективной.
Настройка инструментов
Сначала убедимся, что Solana Tool Suite правильно настроен для разработки в Localhost. Выполните команду solana config set --url localhost, чтобы все параметры указывали на URL Localhost.
Также убедитесь, что у вас есть локальная пара ключей для взаимодействия с Solana. Чтобы развернуть программу через Solana CLI, нужен кошелёк Solana с балансом SOL. Выполните команду solana address, чтобы проверить наличие локальной пары ключей. Если возникнет ошибка, выполните solana-keygen new. По умолчанию новый кошелёк файловой системы будет создан по пути ~/.config/solana/id.json. Вы также получите фразу восстановления, с помощью которой можно восстановить публичный и приватный ключи. Рекомендуется сохранить эту пару ключей, даже если она используется только локально. Обратите внимание: если в стандартном расположении уже сохранён кошелёк файловой системы, команда solana-keygen new не перезапишет его без явного указания команды --force.
Настройка Anchor.toml
Теперь убедимся, что файл Anchor.toml правильно указывает на Localhost. Он должен содержать следующий код:
...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Localnet"
wallet = '~config/solana/id.json'Здесь [programs.localnet] обозначает ID программы в localnet, то есть Localhost. ID программы всегда указывается применительно к кластеру. Это связано с тем, что одну программу можно развернуть по разным адресам в разных кластерах. С точки зрения удобства разработки объявлять новые ID для программ, развёрнутых в разных кластерах, может быть неудобно.
ID программы общедоступен. Однако соответствующая пара ключей хранится в каталоге target/deploy. Имя файла формируется по определённому правилу на основе имени программы. Например, если программа называется hello_world, Anchor будет искать пару ключей по пути target/deploy/hello-world-keypair.json. Если во время развёртывания Anchor не найдёт этот файл, он создаст новую пару ключей. В результате программа получит новый ID. Поэтому после первого развёртывания крайне важно обновить ID программы. Файл hello-world-keypair.json подтверждает право владения программой. Если пара ключей попадёт к злоумышленникам, они смогут внести в программу несанкционированные изменения.
С помощью [provider] мы указываем Anchor использовать Localhost и заданный кошелёк для оплаты хранения и транзакций.
Сборка, развёртывание и запуск локального реестра
Для сборки программы используйте команду anchor build. Чтобы собрать конкретную программу по имени, выполните anchor build -p <program name>, заменив <program name> именем программы. Поскольку мы разрабатываем в localnet, для упрощения процесса можно использовать команды localnet из Anchor CLI. Например, anchor localnet --skip-build особенно полезна, чтобы пропустить сборку программы в рабочем пространстве. Это экономит время при запуске тестов, если код программы не изменялся.
Если сейчас выполнить команду anchor deploy, возникнет ошибка. У нас на компьютере ещё не запущен кластер Solana для тестирования. Чтобы имитировать кластер, можно запустить локальный реестр. В Solana CLI уже встроен тестовый валидатор. Команда solana-test-validator запустит на вашем компьютере полнофункциональный однонодовый кластер. У него есть ряд преимуществ: отсутствуют ограничения частоты запросов RPC и лимиты аирдропов, можно напрямую развёртывать ончейн-программы, загружать аккаунты из файлов и клонировать аккаунты из публичного кластера. Тестовый валидатор необходимо запустить в отдельном окне терминала и не останавливать, чтобы локальный кластер оставался доступным для взаимодействия.
Теперь можно успешно выполнить anchor deploy и развернуть программу в локальном реестре. Все переданные в него данные будут сохранены в каталоге test-ledger, созданном в текущем рабочем каталоге. Рекомендуется добавить его в файл .gitignore, чтобы не фиксировать каталог в репозитории. Завершение работы локального реестра, то есть нажатие Ctrl + C в терминале, не удалит отправленные в кластер данные. Для их удаления нужно удалить каталог test-ledger или выполнить solana-test-validator --reset.
Поздравляем! Вы только что развернули свою первую программу Solana в Localhost!
Solana Explorer
Разработчики также могут настроить Solana Explorer для работы с локальным реестром. Откройте Solana Explorer. На панели навигации нажмите зелёную кнопку с названием текущего кластера:
Откроется боковая панель, где можно выбрать кластер. Нажмите Пользовательский URL RPC. Поле должно автоматически заполниться адресом http://localhost:8899. Если этого не произошло, введите его, чтобы направить Explorer на порт 8899 вашего компьютера:
Это очень полезно по нескольким причинам:
- Разработчики могут в реальном времени проверять транзакции в локальном реестре, как в обозревателе блоков для devnet или mainnet
- Проще визуализировать состояние аккаунтов, токенов и программ так, будто они работают в активном кластере
- Доступна подробная информация об ошибках и сбоях транзакций
- Знакомый интерфейс обеспечивает единообразную разработку в разных кластерах
Развёртывание в Devnet
Хотя мы рекомендуем разработку в Localhost, программу также можно развернуть в devnet, если нужно протестировать её именно в этом кластере. Процесс в целом тот же, но запускать локальный реестр не требуется: у нас уже есть полноценный кластер Solana, с которым можно взаимодействовать.
Выполните команду solana config set --url devnet, чтобы переключить выбранный кластер на devnet. Теперь любая команда solana, выполненная в терминале, будет работать с devnet. Затем в файле Anchor.toml продублируйте раздел [programs.localnet] и переименуйте его в [programs.devnet]. Также измените [provider], чтобы он указывал на devnet:
...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
[programs.devnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Devnet"
wallet = '~config/solana/id.json'Для развёртывания программы у разработчиков должен быть SOL в devnet. Используйте команду solana airdrop <amount>, чтобы получить аирдроп на пару ключей по стандартному пути ~/.config/solana/id.json. Адрес кошелька также можно указать с помощью solana aidrop <amount> <wallet address>. Кроме того, SOL для devnet можно получить через этот кран. Рекомендую ознакомиться с руководством по получению SOL в devnet.
Может возникнуть следующая ошибка: не удалось подтвердить транзакцию. Это может произойти, например, из-за истечения срока действия транзакции или недостатка средств у плательщика комиссии
Часто это происходит из-за отсутствия средств в кране devnet и/или запроса слишком большого количества SOL за один раз. Текущий лимит составляет 5 SOL, чего более чем достаточно для развёртывания этой программы. Поэтому рекомендуется запросить в кране 5 SOL или выполнить команду solana airdrop 5. Последовательные запросы небольших сумм могут привести к ограничению частоты запросов.
Теперь соберите и разверните программу с помощью следующих команд:
anchor build
anchor deployПоздравляем! Вы только что локально развернули свою первую программу Solana в devnet!
Сборка и развёртывание в Solana Playground
В Solana Playground откройте раздел Инструменты на левой боковой панели. Нажмите Собрать. В консоли должно появиться следующее:
Building...
Build successful. Completed in 2.20s..Обратите внимание, что ID в макросе declare_id! был перезаписан. По этому новому адресу мы развернём программу. Теперь нажмите Развернуть. В консоли должно появиться нечто подобное:
Deploying... This could take a while depending on the program size and network conditions.
Warning: 41 transactions not confirmed, retrying...
Deployment successful. Completed in 17sПоздравляем! Вы только что развернули свою первую программу Solana в devnet через Solana Playground!
Эффективная абстракция: IDL и макросы
Anchor упрощает разработку программ с помощью эффективной абстракции. Иными словами, Anchor упрощает сложные концепции программирования блокчейна, делая их понятнее и удобнее в работе. Например, Anchor использует язык описания интерфейсов (IDL) для определения интерфейса программы. При сборке программы Anchor создает JSON-файл, представляющий IDL программы. Эту структуру можно использовать на стороне клиента: она определяет способы взаимодействия с функциями и структурами данных программы. Anchor также предоставляет высокоуровневые абстракции для управления состоянием. Разработчики могут определять состояние программы с помощью структур Rust. Это интуитивнее, чем работа с необработанными массивами байтов или ручной сериализацией. Таким образом, разработчики определяют состояние так же, как и в любой обычной структуре данных Rust, а Anchor выполняет базовую сериализацию и сохраняет данные в аккаунтах.
Опубликовать IDL в блокчейне также очень просто. Разработчики могут опубликовать IDL следующей командой:
anchor idl init --filepath --provider.cluster --provider.walletУбедитесь, что указанный кошелек является владельцем программы и на нем достаточно SOL для транзакции. Теперь разработчики могут просматривать свой IDL в обозревателе блоков, например Orb.
Например, вот IDL агрегатора DFlow v4 в Orb.
Макросы Anchor — одна из важнейших, если не самая важная абстракция. В Rust макрос — это фрагмент кода, который генерирует другой фрагмент кода. Это одна из форм метапрограммирования. Декларативные макросы — наиболее распространенный вид макросов в Rust. Они позволяют разработчикам писать конструкции, похожие на выражение match, с помощью конструкции macro_rules!. Процедурные макросы больше похожи на функции: они принимают код на вход, обрабатывают его и создают результат. Например, в Anchor макрос #[account] определяет и применяет ограничения к аккаунтам Solana. Это снижает сложность управления аккаунтами и вероятность ошибок. Рассмотрение макросов Anchor неизбежно требует обсуждения структуры программы Anchor.
Структура программы Anchor
Структура программы Anchor использует сочетание макросов и трейтов для генерации шаблонного кода и применения логики программы. Этот подход значительно упрощает процесс разработки и обеспечивает согласованное и надежное поведение программы.
Объявления use находятся в верхней части файла. Обратите внимание, что это общая семантическая конструкция языка Rust, не относящаяся непосредственно к Anchor. Такие объявления создают одну или несколько локальных привязок имен, соответствующих другому пути: объявления use сокращают путь, необходимый для обращения к элементу модуля. Они могут находиться в модулях или блоках. Кроме того, ключевое слово self может привязать список путей с общим префиксом и их общий родительский модуль. Например, все следующие объявления use допустимы:
use anchor_lang::prelude::*;
use std::collections::hash_map::{self, HashMap};
use a::b::{c, d, e::f, g::h::i};
use a::b::{self, c, d::e};Первый макрос Anchor, с которым столкнется разработчик, — declare_id!. Он объявляет адрес программы (ID программы) и обеспечивает правильную маршрутизацию всех взаимодействий к программе. Когда разработчик впервые собирает программу Anchor, Anchor генерирует новую пару ключей. Если не указано иное, эта пара ключей используется для развертывания программы. Публичный ключ пары следует передать в качестве ID программы макросу declare_id!:
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");Макрос атрибута #[program] обозначает модуль с логикой инструкций программы. Он служит точкой входа и определяет, как программа интерпретирует и выполняет входящие инструкции. Этот макрос упрощает маршрутизацию инструкций к соответствующим функциям программы, делая код более организованным и управляемым. Каждая функция в этом модуле рассматривается как отдельная инструкция. Первым аргументом каждой функции должен быть параметр контекста (ctx) типа Context. Разработчики могут получить доступ к аккаунтам, ID выполняемой программы и оставшимся аккаунтам.
Тип Context определяется следующим образом:
pub struct Context<'a, 'b, 'c, 'info, T: Bumps> {
pub program_id: &'a Pubkey,
pub accounts: &'b mut T,
pub remaining_accounts: &'c [AccountInfo<'info>],
pub bumps: T::Bumps,
}Он позволяет передавать программе входные данные, не являющиеся аргументами. Поле program_id имеет тип Pubkey и представляет ID выполняемой в данный момент программы. accounts обозначает сериализованные аккаунты, а remaining_accounts — переданные оставшиеся аккаунты, которые не были десериализованы или проверены. Будьте крайне осторожны при непосредственной работе с ними. Поле bumps имеет тип Bumps, сгенерированный #[derive(Accounts)]. Оно представляет bump seeds, найденные при проверке ограничений. Ограничения аккаунтов мы рассмотрим в следующем разделе. Пока важно понимать, что это поле предоставлено для удобства: обработчикам не нужно повторно вычислять bump seeds или передавать их как аргументы.
Обратите внимание, что Context — обобщенный тип. В Rust обобщения позволяют разработчикам писать гибкий и многократно используемый код, который работает с любым типом данных. Они позволяют определять типы структур, перечислений, функций и методов без указания конкретного типа, с которым те будут работать. Вместо него используется заполнитель, обычно обозначаемый как T. Обобщения сокращают повторение кода и делают его понятнее. Например, можно определить перечисление для хранения обобщенных типов данных:
enum Option<T> {
Some(T),
None,
}В приведенном выше фрагменте показано перечисление Option<T>. Это стандартное перечисление Rust, которое может содержать значение любого типа (то есть Some(T)) или не содержать значения (None).
В нашем случае Context — обобщенный тип, где T задает аккаунты, необходимые инструкции, то есть любой тип, который разработчик хочет создать для хранения данных. При использовании Context разработчики могут определить T как структуру, реализующую трейт Accounts. Например, Context<SetData>. Доступ к полям типа Context можно получить с помощью точечной нотации. Например, ctx.accounts обращается к полю accounts структуры Context.
Как уже упоминалось, макрос #[account] определяет пользовательские типы аккаунтов. В следующих разделах мы рассмотрим типы и ограничения аккаунтов с использованием #[account(...)]. Пока важно отметить, что в структуре Accounts разработчик определяет, какие аккаунты ожидает инструкция и каким ограничениям они должны соответствовать.
Типы аккаунтов
Тип Account используется, когда инструкции требуется доступ к десериализованным данным аккаунта. Структура Account является обобщенной по T и определяется следующим образом:
pub struct Account<'info, T: AccountSerialize + AccountDeserialize + Clone> { /* private fields */ }Это оболочка для AccountInfo, которая проверяет владельца программы и десериализует базовые данные в тип Rust. Она проверяет владение программой согласно условию Account.info.owner == T::owner(). Иными словами, она проверяет, совпадает ли владелец данных с ID крейта, где используется #[account]. Этот ID был ранее создан с помощью declare_id!. Следовательно, тип данных, который оборачивает Account (=T), должен реализовывать трейт Owner. Атрибут #[account] реализует этот трейт для структуры с использованием crate::ID, объявленного через declare_id! в той же программе. В большинстве случаев разработчикам достаточно использовать атрибут #[account], чтобы добавить к данным необходимые трейты и реализации. Атрибут #[account] генерирует реализации следующих трейтов:
При реализации трейтов сериализации аккаунта первые 8 байт выделяются под уникальный дискриминатор аккаунта. Этот дискриминатор определяется первыми 8 байтами SHA-256-хеша идентификатора аккаунта в Rust. Любой вызов try_deserialize для AccountDeserialize проверяет этот дискриминатор. Если передан недопустимый аккаунт, десериализация аккаунта завершается с ошибкой.
Иногда разработчикам требуется взаимодействовать с программами, созданными не на Anchor. В этом случае можно получить все преимущества Account, если создать собственный тип-оболочку вместо использования #[account]. Рассмотрим следующий фрагмент кода:
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;
// Rest of the program
#[derive(Accounts)]
pub struct SetData<'info> {
#[account(mut)]
pub my_account: Account<'info, MyAccount>,
#[account(
constraint = my_account.mint == token_account.mint,
has_one = owner
)]
pub token_account: Account<'info, TokenAccount>,
pub owner: Signer<'info>
}Большая часть проверок аккаунтов выполняется через ограничения аккаунтов, которые мы рассмотрим в следующем разделе. Пока обратите внимание, как тип TokenAccount проверяет, принадлежит ли входящий аккаунт токен-программе. TokenAccount оборачивает структуру Account токен-программы и добавляет необходимые функции. Благодаря этому Anchor может десериализовать аккаунт, а разработчики — использовать его поля в ограничениях аккаунтов и функции инструкции.
Также обратите внимание, что в приведенном выше фрагменте кода макрос derive охватывает всю структуру. Он реализует десериализатор Accounts для SetData и используется для проверки входящих аккаунтов.
В структуре проверки аккаунтов можно использовать несколько типов Account, включая:
- Account<’info, T>: контейнер аккаунта, который проверяет владельца при десериализации
- AccountInfo<’info>: непроверенный аккаунт, который можно использовать как тип. Однако вместо него следует использовать UncheckedAccount, поскольку AccountInfo, вероятно, будет удален в одном из будущих выпусков
- AccountLoader<’info, T>: тип для выполнения десериализации без копирования по запросу. Это отличается от использования
Account: разработчик должен вызватьload_initпосле инициализации аккаунта,load— если аккаунт неизменяемый, иload_mut— если аккаунт изменяемый - Box<Account<’info, T>> или Box<InterfaceAccount<’info, T>>: тип box, который экономит место в стеке. Иногда аккаунты слишком велики для стека и могут вызвать его переполнение — упаковка аккаунта в box помогает устранить эту проблему
- Interface<’info, T>: тип-оболочка над
Program, который проверяет, относится ли аккаунт к одной из заданных программ. Он проверяет, содержит ли ожидаемая программа ключ аккаунта и является ли аккаунт исполняемым - InterfaceAccount<’info, T>: контейнер аккаунта, который проверяет владельца программы и десериализует базовые данные в тип Rust
- Option<Account<’info, T>>: тип option для необязательных аккаунтов
- Program<’info, T>: тип, который проверяет, является ли аккаунт заданной программой
- Signer<’info>: тип, который проверяет, подписал ли аккаунт транзакцию
- SystemAccount<’info>: тип, который проверяет, принадлежит ли аккаунт System Program
- Sysvar<’info, T>: тип, который проверяет, является ли аккаунт sysvar. То есть относится ли аккаунт к специальному типу с динамически обновляемыми данными о кластере сети, истории блокчейна и выполняемой транзакции. При разработке программ полезны sysvar
clock,epoch_schedule,instructionsиrent - UncheckedAccount<’info>: контейнер аккаунта, который явно указывает, что заданный аккаунт не проходит никаких проверок
Ограничения аккаунтов
Ограничения аккаунтов крайне важны для разработки безопасных программ Anchor. В будущих статьях мы подробнее рассмотрим безопасность программ Solana и взлом программ Anchor. Однако уже сейчас важно разобрать ограничения. Они позволяют разработчикам проверять, соответствуют ли определенные аккаунты или хранящиеся в них данные заранее заданным требованиям. С помощью атрибута #[account(...)] можно применять несколько типов ограничений, которые также могут ссылаться на другие структуры данных. Формат выглядит так:
#[account(constraint goes here)]
pub account: AccountTypeТакже важно отметить, что внутри макроса Accounts разработчики могут обращаться к аргументам инструкции с помощью атрибута #[instruction(...)]. Аргументы инструкции нужно перечислить в том же порядке, что и в самой инструкции. При этом можно опустить все аргументы после последнего необходимого. Например, из документации Anchor:
...
pub fn initialize(ctx: Context, bump: u8, authority: Pubkey, data: u64) -> anchor_lang::Result<()> {
...
Ok(())
}
...
#[derive(Accounts)]
#[instruction(bump: u8)]
pub struct Initialize<'info> {
...
}Ограничения аккаунтов можно разделить на обычные ограничения и ограничения SPL. В оставшейся части статьи мы рассмотрим конкретные ограничения. В этих примерах <expr> обозначает произвольное выражение, которое можно передать при условии, что его результат имеет ожидаемый тип. Например, owner = token_program.key().
Анализ ограничений программы
Для более полного списка возможных ограничений рекомендую изучить документацию Anchor об аккаунтах. Перебирать каждое ограничение и давать ему формальное определение в виде таблицы было бы слишком трудоемко. Для наших целей полезнее проанализировать следующую программу и увидеть ограничения аккаунтов в действии:
use anchor_lang::prelude::*;
#[cfg(not(feature = "no-entrypoint"))]
use {default_env::default_env, solana_security_txt::security_txt};
declare_id!("fanqeMu3fw8R4LwKNbahPtYXJsyLL6NXyfe2BqzhfB6");
pub mod errors;
pub mod instructions;
pub mod state;
pub use instructions::*;
pub use state::*;
#[cfg(not(feature = "no-entrypoint"))]
security_txt! {
name: "Fanout",
project_url: "http://helium.com",
contacts: "email:hello@helium.foundation",
policy: "https://github.com/helium/helium-program-library/tree/master/SECURITY.md",
// Optional Fields
preferred_languages: "en",
source_code: "https://github.com/helium/helium-program-library/tree/master/programs/fanout",
source_revision: default_env!("GITHUB_SHA", ""),
source_release: default_env!("GITHUB_REF_NAME", ""),
auditors: "Sec3"
}
#[program]
pub mod fanout {
use super::*;
pub fn initialize_fanout_v0(
ctx: Context<InitializeFanoutV0>,
args: InitializeFanoutArgsV0,
) -> Result<()> {
instructions::initialize_fanout_v0::handler(ctx, args)
}
pub fn stake_v0(ctx: Context<StakeV0>, args: StakeArgsV0) -> Result<()> {
instructions::stake_v0::handler(ctx, args)
}
pub fn unstake_v0(ctx: Context<UnstakeV0>) -> Result<()> {
instructions::unstake_v0::handler(ctx)
}
pub fn distribute_v0(ctx: Context<DistributeV0>) -> Result<()> {
instructions::distribute_v0::handler(ctx)
}
}Это программа Fanout от Helium. Это достаточно сложная программа, которая распределяет токены между их держателями пропорционально объему активов. Пока проект выглядит для нас не слишком полезным, поскольку в нем не видно ограничений. Однако если проанализировать структуру StakeV0 инструкции stake_v0, мы найдем множество ограничений для изучения.
mut
Первое ограничение в этой инструкции — ограничение аккаунта mut. mut определяется как #[account(mut)] или #[account(mut @ <custom_error>)] и поддерживает пользовательские ошибки через нотацию @. Это ограничение проверяет, является ли заданный аккаунт изменяемым, и заставляет Anchor сохранять любые изменения состояния. В программе Helium ограничение гарантирует изменяемость аккаунта payer:
...
pub struct StakeV0<'info> {
#[account(mut)]
pub payer: Signer<'info>,
pub staker: Signer<'info>,
/// CHECK: Just needed to receive nft
pub recipient: AccountInfo<'info>,
...has_one
Ограничение has_one определяется как #[account(has_one = <target_account)] или #[account(has_one = <target_account> @ <custom_error>)]. Оно проверяет поле target_account, чтобы определить, соответствует ли аккаунт ключу поля target_account в структуре Accounts. Пользовательские ошибки поддерживаются через аннотацию @.
В контексте структуры StakeV0 ограничение has_one проверяет, содержит ли аккаунт membership_mint, token_account и membership_collection:
...
#[account(
mut,
has_one = membership_mint,
has_one = token_account,
has_one = membership_collection
)]
pub fanout: Box<Account<'info, FanoutV0>>,
pub membership_mint: Box<Account<'info, Mint>>,
pub token_account: Box<Account<'info, TokenAccount>>,
pub membership_collection: Box<Account<'info, Mint>>,
...Обратите внимание: здесь применяется несколько ограничений has_one, а также ограничение mut. К одному аккаунту можно одновременно применить несколько ограничений аккаунта.
seeds, bump
Ограничения seeds и bump проверяют, является ли заданный аккаунт PDA, полученным из выполняемой программы, seeds и, если он указан, bump:
#[account(seeds = <seeds>, bump)]#[account(seeds = <seeds>, bump, seeds::program = <expr>)]#[account(seeds = <seeds>, bump = <expr>)]#[account(seeds = <seeds>, bump = <expr>, seeds::program = <expr>)]
Если bump не указан, Anchor использует канонический bump. Seeds::program = <expr> позволяет получить PDA из другой программы, а не из выполняемой в данный момент.
В программе Fanout от Helium ограничение seeds проверяет, используются ли текст «metadata», ключ token_metadata_program, ключ membership_collection и текст «edition» как seeds для получения этого PDA. Ограничение seeds::program гарантирует, что для получения PDA используется token_metadata_program, а не текущая программа:
...
#[account(
mut,
seeds = ["metadata".as_bytes(), token_metadata_program.key().as_ref(), membership_collection.key().as_ref()],
seeds::program = token_metadata_program.key(),
bump,
)]
pub collection_metadata: UncheckedAccount<'info>,
...token::mint, token::authority
Ограничения token::mint и token::authority определяются следующим образом:
#[account(token::mint = <target account>, token::authority = <target account>)]#[account(token::mint = <target account>, token::authority = <target account>, token::token_program = <target account>)]
Токен-ограничения mint и authority проверяют адрес mint и полномочия TokenAccount. Эти ограничения можно использовать для проверки или вместе с ограничением init, чтобы создать токен-аккаунт с заданными адресом mint и полномочиями. При использовании для проверки можно указать только часть ограничений.
В контексте программы Helium эти ограничения проверяют, равен ли mint объекта associated_token значению membership_mint и установлены ли полномочия токена на staker:
...
#[account(
mut,
associated_token::mint = membership_mint,
associated_token::authority = staker,
)]
pub from_account: Box<Account<'info, TokenAccount>>,
...init, payer, space
Теперь стоит немного продвинуться по коду и проанализировать ограничения init, payer и space. Ограничение init определяется как [#account(init, payer = <target_account>, space = <num_bytes>)]. Оно создает аккаунт через CPI к System Program и инициализирует его, устанавливая дискриминатор аккаунта. Аккаунт помечается как изменяемый, поэтому это ограничение несовместимо с mut. Для аккаунтов размером более 10 кибибайтов используйте #[account(zero)].
Ограничение init необходимо использовать с несколькими дополнительными ограничениями. Для него требуется ограничение payer, которое указывает аккаунт, оплачивающий создание аккаунта. Также в структуре должна присутствовать System Program с именем system_program. Кроме того, необходимо определить ограничение space. В разделе о пространстве аккаунта мы подробнее рассмотрим это ограничение и требования к пространству.
В программе Fanout от Helium команда init создает новый аккаунт. В качестве payer задается payer, ранее определенный в структуре как pub payer: Signer<'info>. Пространство аккаунта устанавливается равным размеру FanoutVoucherV0 плюс 8 байт для дискриминатора и еще 61 байт дополнительного пространства:
...
#[account(
init,
payer = payer,
space = 60 + 8 + std::mem::size_of::<FanoutVoucherV0>() + 1,
seeds = ["fanout_voucher".as_bytes(), mint.key().as_ref()],
bump,
)]
pub voucher: Box<Account<'info, FanoutVoucherV0>>,
...init_if_needed
Ограничение init_if_needed определяется как #[account(init_if_nedded, payer = <target_Account>)] или #[account(init)if_needed, payer = <target_account>, space = <num_bytes>)]. Оно работает точно так же, как init, но выполняется только в том случае, если аккаунт еще не существует. Если аккаунт существует, init_if_needed все равно проверяет соблюдение всех ограничений инициализации: например, выделен ли аккаунту правильный объем пространства или заданы ли правильные seeds в случае PDA.
init_if_needed следует использовать осторожно: из-за потенциальных рисков он доступен только под флагом функции. Чтобы включить его, импортируйте anchor-lang с cargo-функцией init-if-needed. При использовании init_if_needed крайне важно защититься от атак повторной инициализации. Разработчики должны добавить в код проверки, которые не позволят сбросить аккаунт в исходное состояние после инициализации, если такое поведение не предусмотрено намеренно. Чтобы снизить риск подобных атак, рекомендуется сохранять пути выполнения инструкций простыми. Можно разделить инструкции: одну использовать для инициализации, а остальные — для последующих операций.
Программа Fanout от Helium использует ограничение init_if_needed, чтобы инициализировать recipient_account, если аккаунт еще не существует:
...
#[account(
init_if_needed,
payer = payer,
associated_token::mint = mint,
associated_token::authority = recipient,
)]
pub receipt_account: Box<Account<'info, TokenAccount>>,
...constraint
Ограничение constraint определяется как #[account(constraint = <expr>)] или #[account(constraint = <expr> @ <custom_error>)]. Оно проверяет, возвращает ли переданное выражение true. Это полезно, когда ни одно другое ограничение не подходит для нужного сценария. Ограничение также поддерживает пользовательские ошибки через аннотацию @.
Программа Fanout использует constraint, чтобы проверить, равен ли объем предложения mint нулю:
...
#[account(
mut,
constraint = mint.supply == 0,
mint::decimals = 0,
mint::authority = voucher,
mint::freeze_authority = voucher,
)]
pub mint: Box<Account<'info, Mint>>,
...mint::authority, mint::decimals, mint::freeze_authority
В приведенном выше фрагменте кода ограничения mint::decimals, mint::authority и mint::freeze_authority проверяют, равна ли точность mint нулю и имеет ли voucher полномочия и полномочия заморозки.
Для контекста ограничения mint::authority, mint::decimals и mint::freeze_authority определяются следующим образом:
#[account(mint::authority = <target account>, mint::decimals = <expr>)]#[account(mint::authority = <target account>, mint::decimals = <expr>, mint::freeze_authority = <target account>)]
Назначение этих ограничений очевидно: они проверяют полномочия токена, точность и полномочия заморозки соответственно. Их можно использовать для проверки или вместе с init, чтобы создать mint-аккаунт с заданной точностью и полномочиями mint. При использовании с init полномочия заморозки полностью необязательны. При использовании для проверки можно указать только часть этих ограничений.
Пространство аккаунта
Для каждого аккаунта, который использует программа в Solana, необходимо явно выделить пространство хранилища. Это важно для эффективного управления ресурсами и гарантирует, что в блокчейне хранятся только необходимые данные. Такой подход также обеспечивает предсказуемую стоимость транзакций и повышает эффективность их выполнения: транзакции обрабатываются без динамического выделения или изменения размера хранилища аккаунта. Кроме того, предварительное выделение пространства гарантирует, что в аккаунте достаточно места для всех необходимых данных. Это снижает риск сбоев транзакций и потенциальных уязвимостей безопасности.
Переменные для расчета размера
Разным типам данных требуется разный объем пространства. Ниже приведено упрощенное руководство по оценке требований к пространству:
- Базовые типы: простые типы данных, такие как bool, u8, i8, u16, i16, u32, i32, u64, i64, u128 и i128, имеют фиксированный размер. Он варьируется от 1 байта для
bool(хотя используется только 1 бит) до 16 байт дляu128/i128 - Массивы: для массива
[T;amount]пространство вычисляется как размерT, умноженный на количество элементов (то естьamount). Например, массив из 16 элементовu16потребует 32 байта - Pubkey: публичный ключ в Solana всегда занимает 32 байта
- Динамические типы:
StringиVec<T>требуют особого внимания. Для хранения длины каждому из них требуется 4 байта плюс пространство для фактического содержимого. Важно выделить достаточно пространства для максимально ожидаемого размера. ДляStringэто 4 байта плюс длинаStringв байтах. ДляVec<T>это 4 байта плюс размер заданного типа, умноженный на ожидаемое количество элементов (то есть 4 + space(T) * amount) - Опции и перечисления: типу
Option<T>требуется 1 байт плюс пространство для типаT. Перечислениям требуется 1 байт для дискриминатора перечисления плюс пространство, необходимое для самого крупного варианта - Числа с плавающей точкой: типы
f32иf64занимают 4 и 8 байт соответственно. Будьте осторожны со значениями NaN, поскольку они могут привести к сбою сериализации
Следующее руководство применимо только к аккаунтам, которые не используют сериализацию zero-copy. Сериализация без копирования обозначается атрибутом #[zero_copy]. Для размещения данных в памяти она использует атрибут repr(c), который позволяет получать доступ к данным прямым приведением указателя. Это эффективный способ работы с данными в блокчейне без накладных расходов традиционной десериализации. #[zero_copy] — сокращенная форма применения #[derive(Copy, Clone)], #[derive(bytemuck::Zeroable)], #[derive(bytemuck::Pod)] и #[repr(C)]. Эти атрибуты позволяют безопасно рассматривать аккаунт как последовательность байтов и обеспечивают совместимость с десериализацией без копирования. Десериализация без копирования критически важна для аккаунтов, которым требуется очень большой объем пространства, — аккаунтов, которые невозможно эффективно сериализовать с помощью Borsh или стандартных механизмов сериализации Anchor без превышения ограничений кучи или стека.
Внутренний дискриминатор Anchor
Разработчики должны прибавлять 8 к ограничению space для внутреннего дискриминатора Anchor. Например, если аккаунту требуется 32 байта, необходимо выделить 40. Рекомендуется задавать ограничение пространства как space = 8 + <account size>, чтобы явно показать, что внутренний дискриминатор учтен при расчете пространства.
Дискриминатор — это уникальный идентификатор, используемый для различения типов данных. Он позволяет различать типы структур данных аккаунтов во время выполнения. Он также используется как префикс инструкций и помогает направлять их соответствующим методам программы Anchor. Дискриминатор представляет собой 8-байтовый массив с уникальным идентификатором типа данных.
Расчет начального пространства
Рассчитать начальные требования к пространству аккаунта может быть непросто. Макрос InitSpace добавляет константу INIT_SPACE, которую можно использовать в структуре аккаунта. Для генерации константы структура не обязана содержать макрос #[account]. В документации Anchor приведен следующий пример:
#[account]
#[derive(InitSpace)]
pub struct ExampleAccount {
pub data: u64,
// max_len represents the length of the structure
#[max_len(50)]
pub string_one: String,
#[max_len(10, 5)]
pub nested: Vec<Vec<u8>>,
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
#[account(init, payer = payer, space = 8 + ExampleAccount::INIT_SPACE)]
pub data: Account<'info, ExampleAccount>,
}В этом примере ExampleAccount::INIT_SPACE автоматически вычисляет пространство, необходимое для ExampleAccount. При расчете также учитывается внутренний дискриминатор Anchor.
Изменение пространства программы
Ограничение realloc используется для изменения пространства аккаунта программы в начале инструкции. Аккаунт должен быть изменяемым (то есть mut), а ограничение применимо к типам Account или AccountLoader. Оно определяется как #[account(realloc = <space>, realloc::payer = <target>, realloc::zero = <bool>)]. При увеличении длины данных аккаунта лампорты переводятся из realloc::payer в аккаунт программы для сохранения освобождения от арендной платы. Если длина данных уменьшается, лампорты возвращаются из аккаунта программы в realloc::payer. Ограничение realloc::zero определяет, нужно ли инициализировать новую выделенную память нулями. Нулевая инициализация гарантирует, что новая память чиста и не содержит остаточных или нежелательных данных.
Не рекомендуется использовать AccountInfo::realloc вручную вместо ограничения realloc. При ручном использовании отсутствуют проверки среды выполнения, которые гарантируют, что повторное выделение памяти не превышает лимит MAX_PERMITTED_DATA_INCREASE. Это может привести к перезаписи данных в других аккаунтах. Ограничение также проверяет и предотвращает повторное выделение памяти в рамках одной инструкции.
Например:
#[derive(Accounts)]
pub struct Data {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
mut,
seeds = [b"data"],
bump,
realloc = 8 + std::mem::size_of::<()>() + 48,
realloc::payer = payer,
realloc::zero = false
)]
pub update_account: Account<'info, NewData>,
system_program: Program<'info, System>,
}Ошибки
Обработка ошибок — важнейшая часть разработки программ. Это механизм выявления ошибок, способных остановить выполнение программы, и управления ими. Обработку ошибок необходимо продумывать заранее, чтобы обеспечить качество, сопровождаемость и работоспособность кода. Anchor упрощает эту задачу благодаря надежным механизмам обработки ошибок. Ошибки в программах Anchor можно разделить на AnchorErrors и ошибки, не относящиеся к Anchor. В этом разделе рассматривается AnchorErrors, поскольку ошибки вне Anchor охватывают широкий спектр ошибок Rust. Для ошибок вне Anchor рекомендуем изучить главу об обработке ошибок в Rust Book и раздел об обработке ошибок в Rust By Example.
Следующий struct определяет AnchorError:
pub struct AnchorError {
pub error_name: String,
pub error_code_number: u32,
pub error_msg: String,
pub error_origin: Option<ErrorOrigin>,
pub compared_values: Option<ComparedValues>,
}Эти поля достаточно просты. error_name — строка с названием ошибки. error_code_number — уникальный идентификатор ошибки, то есть уникальное беззнаковое целое число размером 32 бита. error_msg — описательное сообщение с объяснением ошибки. error_origin — необязательное поле с информацией о месте возникновения ошибки, например исходном файле или связанном аккаунте. compared_values — необязательное поле со значениями, которые сравнивались при возникновении ошибки. Оно особенно полезно при отладке.
AnchorError реализует метод журналирования. Он выводит сведения об источнике ошибки и связанных значениях, что помогает при отладке и устранении ошибок. Для предоставления этой информации метод использует error_origin и compared_values.
AnchorError можно дополнительно разделить на внутренние и пользовательские ошибки Anchor. Anchor может возвращать длинный список кодов внутренних ошибок. Эти внутренние ошибки не предназначены для использования пользователями. Однако полезно знать соответствие между кодами и их причинами. Обычно они возникают при нарушении ограничения. Коды внутренних ошибок соответствуют следующей схеме:
- >= 100 — коды ошибок инструкций
- >= 1000 — коды ошибок IDL
- >= 2000 — коды ошибок ограничений
- >= 3000 — коды ошибок аккаунтов
- >= 4100 — коды прочих ошибок
- = 5000 — устаревшие коды ошибок.
Пользовательские ошибки начинаются со значения ERROR_CODE_OFFSET, то есть с 6000.
Разработчики могут реализовать собственные ошибки с помощью атрибута error_code. Этот атрибут применяется к перечислению, варианты которого можно использовать как ошибки во всей программе. Для каждого варианта можно добавить сообщение. При возникновении ошибки клиент может показать это сообщение. Например:
#[error_code]
pub enum HeliusError {
#[msg(“This RPC provider is too good”)]
RPCTooGood
}Для генерации этих ошибок можно использовать макросы err! и error!. Например:
require!(rpc.speed > 9000, HeliusError::RPCTooGood);Важно учитывать, что доступно несколько макросов require. Подавляющее большинство этих макросов предназначено для значений, не являющихся публичными ключами. Например, макрос require_gte проверяет, что первое значение, не являющееся публичным ключом, больше или равно второму:
pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
require_gte!(ctx.accounts.data.data, 1);
ctx.accounts.data.data = data;
Ok(());
}При сравнении публичных ключей также есть несколько нюансов. Например, разработчикам следует использовать require_keys_eq вместо require_eq, поскольку второй вариант обходится дороже.
Все программы возвращают ProgramError. Этот тип ошибки содержит специальное поле для номера пользовательской ошибки, в котором Anchor хранит внутренние и пользовательские коды ошибок. Однако это лишь одно число, поэтому его информативность ограничена. Упомянутое выше журналирование AnchorError в Anchor значительно полезнее. Клиенты Anchor умеют анализировать эти журналы. Однако в некоторых сценариях это может быть непросто. Например, получить журналы обработанных транзакций с отключенными предварительными проверками не так просто. Кроме того, Anchor использует резервный механизм для программ вне Anchor или устаревших программ, которые не записывают AnchorError в журнал стандартным способом. В этом случае Anchor проверяет, соответствует ли номер ошибки, возвращенный транзакцией, внутреннему коду ошибки Anchor или номеру ошибки из IDL программы. При обнаружении совпадения Anchor дополняет информацию об ошибке контекстом. Когда это возможно, Anchor также пытается разобрать стек ошибок программы, чтобы найти исходную причину ошибки. ProgramError служит базовым типом ошибки, а механизмы журналирования и разбора Anchor расширяют его возможности и предоставляют подробную информацию об ошибке.
Межпрограммные вызовы (CPI)
Межпрограммные вызовы (CPI) уже упоминались в этой статье, поэтому им стоит посвятить отдельный раздел. CPI лежат в основе компонуемости Solana, поскольку позволяют программам напрямую вызывать другие программы. В результате экосистема Solana превращается для разработчиков в обширный взаимосвязанный API. Для краткости рекомендуем изучить документацию Anchor по CPI, где приведен полезный пример работы CPI с программами марионетки и кукловода.
CPI можно определить как вызов одной программой другой, направленный на конкретную инструкцию вызываемой программы. Выполнение вызывающей программы приостанавливается, пока вызванная программа не завершит обработку инструкции.
Повышение привилегий
CPI позволяет вызывающей программе передавать свои привилегии подписанта вызываемой программе. Передача привилегий удобна, но потенциально очень опасна. Если CPI по ошибке направлен вредоносной программе, она получает те же привилегии, что и вызывающая сторона. Anchor снижает этот риск с помощью двух защитных механизмов:
- Тип
Program<’info, T>гарантирует, что указанный аккаунт соответствует ожидаемой программе (T) - Даже если тип
Programне используется, автоматически сгенерированная функция CPI проверит, что аргументcpi_programсоответствует ожидаемой программе
Выполнение CPI
Программа может выполнить CPI с помощью invoke или invoke_signed из крейта solana_program. Anchor также предоставляет структуру CpiContext для указания входных данных CPI, не являющихся аргументами.
invoke
Функция invoke используется, когда подпись PDA не требуется. В этом случае среда выполнения передает исходную подпись вызывающей программы вызываемой программе. Функция определяется следующим образом:
pub fn invoke(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>]
) -> ProgramResultВызов другой программы предполагает создание Instruction, содержащего ID программы, данные инструкции для вызываемой программы и список аккаунтов, к которым она получит доступ. Программа получает значения AccountInfo от среды выполнения только в своей точке входа. Любой аккаунт, необходимый вызываемой программе, должен быть включен и предоставлен вызывающей программой. Например, если вызываемой программе нужно изменить определенный аккаунт, вызывающая программа должна включить этот аккаунт в список значений AccountInfo. Это также относится к ID вызываемой программы: вызывающая сторона должна явно указать вызываемую программу, включив ее ID.
Instruction обычно создается внутри вызывающей программы, хотя его можно десериализовать из внешнего результата.
Если вызываемая программа столкнется с ошибкой или аварийно завершит работу, вся транзакция немедленно завершится сбоем. Это связано с тем, что функция invoke возвращает управление только при успешном выполнении. Чтобы вернуть данные как результат CPI, используйте функции set_return_data или get_return_data. Возвращаемый тип должен реализовывать трейты AnchorSerialize и AnchorDeserialize. Другой вариант — записать данные из вызываемой программы в отдельный аккаунт
Программа может рекурсивно вызывать саму себя, но косвенные рекурсивные вызовы другой программой, то есть повторный вход, немедленно приведут к сбою транзакции.
Например, если программа переводит токены через CPI, invoke используется следующим образом:
pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
require_gte!(ctx.accounts.data.data, 1);
ctx.accounts.data.data = data;
Ok(());
}invoke_signed
invoke_signed используется для CPI, которым требуется PDA в качестве подписанта. Функция позволяет вызывающей программе действовать от имени PDA, предоставляя необходимые для его создания сиды:
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> ProgramResultPDA также могут выступать подписантами в CPI. Среда выполнения использует предоставленные сиды и program_id вызывающей программы, чтобы создать PDA внутри системы через create_program_address. Затем PDA сверяется с адресами, переданными в инструкции (то есть account_infos), чтобы подтвердить, что он является допустимым подписантом.
Эта функция позволяет вызову подписывать данные от имени одного или нескольких PDA, контролируемых вызывающей программой. Благодаря этому вызываемая программа может взаимодействовать с указанными аккаунтами так, словно они подписаны криптографически. signer_seeds состоит из срезов сидов, используемых для создания PDA. Во время вызова среда выполнения считает любой соответствующий аккаунт в account_info «подписанным». Например, если программа создает аккаунт для PDA, invoke_signed вызывается следующим образом:
invoke_signed(
&system_instruction::create_account(
&payer.key,
&vault_pda.key,
lamports,
vault_size,
&program_id,
),
&[
payer.clone(),
vault_pda.clone(),
],
&[
&[
b"vault",
payer.key.as_ref(),
&[vault_bump_seed],
],
]
)?;CpiContext
Anchor предоставляет CpiContext как более простой способ выполнения CPI вместо invoke или invoke_signed. Эта структура задает необходимые входные данные CPI, не являющиеся аргументами, и по функциональности во многом соответствует Context. Она содержит информацию об аккаунтах, необходимых инструкции, дополнительных задействованных аккаунтах, ID вызываемой программы и сидах для создания PDA, если они нужны. Используйте CpiContext::new для CPI без PDA и CpiContext::new_with_signer для CPI, которым требуются подписанты PDA.
CpiContext определяется следующим образом, где T — универсальный тип, охватывающий любой объект, который реализует трейты ToAccountMetas и ToAccountInfos<’info>:
pub struct CpiContext<'a, 'b, 'c, 'info, T>where
T: ToAccountMetas + ToAccountInfos<'info>,{
pub accounts: T,
pub remaining_accounts: Vec>,
pub program: AccountInfo<'info>,
pub signer_seeds: &'a [&'b [&'c [u8]]],
}Accounts — универсальный тип, допускающий любой объект, который реализует трейты ToAccountMetas и ToAccountInfos<’info>. Это обеспечивает макрос атрибута #[derive(Accounts)], который упрощает организацию кода и повышает безопасность типов.
CpiContext упрощает вызов программ Anchor и программ вне Anchor. Для программ Anchor достаточно объявить зависимость в файле проекта Cargo.toml и использовать модуль cpi, сгенерированный Anchor:
[dependencies]
callee = { path = "../callee", features = ["cpi"]}Настройка features = [“cpi”] предоставляет программе доступ к модулю callee::cpi. Anchor автоматически генерирует этот модуль и предоставляет инструкции программы как функцию Rust. Эта функция принимает CpiContext и любые дополнительные данные инструкции. Ее формат соответствует обычным функциям инструкций в программах Anchor, но CpiContext заменяет Context. Модуль cpi также предоставляет структуры аккаунтов, необходимые для вызова инструкций.
Например, если у вызываемой программы есть инструкция hello_there, которой требуются определенные аккаунты из структуры GeneralKenobi, вызовите ее следующим образом:
// We assume "jedi" is an Anchor program with a published crate
use jedi::cpi::accounts::GeneralKenobi;
use jedi::cpi::hello_there;
use anchor_lang::prelude::*;
#[program]
pub mod fight_on_utapau {
use super::*;
pub fn call_hello_there(ctx: Context<CallGeneralKenobi>, data: GreetingParams) -> Result<()> {
let cpi_accounts = GeneralKenobi {
jedi: ctx.accounts.jedi.to_account_info(),
// Other account infos needed for the GeneralKenobi struct go here
};
let cpi_program = ctx.accounts.jedi_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
hello_there(cpi_ctx, data);
}
#[derive(Accounts)]
pub struct CallGeneralKenobi<'info> {
pub jedi: UncheckedAccount<'info>,
pub jedi_program: Program<'info, Jedi>,
// Other required accounts
}
pub struct GreetingParams {
// Params required for the hello_there function
}В модуле fight_on_utapau CPI выполняется с помощью CpiContext. Функция call_hello_there предназначена для взаимодействия с программой jedi. Она создает CpiContext с необходимой информацией об аккаунтах для структуры аккаунтов GeneralKenobi из программы jedi, а также информацией об аккаунте программы jedi. Этот контекст вызывает hello_there и передает дополнительные обязательные параметры, заданные структурой GreetingParams. Структура CallGeneralKenobi определяет аккаунты, необходимые этой функции, и упрощает процесс.
Наконец, при вызове инструкций программ вне Anchor проверьте, опубликовали ли сопровождающие программы собственный крейт со вспомогательными функциями для ее вызова. Если для программы, инструкции которой необходимо вызвать, таких функций нет, используйте invoke и invoke_signer для организации и подготовки CPI.
Адреса, производные от программы (PDA)
Помните, что PDA находятся вне кривой и не имеют связанного приватного ключа. Они позволяют программам подписывать инструкции, а разработчикам — создавать в блокчейне структуры, подобные хеш-таблицам. PDA создается с помощью списка необязательных сидов, bump-сида и ID программы.
Повторим: следующие ограничения используются, чтобы проверить, что указанный аккаунт является PDA, созданным на основе текущей выполняемой программы, сидов и, если он указан, bump:
#[account(seeds = <seeds>, bump)]#[account(seeds = <seeds>, bump, seeds::program = <expr>)]#[account(seeds = <seeds>, bump = <expr>)]#[account(seeds = <seeds>, bump = <expr>, seeds::program = <expr>)]
Если bump не указан, Anchor использует канонический bump. Seeds::program = <expr> можно использовать для создания PDA на основе программы, отличной от выполняемой в данный момент.
Ограничения seeds и bump упрощают процесс создания:
#[derive(Accounts)]
struct ExamplePDA<'info> {
#[account(seeds = [b"example"], bump)]
pub example_pda: Account<'info, AccountType>,
}Здесь для создания PDA используется ограничение seeds. Anchor автоматически проверяет, что аккаунт, переданный инструкции, соответствует PDA, созданному на основе сидов. Если ограничение bump используется без конкретного значения, Anchor по умолчанию применяет канонический bump.
Anchor также поддерживает динамические сиды на основе полей других аккаунтов или данных инструкции. Для этого можно ссылаться на другие поля структуры или использовать макрос атрибута #[instruction(...)], чтобы включить десериализованные данные инструкции. Например, в следующей структуре example_pda ограничен комбинацией статического сида, данных инструкции и публичного ключа подписанта:
#[derive(Accounts)]
#[instruction(instruction_data: String)]
pub struct ExamplePDA<'info> {
#[account(seeds = [b"example", signor.key().as_ref(), instruction_data.as_bytes()], bump)]
pub example_pda: Account<'info, AccountType>,
#[account(mut)]
pub signoooorrr: Signer<'info>
}Заключение
Назвать Anchor мощным фреймворком — значит сильно преуменьшить его возможности. Мы рассмотрели различные макросы и типажи, с помощью которых Anchor сокращает объем кода, и убедились, насколько он упрощает процесс разработки. Проект располагает актуальной документацией и развитой экосистемой обучающих материалов и крейтов. Anchor любят и используют подавляющее большинство разработчиков Solana.
Эта статья — очень, очень подробное руководство по разработке программ на Anchor. Мы рассмотрели установку Anchor, работу с Solana Playground, а также создание, сборку и развертывание программы Hello, World! Затем мы изучили методы эффективной абстракции в Anchor, структуру типичной программы Anchor и множество доступных типов аккаунтов и ограничений. Мы также разобрали важность выделения пространства для аккаунтов и обработки ошибок. В завершение мы рассмотрели CPI и PDA. Это главная статья об Anchor — в ней есть все, что нужно, чтобы начать разрабатывать программы на Solana уже сегодня.
Если вы дочитали до этого места — спасибо, анон! Обязательно укажите ниже свой адрес электронной почты, чтобы не пропускать новости Solana. Готовы погрузиться глубже? Заходите в наш Discord, чтобы начать разрабатывать программы на Anchor.
Дополнительные ресурсы
Похожие статьи
Подпишитесь на Helius
Следите за новостями разработки на Solana и получайте уведомления о наших публикациях


