В прошлой статье на Хабре я рассказывал о том, как построил небольшое приложение - 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), приложение:

  1. Отправляет результат в Kafka.

  2. Rust-сервис получает событие и начисляет награду.

  3. В зависимости от режима (SOLANA_ON_CHAIN=true/false) награда выдаётся:

    • либо on-chain - транзакцией в Solana,

    • либо off-chain - через локальное API.

  4. Rust подтверждает награду через Kafka.

  5. Node.js помечает награду как выданную.

  6. Если достигнут 7-дневный стрик полностью правильных ответов - генерируется NFT.

Solana Quiz Streaker: 7 Days
Solana Quiz Streaker: 7 Days

? Работа с 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

Спасибо за внимание! ?

Комментарии (0)