В прошлой статье на Хабре я рассказывал о том, как построил небольшое приложение - Solana Quiz, где пользователи отвечают на ежедневные вопросы и получают награды.
Вот ссылка на ту статью - чтобы не повторять архитектуру и базовые вещи:
? https://habr.com/ru/articles/956186/
С тех пор я значительно расширил функционал: я добавил on-chain награды, 7-дневные стрики и самое интересное - NFT за серию полностью правильных ответов.
Казалось бы, задача простая: получил событие - начислил токены - иногда выдал NFT.
Но за этим "иногда" скрывается целая гора нюансов: от Solana PDA до порядка вызовов в метадате и странных ошибок, которые не объяснены ни в одном официальном гайде.
В этой статье я поделюсь:
как я реализовал on-chain/off-chain награды,
почему стрики работают по-разному в этих режимах,
как устроено NFT-вознаграждение,
какие технические грабли я собрал,
и что нужно знать о последовательности команд при создании NFT (если поменять порядок - всё ломается).
? Коротко о том, как сейчас работает Solana Quiz
Когда пользователь проходит квиз (пока - всегда 5 вопросов, значение зашито в коде, позже вынесу в .env), приложение:
Отправляет результат в Kafka.
Rust-сервис получает событие и начисляет награду.
-
В зависимости от режима (SOLANA_ON_CHAIN=true/false) награда выдаётся:
либо on-chain - транзакцией в Solana,
либо off-chain - через локальное API.
Rust подтверждает награду через Kafka.
Node.js помечает награду как выданную.
Если достигнут 7-дневный стрик полностью правильных ответов - генерируется NFT.

? Работа с Solana test-validator + симуляция транзакций
? Зачем вообще нужен solana-test-validator
Когда я начал писать on-chain часть для квиза, я быстро понял одну вещь:
тестировать всё сразу на devnet - боль.
Транзакции подтверждаются дольше.
Не всегда понятно, это я ошибся или сеть.
Некоторые ошибки (особенно PDA / metadata) ловить невозможно в "боевом" сложнее.
Поэтому в Solana есть потрясающая штука - локальный валидатор:
solana-test-validator
Это полноценная локальная цепочка Solana, запускаемая одной командой.
Она работает быстро, даёт бесконечные токены и позволяет деплоить программы локально без ограничений.
После запуска RPC доступен по умолчанию на: http://127.0.0.1:8899
И теперь вы можете направить туда ваше приложение:
SOLANA_NETWORK=local
SOLANA_RPC_ENDPOINT=http://127.0.0.1:8899 # http://host.docker.internal:8899
Когда это полезно:
Когда вы пишете свои Solana программы (Anchor или чистый Rust).
Когда вы тестируете mint NFT / PDA / metadata.
Когда вы хотите массово симулировать транзакции.
Когда нужно воспроизвести ошибку быстро и повторяемо.
? Devnet, Localnet, Mainnet - в чём разница
Среда |
Для чего |
Плюсы |
Минусы |
Localnet (solana-test-validator) |
Мгновенная разработка |
мгновенные блоки, полный контроль |
не отражает реальную сеть |
Devnet |
Предпрод / публичные тесты |
реальная нагрузка, публичный RPC |
иногда тормозит, имеет лимиты |
Mainnet |
Прод |
стабильность, реальная токеномика |
дорогие транзакции |
При разработке on-chain функционала я могу уверенно сказать:
? 80% времени стоит работать на test-validator
? 20% - финальная проверка на devnet
? Как симулировать транзакции и получать читабельные ошибки
Если вы работаете с Solana без симуляций, вы теряете половину дебаг-информации.
Симуляция позволяет:
поймать ошибку ещё до отправки,
увидеть логи программ,
прочитать ошибки SPL-токенов, Metaplex и т. д.,
понять, какие PDA не совпадают,
проверить, хватает ли signer-ов,
увидеть stack trace on-chain вызовов.
let simulation = self.rpc_client.simulate_transaction(&transaction).await?;
println!("Simulation logs: {:#?}", simulation.value.logs);
Примеры логов:
Program log: Incorrect mint authority
Program log: Owner does not match
Program log: Failed to deserialize metadata
Program log: Account is not rent-exempt
Program log: Missing required signature
Program log: Edition already exists
Эти строки спасли мне массу времени.
? On-chain и Off-chain: что реально регулирует SOLANA_ON_CHAIN
Самая частая ошибка при проектировании - думать, что переменная включит или выключит выдачу наград.
Нет - награды начисляются всегда.
SOLANA_ON_CHAIN влияет только на то, как награда будет применена:
SOLANA_ON_CHAIN |
Как считается токен |
Как считается стрик |
true |
On-chain (Solana transaction) |
На блокчейне, по факту успешных транзакций |
false |
Off-chain (через API, Rust) |
Локально в Rust, без взаимодействия с Solana |
Почему так?
Потому что логика стрика должна быть консистентной.
Если я работаю on-chain - я полагаюсь на реальные транзакции.
Если off-chain - быстрее и дешевле считать локально.
? Самая интересная часть: NFT за стрики
NFT выдаётся за 7 дней полностью правильных ответов (SOLANA_STREAK_DAYS=7).
В будущем у меня в планах расширять этот функционал - например, редкости, уровни, разные типы NFT, но пока реализован только один тип.
Технически NFT создаётся следующим образом:
создаётся новый mint,
создаётся ATA для пользователя,
минтится 1 токен на ATA,
создаётся metadata (вместе с master edition).
И вот здесь начинается магия.
А точнее - боль.
? Главная боль: последовательность команд имеет значение
Если вы просто посмотрите примеры Metaplex или Solana SDK - вы не увидите предупреждений о порядке вызовов.
Я тоже не видел.
Но когда я попробовал в реальном проекте, вот что произошло:
❌ Если делать так:
create_metadata(); // создаётся metadata (вместе с master edition)
mint_token();
То NFT часто не создавался, а иногда вылетала ошибка:
"mint must have exactly 1 token minted before metadata creation"
или ещё веселее:
"MasterEdition account does not match mint supply"
Или просто транзакция падала без объяснения причин.
✔ Рабочий порядок (который я вывел экспериментами)
Вот финальная версия корректного рабочего flow:
/// Full workflow: mint NFT → create ATA → send NFT → create metadata
pub async fn mint_nft_to_recipient(&self, recipient_pubkey: &Pubkey) -> Result<()> {
// 1) Create mint
let (mint_keypair, mint_signature) = self.create_mint().await?;
println!(
"✅ Mint: {}, Signature: {}",
&mint_keypair.pubkey(),
mint_signature
);
// 2) Create user's ATA
let token_account_signature = self
.create_token_account(&mint_keypair.pubkey(), &recipient_pubkey)
.await?;
println!("✅ Token Account, Signature: {}", token_account_signature);
// 3) Mint 1 token to recipient
let one_token_signature = self
.mint_token(&mint_keypair, &recipient_pubkey)
.await?;
println!("✅ Token (NFT), Signature: {}", one_token_signature);
// 4) Create metadata + master edition
let metadata_signature = self.create_metadata(&mint_keypair).await?;
println!("✅ Metadata, Signature: {}", metadata_signature);
Ok(())
}
Почему именно так?
-
Mint должен иметь supply = 1 до создания metadata.
Если supply = 0 - это ещё "пустой" mint, Metadata не разрешит создавать NFT.
ATA должен существовать заранее, иначе mint_to_checked просто некуда будет отправить токен.
MasterEdition можно создать только после успешного mint_to_checked, что следует из логики стандартов Metaplex. Мы не делаем отдельный вызов - всё инкапсулировано в create_metadata().
Я перепробовал множество вариантов, и только эта последовательность работает стабильно - независимо от RPC и окружения.
⚠️ Очень важно быть внимательным с подписантами (signers):
Ошибки часто случаются, если неправильно указать authority или не подписан нужный Keypair.
Любая транзакция без корректного подписанта просто упадёт или вернёт непонятную ошибку.
? Совет:
Если возникли проблемы, обязательно воспользуйтесь симуляцией транзакции (описано выше). Это позволяет увидеть все ошибки и предупреждения в читаемом виде, не тратя драгоценное время и SOL на devnet или mainnet.
? Почему вообще NFT?
Причин несколько:
1. Это подкрепляет мотивацию пользователя
Стрик из 7 дней - небольшое достижение, но приятно получить что-то осязаемое в обмен на ежедневные усилия
2. Это отличный повод изучить Metaplex и Solana SDK
Проходя через боль поиска PDA, ошибок mint_to_checked и order-sensitive API, я стал понимать архитектуру куда глубже.
3. Это хороший showcase для GitHub
Сейчас весь код опубликован - и это отличная демонстрация Rust + Solana + Node.js + Kafka взаимодействия.
? Репозиторий: https://github.com/di-zed/solana-quiz
? Какие ещё технические моменты были интересными
✔ PDA для Metadata и MasterEdition надо считать вручную
Никакого удобного модуля вроде mpl_token_metadata::pda нет.
Я использую такие вычисления:
let (metadata_pubkey, _) = Metadata::find_pda(mint_pubkey);
let (master_edition_pda, _) = Pubkey::find_program_address(
&[
b"metadata",
mpl_token_metadata::ID.as_ref(),
mint_pubkey.as_ref(),
b"edition",
],
&mpl_token_metadata::ID,
);
✔ Mint authority должен существовать только до выпуска токена
✔ ATA для NFT всегда уникальный
NFT = 1 токен, 0 decimals.
Ошибки часто возникают, если случайно выставить decimals != 0.
? Что можно расширить дальше
разные типы NFT за разные streak lengths;
on-chain подсчёт стрика полностью в программе Anchor;
уровни наград;
коллекции NFT;
поддержка mainnet-beta.
Пока что я ограничился одним типом NFT и стриком в 7 дней, но вся архитектура уже позволяет безболезненно расширять функциональность - хотя для этого потребуется доработка логики и шаблонов.
⚡ Итог
Я прошёл через:
on-chain/off-chain логику начисления наград,
создание полноценных NFT на Solana,
ручное вычисление PDA,
ошибки Metaplex, которые нигде не описаны,
экспериментально вывел корректный порядок команд при создании NFT.
Теперь Solana Quiz стал не просто игрой, а сервисом, где:
токены начисляются честно и прозрачно,
streaks работают и учитываются корректно,
и за упорство можно получить свой небольшой, но настоящий NFT.
Если вам хочется посмотреть рабочий код, разобрать Rust-сервис, или сделать форк - буду рад:
? https://github.com/di-zed/solana-quiz
А так же:
Solana программа https://github.com/di-zed/solana-quiz/blob/main/rust/programs/solana_quiz_rewards/programs/solana_quiz_rewards/src/lib.rs
Пример логов: https://solscan.io/tx/2nr9QGfE2WVj2udFMdjmUWYvvatMT1BoHuouwEqnGg7VKwLtoWkZcsGvK5bMH6SbDgN6T7n5hNi1WuQcFdutBXoY?cluster=devnet
NFT API: https://github.com/di-zed/solana-quiz/blob/main/rust/src/services/nft_api.rs
Пример логов: https://solscan.io/tx/uomvJ7LJBDrQp74RUAgRve1MbweyPjJSDvnivkAk93AggygDnbrKKBMLu3TYEgQ4RkihKzfbdjURBz8wojkPNhb?cluster=devnet
Спасибо за внимание! ?