Введение
В 2024 году Solana стремительно набрала популярность, заняв четвертое место среди публичных блокчейнов по объему заблокированных средств (TVL) и привлекая внимание инвесторов и разработчиков.
Компания BlockSec подготовила серию материалов «Solana: просто о сложном» (Solana Simplified), которая включает в себя статьи об основных концепциях Solana, руководства по написанию смарт-контрактов Solana и инструкции по анализу транзакций в сети. Цель этой серии — помочь читателям разобраться в экосистеме Solana и освоить ключевые навыки для разработки проектов и проведения транзакций в Solana.
В первой статье этой серии мы подробно рассмотрели ключевые концепции сети Solana, включая механизм ее работы, модель аккаунтов и транзакции, заложив прочный фундамент для написания корректных и высокопроизводительных контрактов Solana.
В этой статье мы покажем вам, как написать программу для Solana (т.е. смарт-контракт Solana) для публикации и отображения статей. Это поможет закрепить знания из первой статьи и познакомит вас с некоторыми функциями Solana, которые мы еще не обсуждали. Соответствующая программа и код для тестирования были опубликованы на GitHub.
Настройка окружения
Примечание: Следующие команды охватывают только систему Ubuntu. Некоторые команды могут не работать в системах Windows и macOS.
👉 Вы можете использовать альтернативные команды или обратиться к разделу «Setup local development» и установить Solana CLI для решения этой задачи.
Мы будем компилировать и развертывать программы Solana в локальном окружении. Прежде чем приступить к разработке программ для Solana, необходимо установить некоторые команды и зависимости.
Rust
Программы для Solana преимущественно пишутся на языке программирования Rust, поэтому для его установки необходимо выполнить следующую команду:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Node.js и TypeScript
Тестовый скрипт написан на TypeScript, поэтому нам нужно установить Node.js и инструментарий TypeScript:
curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs npm
npm install -g typescript
npm install -g ts-node
Solana CLI
Далее нам нужно установить инструмент командной строки Solana CLI, который предоставляет команды для таких задач, как создание кошельков и развертывание контрактов:
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
Выполните следующую команду, чтобы применить изменения в текущем терминале:
source ~/.profile
Вы можете выполнить solana --version, чтобы убедиться, что Solana CLI успешно установлен:
$ solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)
Локальный кошелек
Затем выполните следующую команду для создания локального файлового кошелька:
solana-keygen new
По умолчанию эта команда создает файл закрытого ключа по адресу ~/.config/solana/id.json, который мы будем использовать в тестовом скрипте позже. Вы можете использовать команду solana address, чтобы увидеть сгенерированный адрес:
$ solana address
9FcGDHvzs8Czo4B1pQq8GPdXfdGHqNqayvh6bu4hVGfy
Devnet
Программа будет развернута в сети devnet, поэтому нам нужно выполнить следующую команду, чтобы переключить Solana CLI на devnet:
solana config set --url https://api.devnet.solana.com
Токены SOL
Поскольку развертывание контрактов и отправка тестовых транзакций не бесплатны, нам нужно запросить немного токенов SOL. Вы можете выполнить solana airdrop 2 или напрямую запросить токены через публичный веб-кран (faucet).
Написание программы
Программа, которую мы собираемся представить, позволяет пользователям публиковать статьи и просматривать список всех опубликованных на данный момент статей. Она обрабатывает три типа инструкций:
- init: инициирует программу, создавая аккаунт данных для хранения текущего максимального индекса статей.
- post: сохраняет опубликованную статью в новом аккаунте данных.
- list: выводит все опубликованные статьи в лог.
Ниже представлена структура программы:
$ tree program/
program/
├── Cargo.lock
├── Cargo.toml
└── src
├── instructions
│ ├── init.rs
│ ├── list.rs
│ ├── mod.rs
│ └── post.rs
├── lib.rs
└── processor.rs
Cargo.toml определяет внешние библиотеки для проекта:
[dependencies]
borsh = "0.10.0"
borsh-derive = "0.10.0"
solana-program = "=1.17.25"
Примечание: Версия
solana-programдолжна совпадать с версией установленного Solana CLI (вы можете проверить это с помощьюsolana --version). В противном случае могут возникнуть ошибки во время компиляции.
Точка входа контракта определена в processor.rs. Файл начинается с импорта необходимых зависимостей (мы пропустим аналогичные части в других файлах Rust):

Далее следует описание функции входа в контракт:

В строке 10 программы макрос entrypoint! из библиотеки Solana указывает, что функцией входа является process_instruction. Эта функция принимает три параметра:
- program_id: адрес развернутой текущей программы.
- accounts: все аккаунты, участвующие в инструкции.
- instruction_data: массив байтов, используемый для обработки инструкций.
process_instruction извлекает первый байт instruction_data, чтобы определить тип инструкции, и вызывает соответствующую функцию для ее обработки. Далее мы рассмотрим, как реализованы эти три функции.
init
Каждая из трех функций определена в файле с аналогичным именем в директории program/src/instructions. Начнем с init.rs.

Поскольку инструкция init не требует дополнительного байтового массива, она принимает только два параметра: program_id и accounts.
В строках 12–15 программа последовательно извлекает необходимую информацию об аккаунте, а затем вызывает функцию find_program_address для вычисления адреса аккаунта данных, который используется для хранения текущего максимального индекса статьи, index_pda_key. Затем программа проверяет, что index_pda_key совпадает с адресом index_pda.
Здесь адрес index_pda является особым типом адресов, называемым PDA (Program Derived Addresses — адреса, производные от программы). В отличие от адресов «обычных» аккаунтов-кошельков, генерируемых из открытых ключей, PDA выводятся из необязательного байтового массива, байта «bump» и адреса программы. Байтовый массив и адрес программы предоставляются вызывающим лицом явно, а значение bump автоматически генерируется и возвращается функцией find_program_address. Bump гарантирует, что адреса, созданные таким образом, не смогут совпасть с адресами, сгенерированными на основе открытых ключей.

После подтверждения того, что адрес index_pda верный, мы используем инструкцию create_account, предоставляемую SystemProgram, для создания index_pda. В строке 22 программа создает переменную типа IndexAccountData, которая записывает текущий максимальный индекс статей (инициализируется нулем). Как показано на рисунке ниже, этот тип реализует трейты BorshSerialize и BorshDeserialize, что позволяет сериализовать и десериализовать его в формате Borsh:

Строки 23–24 вычисляют требуемое пространство для хранения данных аккаунта и минимальную арендную плату (rent) для создания аккаунта.
Строки 27–37 создают инструкцию create_account от SystemProgram и вызывают ее с помощью метода invoke_signed. Действие по вызову инструкции другой программы внутри одной программы называется CPI (Cross-Program Invocation — кросс-программный вызов). В Solana для выполнения CPI можно использовать две функции: invoke и invoke_signed.
Функция invoke напрямую обрабатывает заданную инструкцию, ее сигнатура выглядит так:
pub fn invoke(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>
Единственное различие между invoke_signed и invoke заключается в том, что invoke_signed предоставляет способ подписи для аккаунтов PDA. У аккаунтов PDA нет пар закрытых/открытых ключей, поэтому они не могут предоставлять подписи напрямую. invoke_signed решает эту проблему. Эта функция принимает дополнительный параметр signers_seeds. Ее сигнатура выглядит так:
Аналогично работе функции find_program_address, invoke_signed вычисляет набор PDA-адресов на основе предоставленных signers_seeds и program_id вызывающей стороны. Если account_infos содержит эти PDA-адреса, они будут помечены как подписанты (signers), как если бы они предоставили подпись.
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>
Здесь, поскольку инструкция create_account требует, чтобы создаваемый аккаунт был подписантом, мы вынуждены использовать invoke_signed, чтобы предоставить подпись для index_pda.

В конце программы контракт вызывает метод serialize для index_data, чтобы сериализовать данные и записать их в поле data аккаунта index_pda.
post_article

Теперь давайте рассмотрим реализацию инструкции post. В отличие от init, инструкция post принимает текст статьи, поэтому у нее есть дополнительный параметр instruction_data_inner, который хранит сериализованные данные статьи.
Как обычно, программа начинается с извлечения информации об аккаунтах, необходимой для выполнения инструкции. Поскольку мы генерируем отдельный PDA-аккаунт для каждой статьи, программа принимает дополнительный аккаунт new_article_pda в строке 23.
Как и в инструкции init, в строках 27–30 используется find_program_address для проверки корректности переданного адреса index_pda. Затем мы десериализуем данные в его поле data, чтобы прочитать текущий максимальный выделенный индекс.
Строки 33–40 также проверяют адрес new_article_pda. Программа использует строку «ARTICLE_PDA» и текущий индекс для генерации нового адреса. Индекс увеличивается с каждой загрузкой статьи, что гарантирует уникальность сгенерированного адреса.

Далее программа десериализует instruction_data_inner в данные статьи, которые имеют тип PostArticleData:

Эта структура имеет только два поля: title (заголовок) и content (контент). В строках 45–49 программа выполняет проверку, чтобы убедиться, что статья не слишком велика, чтобы уместиться в одном аккаунте.

Следующие шаги также аналогичны инструкции init: программа сначала вычисляет необходимое пространство и арендную плату для аккаунта, а затем вызывает инструкцию create_account у SystemProgram для создания PDA-аккаунта для хранения статьи. В завершение опубликованная статья сериализуется в поле data аккаунта new_article_pda, а текущий максимальный индекс увеличивается на единицу и сериализуется в поле data аккаунта index_pda.
list_articles

Наконец, давайте рассмотрим реализацию инструкции list. Поскольку эта инструкция должна вывести список всех статей, в строке 15 используется вектор для представления всех PDA-аккаунтов, хранящих статьи, а адрес аккаунта index_pda также проверяется.

После этого программа проверяет, что размер вектора совпадает с текущим максимальным индексом, и проверяет корректность адреса каждого аккаунта.

В самом конце программы она выполняет итерацию по каждому аккаунту и использует макрос msg! из библиотеки solana-program для поочередного вывода десериализованного контента статей.
Тестирование транзакций
Для тестирования нашего контракта мы используем TypeScript-скрипт, расположенный в client/main.ts в репозитории.

В начале скрипта мы импортируем все необходимые библиотеки и определяем три глобальные константы. KEYPAIR_PATH указывает путь к файлу закрытого ключа, сгенерированного с помощью solana-keygen new, PROGRAM_ID — это адрес развернутой программы, которую мы написали в предыдущем разделе, а POST_ARTICLE_SCHEMA — объект, используемый для сериализации статьи.


В основной части скрипта сначала вызывается функция loadKeyFromFile для парсинга файла закрытого ключа и получения объекта Keypair, который будет использоваться для оплаты комиссий за транзакции. Затем используется метод findProgramAddressSync, предоставляемый @solana/web3.js, для вычисления адреса indexPda. Этот метод использует тот же алгоритм, что и контракт на Rust, гарантируя вычисление того же адреса с теми же параметрами. Объект connection указывает, что мы будем использовать devnet для тестирования.

Затем мы отправляем первую транзакцию инициализации. Мы создаем транзакцию, содержащую одну инструкцию. Поле data этой инструкции содержит только один байт "0", что указывает на вызов инструкции init. Затем транзакция отправляется и подтверждается с помощью sendAndConfirmTransaction.

Вторая транзакция вызывает инструкцию post для публикации статей. В строках 56–63 скрипт сериализует две статьи и вычисляет соответствующие PDA-адреса. Затем в одну транзакцию добавляются две инструкции post для создания этих статей.

Финальная транзакция вызывает инструкцию list, после чего логи выводятся в консоль после подтверждения транзакции.
Чтобы протестировать программу с помощью скрипта, замените KEYPAIR_PATH на путь к вашему закрытому ключу (если он отличается от пути по умолчанию). Затем скомпилируйте и разверните смарт-контракт на Rust, выполнив следующие команды:
cd program
cargo build-bpf
solana program deploy ./target/deploy/hello_solana.so
Затем скопируйте адрес развернутой программы и вставьте его в поле PROGRAM_ID. В корневой директории проекта выполните npm install для установки зависимостей, а затем запустите npm start для выполнения скрипта.
После этого мы можем использовать Solscan для просмотра деталей выполнения тестовых транзакций.

Давайте взглянем на вторую транзакцию публикации статей. Она содержит две инструкции, и у каждой из них первый байт в Instruction Data Raw равен 0x01. Кроме того, каждая инструкция включает внутреннюю инструкцию — вызов инструкции CreateAccount системной программы (System Program).

Аналогичным образом мы можем проверить третью транзакцию для вывода всех статей. В разделе Program Logs можно увидеть, что индексы, заголовки и контент двух статей успешно выведены.
Заключение
В этой статье мы сначала объяснили, как локально настроить среду программирования и выполнения для Solana. Затем мы подробно описали логику реализации контракта Solana. Наконец, мы протестировали функции контракта с помощью TypeScript-скрипта. Мы уверены, что благодаря этому пошаговому руководству вы научились писать простые смарт-контракты для Solana 🥳.
В следующей статье мы предоставим подробное руководство о том, как просматривать и анализировать транзакции Solana с помощью Phalcon Explorer (теперь Phalcon Explorer поддерживает Solana). Следите за обновлениями!



