В прошлой статье на Хабре я рассказывал о том, как построил небольшое приложение — 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
Спасибо за внимание! 🚀
Источник: habr.com



























