Compare commits

..

8 Commits

168 changed files with 2055 additions and 6941 deletions

View File

@ -1,180 +0,0 @@
# SHINY USER FORMAT v1.1 (DRAFT)
Документ описывает единый бинарный формат пользовательской записи в `user_pda` для программы `shine_users`.
Цель версии `v1.1`: сохранить текущую рабочую логику регистрации/обновления и добавить задел под будущую смену ключей через 3 статус-байта:
- `root_key_status`
- `blockchain_key_status`
- `device_key_status`
Сейчас во всех трех полях используется значение `0` (ключ создан и ни разу не менялся).
## 1) Общие правила кодирования
- Числа: Little Endian (`LE`).
- Строки: `UTF-8` с префиксом длины `u8`.
- Публичные ключи: 32 байта (`Pubkey`).
- Подпись: 64 байта (Ed25519).
- Размер PDA фиксированный: `USER_PDA_SPACE` (сейчас 768 байт).
- `record_len` хранит длину полезной записи от `magic` до `signature` включительно (без `padding`).
## 2) Единый список полей в порядке хранения
1. `magic`
- Размер: 5 байт
- Значение: `"SHiNE"`
- Назначение: маркер формата записи.
2. `format_major`
- Размер: 1 байт (`u8`)
- Назначение: major-версия формата.
3. `format_minor`
- Размер: 1 байт (`u8`)
- Назначение: minor-версия формата.
4. `record_len`
- Размер: 2 байта (`u16`, LE)
- Назначение: длина полезных данных записи (без `padding`).
5. `created_at_ms`
- Размер: 8 байт (`u64`, LE)
- Назначение: время создания записи (Unix time, ms).
6. `updated_at_ms`
- Размер: 8 байт (`u64`, LE)
- Назначение: время последнего обновления записи (Unix time, ms).
7. `version`
- Размер: 4 байта (`u32`, LE)
- Назначение: номер версии записи.
- Правило: при обновлении должен быть строго `old_version + 1`; это проверяется программой.
8. `prev_hash`
- Размер: 32 байта
- Назначение: хэш unsigned-предыдущей версии для связывания истории.
9. `login_len`
- Размер: 1 байт (`u8`)
- Назначение: длина поля `login` в байтах.
10. `login`
- Размер: `login_len` байт (UTF-8)
- Назначение: логин пользователя.
- Текущие ограничения: от 1 до 30 символов, только `a-z`, `0-9`, `_`.
11. `root_key_status`
- Размер: 1 байт (`u8`)
- Текущее значение: `0`
- Назначение: статус `root_key`.
- Комментарий: `0` = ключ создан и не менялся; другие статусы зарезервированы на будущее, сейчас ротация root-ключа не реализована.
12. `root_key`
- Размер: 32 байта (`Pubkey`)
- Назначение: корневой ключ пользователя для подписи записи.
13. `blockchain_key_status`
- Размер: 1 байт (`u8`)
- Текущее значение: `0`
- Назначение: статус `blockchain_key`.
- Комментарий: `0` = ключ создан и не менялся; другие статусы зарезервированы на будущее.
14. `blockchain_key`
- Размер: 32 байта (`Pubkey`)
- Назначение: рабочий блокчейн-ключ пользователя.
15. `device_key_status`
- Размер: 1 байт (`u8`)
- Текущее значение: `0`
- Назначение: статус `device_key`.
- Комментарий: `0` = ключ создан и не менялся; другие статусы зарезервированы на будущее.
16. `device_key`
- Размер: 32 байта (`Pubkey`)
- Назначение: ключ устройства пользователя.
17. `blockchain_number`
- Размер: 2 байта (`u16`, LE)
- Назначение: номер/версия пользовательского блокчейн-профиля.
- Текущее использование: базовый сценарий с одним профилем; обычно значение `1` (только основной блокчейн-профиль без форков).
18. `balance`
- Размер: 8 байт (`u64`, LE)
- Назначение: лимит/баланс пользователя.
19. `is_server`
- Размер: 1 байт (`u8`)
- Значения: `0` или `1`
- Назначение: флаг серверного профиля.
20. `server_key` (только если `is_server = 1`)
- Размер: 32 байта (`Pubkey`)
- Назначение: публичный ключ сервера.
21. `server_address_len` (только если `is_server = 1`)
- Размер: 1 байт (`u8`)
- Назначение: длина строки `server_address`.
22. `server_address` (только если `is_server = 1`)
- Размер: `server_address_len` байт (UTF-8)
- Назначение: адрес сервера.
23. `connection_servers_count`
- Размер: 1 байт (`u8`)
- Назначение: количество серверов в списке подключения.
24. Повтор `connection_servers_count` раз:
- `server_login_len` — 1 байт (`u8`)
- `server_login``server_login_len` байт (UTF-8)
- Назначение: логины серверов подключения.
25. `trusted_count`
- Размер: 1 байт (`u8`)
- Назначение: текущее число trusted-контактов.
- Текущее состояние: пока только счетчик; в текущем рабочем потоке обычно `0`, расширенная trusted-логика еще не включена.
26. `reserved`
- Размер: 5 байт
- Текущее значение: все `0x00`
- Назначение: резерв под будущие расширения.
27. `signature`
- Размер: 64 байта
- Назначение: Ed25519 подпись хэша unsigned-части записи.
28. `padding`
- Размер: до полного `USER_PDA_SPACE`
- Текущее значение: `0x00`
- Назначение: добивка до фиксированного размера PDA.
## 3) Что подписывается
Подписывается SHA-256 от unsigned-части записи:
- от `magic` до `reserved` включительно;
- без `signature`;
- без `padding`.
## 4) Что сейчас ограничено и зарезервировано на будущее
1. Статусы ключей (`root_key_status`, `blockchain_key_status`, `device_key_status`)
Сейчас только значение `0`; будущие значения для ротации ключей пока не введены.
2. Trusted-часть (`trusted_count`)
Пока хранится только счетчик. Полная логика trusted-пользователей (список, очередь, таймауты) еще не реализована.
3. `reserved` (5 байт)
Полностью нулевой резерв под будущие поля/флаги.
4. Блокчейн-профили
Сейчас рабочая модель ориентирована на один базовый профиль; расширение до нескольких профилей/форков будет отдельным этапом.
5. Версия формата
Текущий документ описывает `v1.1 (DRAFT)`. В тестовой разработке формат можно не повышать до момента внедрения новой on-chain логики.
6. Текущие рабочие операции
Сейчас по факту поддерживаются:
- регистрация пользователя (`create_user_pda`);
- обновление записи (`update_user_pda`) с увеличением `balance` через `additional_limit`;
- инкремент `version` на каждом успешном update;
- оплата уходит на адрес, заданный в `REGISTRATION_FEE_RECEIVER` (а не в DAO по умолчанию).

@ -1 +1 @@
Subproject commit 3abc3959fa56192511bdc977615f500a55022e88
Subproject commit 67c9fd0b0ce55f79d36091da199ba6b534f8ed03

View File

@ -4,9 +4,9 @@
В проекте есть спецификация пользовательской PDA-записи:
- `doc/SHINE_USER_PDA_V1.md`
- актуальные документы в `doc/`.
Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, этот документ нужно обновлять в том же изменении.
Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, соответствующую документацию в `doc/` нужно обновлять в том же изменении.
## Language Rule
@ -35,6 +35,7 @@ Push выполнять через `http.extraHeader` (Authorization) без в
## Rule: Commit Messages
Текст commit message писать на русском языке.
Это обязательное правило для всех новых коммитов в этом репозитории.
## Rule: UI Deploy

View File

@ -1,31 +0,0 @@
Деплой в devnet (по умолчанию у тебя уже devnet):
обе программы сразу:
anchor deploy
или по одной:
anchor deploy --program-name shine_users
anchor deploy --program-name shine_payments
Проверка деплоя
anchor keys list
solana program show 5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t // <ID_из_shine_users-keypair.json>
solana program show GcGFR47xF7o7ztXzN4MFThmxzHn4z6VmpmELgNk8smCm // <ID_из_shine_payments-keypair.json>
Апгрейд в будущем
После изменений кода:
anchor build
anchor upgrade --program-name shine_users
anchor upgrade --program-name shine_payments
посмотреть адрес кошелька
solana address -k /home/ai/.config/solana/id.jsonanchor build --program-name shine_payments
anchor deploy --program-name shine_payments
solana program show 92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW --url http://127.0.0.1:8899
solana address -k /home/ai/work/SOLANA/shine/target/deploy/shine_payments-keypair.json

View File

@ -1,39 +0,0 @@
# подключаться надо к
JSON RPC URL: http://127.0.0.1:8899
# Запустить саму ноду
solana-test-validator
# Удалить процесс ноды что бы запустить заново
kill -9 $(pgrep -f "solana-test-validator")
или
ps aux | grep solana-test-validator
а потом
kill -9 1052345
# Убивает и логи и всю базу локальной ноды
rm -rf test-ledger
# Удалить все данные с ноды
solana-test-validator --reset
# Что бы запустить просмотр логов ноды
solana logs
# Запустить контракт
anchor deploy
# Cкомпилировать и задеплоить новую версию
anchor build # Скомпилировать контракт и сгенерировать IDL
anchor deploy # Задеплоить контракт в сеть (указанную в Anchor.toml)
Если ты хочешь сразу убедиться, куда он деплоится — проверь Anchor.toml.
[provider]
cluster = "https://api.testnet.solana.com" # или "localnet"
wallet = "~/.config/solana/id.json"
# Создать новый проект
anchor init имя_проекта

View File

@ -1,65 +0,0 @@
https://api.devnet.solana.com
проверить настройки
solana config get
настроить
solana config set --url https://api.devnet.solana.com
или
solana config set --url http://127.0.0.1:8899
потом
solana airdrop 2 --keypair /home/ai/.config/solana/id.json
и
solana balance --keypair /home/ai/.config/solana/id.json
anchor deploy \
--provider.cluster https://api.devnet.solana.com \
--provider.wallet /home/ai/.config/solana/id.json
Шаг 1. Создай новый ключ для новой программы
solana-keygen new --outfile target/deploy/user_registry-testnet-keypair.json
Шаг 2. Укажи новый ID в declare_id!:
declare_id!("НОВЫЙ_PUBKEY_ОТСЮДА"); // получен из предыдущей команды
Чтобы узнать pubkey:
solana address -k target/deploy/user_registry-testnet-keypair.json
Шаг 3. Обнови Anchor.toml:
[programs.testnet]
user_registry = "НОВЫЙ_PUBKEY"
[provider]
cluster = "https://api.testnet.solana.com"
wallet = "~/.config/solana/id.json"
Шаг 4. Компиляция и деплой:
anchor build
anchor deploy --provider.cluster testnet
Шаг 5. Проверка:
solana program show НОВЫЙ_PUBKEY --url https://api.testnet.solana.com

View File

@ -1 +0,0 @@
Просто разные заметки для себя

View File

@ -1,50 +0,0 @@
# SHINE Payments v2: тестовый план (Devnet)
## Цель
Проверить:
1. создание PDA и инициализацию;
2. покупку обычных билетов (очередь 1);
3. выдачу DAO-лимитов менеджеру;
4. создание менеджерских билетов в очередь 1 и 2;
5. корректный приоритет выплат (очередь 1 > очередь 2);
6. перевод средств в DAO и награды вызывающему шаг выплат.
## Сценарий А: один кошелек
1. Открыть `admin_tools`, выполнить `init`.
2. В `buy_ticket` купить несколько билетов в очередь 1.
3. В `dao_tools` (тем же кошельком, если он DAO) выдать лимиты менеджеру.
4. В `manager_tools` создать билеты в очередь 1 и 2.
5. Пополнить inflow-вольт вручную.
6. В `track_ticket` запускать шаг выплат и смотреть, что:
- сначала платится очередь 1;
- после исчерпания очереди 1 платится очередь 2;
- если в процессе снова появился билет в очереди 1, приоритет возвращается к ней.
## Сценарий Б: разные кошельки
1. Кошелек DAO: выдает лимиты менеджерам.
2. Кошелек менеджера: добавляет тикеты через `manager_add_ticket`.
3. Кошельки покупателей: покупают обычные билеты.
4. Любой кошелек: вызывает `step_payout`.
## Проверка результата
1. У получателей тикетов растут балансы.
2. В DAO поступает симметричная сумма `X` при каждом шаге выплаты.
3. Вызывающий шаг выплат получает фиксированную награду.
4. Агрегаты очередей (`total/paid/sum_total/sum_paid`) изменяются ожидаемо.
## Возврат средств после теста
1. Перевести остатки SOL с тестовых кошельков на исходный кошелек.
2. При необходимости закрыть неиспользуемые program-аккаунты и вернуть ренту.
3. Проверить, что ключевые кошельки и devnet-параметры не содержат лишних средств/прав после завершения теста.
## Ограничения текущего этапа
1. DAO пока заменен обычным кошельком.
2. Финальный governance (голосование DAO) не подключен.
3. Расчеты пока в SOL/lamports, переход на USDT по курсу запланирован.

View File

@ -1,115 +0,0 @@
# SHINE Payments v2
## Назначение
`shine_payments` v2 — контракт очереди выплат с двумя очередями:
1. обычная покупка билета (очередь 1);
2. менеджерское добавление билетов (очередь 1 и очередь 2 по лимитам от DAO);
3. пошаговые выплаты из inflow-вольта с приоритетом очереди 1.
Сейчас тестовый этап в Devnet: расчеты и хранение в USD-центах (1 USD = 100), при этом реальные on-chain переводы выполняются в SOL по курсу Pyth SOL/USD.
## PDA
1. `config_pda` (`shine_payments_v3_config`)
- `dao_wallet`
- `manager_wallet` (сервисный параметр, для будущих сценариев)
- `inflow_vault`
2. `coef_limit_pda` (`shine_payments_v3_coef_limit`)
- `coef_ppm` (fixed-point, scale = 1_000_000)
- `limit_usd_cents` (лимит суммарной исторической суммы очереди 1 для обычной покупки)
- `call_reward_lamports` (награда за шаг выплат, максимум 0.01 SOL)
3. `queues_pda` (`shine_payments_v3_queues`)
- очередь 1: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid`
- очередь 2: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid`
4. `inflow_vault_pda` (`shine_payments_v3_inflow_vault`)
- входящий PDA-вольт программы для выплат.
5. `ticket_pda`
- очередь 1: `shine_payments_v3_q1_ticket + index_le_u64`
- очередь 2: `shine_payments_v3_q2_ticket + index_le_u64`
- поля тикета:
- `queue_id`
- `index`
- `is_paid`
- `recipient_wallet`
- `payout_usd_cents`
- `debt_before_usd_cents`
6. `manager_allowance_pda` (`shine_p_v3_manager_allow + manager_pubkey`)
- `manager_wallet`
- `q1_available_usd_cents`
- `q2_available_usd_cents`
## Методы
1. `init`
- вызывается один раз (кто угодно);
- создает `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
2. `update_coef_limit` (только `dao_wallet` из config)
- меняет коэффициент, лимит покупки в очередь 1 и награду шага выплат;
- ограничение награды: не более `0.01 SOL`.
3. `grant_manager_limits` (только `dao_wallet` из config)
- DAO выдает/добавляет лимиты менеджеру:
- `add_q1_usd_cents`
- `add_q2_usd_cents`
- если PDA менеджера нет — создается;
- если есть — лимиты увеличиваются.
4. `buy_ticket`
- обратная совместимость: покупка с входом в lamports, но запись тикета в USD-центах;
- использует Pyth SOL/USD и stale-check (не старше 120 секунд).
5. `buy_ticket_usd`
- покупка билета в очередь 1 с суммой в USD-центах;
- сумма SOL к оплате считается в контракте по Pyth;
- есть `max_pay_lamports` для slippage-защиты.
6. `buy_ticket_sol`
- покупка билета в очередь 1 с суммой в lamports;
- USD-объём покупки считается в контракте по Pyth;
- есть `min_expected_usd_cents` для slippage-защиты.
7. `manager_add_ticket`
- менеджер добавляет тикет в очередь 1 или 2;
- без денежного перевода;
- списывает лимит менеджера по выбранной очереди (в USD-центах).
8. `step_payout`
- выбирает очередь по приоритету:
1. сначала очередь 1;
2. если в 1-й нет ожидания — очередь 2.
- перед выплатой получает текущий SOL/USD из Pyth и проверяет stale.
- шаг выплаты:
- очередь 1: `X` получателю тикета + `X` в DAO + `reward` вызывающему;
- очередь 2: `X` получателю тикета + `2X` в DAO + `reward` вызывающему.
- если обе очереди пусты/выплачены:
- переводит весь доступный остаток inflow-вольта в DAO (без reward).
9. Экономика покупки
- сумма покупки идет в DAO;
- тикет получает выплату `purchase_usd_cents * coef_ppm / 1_000_000`;
- проверка лимита выполняется по `q1_sum_total_usd_cents` (исторически накопленная сумма, без вычета уже выплаченного).
## Стартовые настройки
См. `programs/shine_payments/src/settings.rs`:
- `START_COEF_PPM = 5_000_000` (коэффициент 5.0)
- `START_LIMIT_USD_CENTS = 10_000 USD`
- `START_CALL_REWARD_LAMPORTS = 0.008 SOL`
- `ORACLE_MAX_AGE_SECS = 120`
- `PYTH_SOL_USD_FEED_ID`
- `PYTH_SOL_USD_ACCOUNT`
- `DAO_WALLET`
- `MANAGER_WALLET`
## Тестовый режим
Пока нет финального production-потока пополнения inflow из регистрации/экосистемы, inflow-вольт пополняется вручную в Devnet, после чего выполняются шаги выплат.

View File

@ -1,146 +0,0 @@
# SHiNE User PDA v1.0
## 1. Назначение
`SHiNE User PDA v1.0` — бинарный формат пользовательской записи в PDA Solana.
Хранит:
- логин;
- ключи пользователя;
- номер внутренней сети;
- лимит (баланс);
- серверные данные (опционально);
- список серверов подключения;
- данные восстановления;
- связь с предыдущей версией через `prev_hash`;
- подпись владельца `root_key`.
Размер PDA фиксированный:
`768 bytes`
Полезная длина записи указывается в `record_len`.
## 2. Общие правила кодирования
- Числа: `little-endian`.
- Строки: `UTF-8` с префиксом длины `u8`.
- Padding до 768 байт: нули `0x00`.
- Padding не входит в `record_len`.
## 3. Структура записи
- `magic` (5): `"SHiNE"`
- `format_major` (1): `1`
- `format_minor` (1): `0`
- `record_len` (2): длина от `magic` до `signature` включительно
- `created_at_ms` (8)
- `updated_at_ms` (8)
- `version` (4)
- `prev_hash` (32)
- `login_len` (1)
- `login` (N)
- `root_key` (32)
- `blockchain_key` (32)
- `device_key` (32)
- `chain_number` (2)
- `balance` (8)
- `is_server` (1)
- если `is_server=1`:
- `server_key` (32)
- `server_address_len` (1)
- `server_address` (N)
- `connection_servers_count` (1)
- повтор `count` раз:
- `server_login_len` (1)
- `server_login` (N)
- `trusted_count` (1)
- `reserved` (5) = `0x00 0x00 0x00 0x00 0x00`
- `signature` (64)
- `padding` до 768
## 4. Подпись (v1.0)
В `v1.0` подписывается не сырой блок полей напрямую, а его SHA-256.
1. Формируется `unsigned_bytes`:
- все поля от `magic` до `reserved` включительно;
- поле `signature` не включается;
- padding не включается.
2. Считается `msg_hash = SHA-256(unsigned_bytes)`.
3. `signature = Ed25519.sign(root_private_key, msg_hash)`.
4. Проверка:
- `Ed25519.verify(root_key, msg_hash, signature)`.
- В текущей реализации проверка выполняется через встроенную Solana-инструкцию `Ed25519Program`
(инструкция должна идти в транзакции перед вызовом `create_user_pda` / `update_user_pda`).
## 5. Что входит в `prev_hash`
Для связи версий:
- `prev_hash = SHA-256(previous_unsigned_bytes)`
Где `previous_unsigned_bytes` — предыдущая версия записи от `magic` до `reserved` включительно, без `signature` и без padding.
Для первой версии:
- `prev_hash = 32` нулевых байта.
## 6. Правила create/update в текущей реализации
### Create
- PDA: seed `["login=", login]`.
- Создаётся запись версии `0`.
- `updated_at_ms = created_at_ms`.
- Стартовый лимит:
- `START_BONUS_LIMIT + additional_limit`.
- Оплата:
- регистрационная комиссия;
- пополнение `additional_limit` по курсу;
- рента PDA (плательщик транзакции).
### Update
- Проверка подписи новой записи по `root_key`.
- Проверка:
- `magic`, `format_major`, `format_minor`;
- корректного `record_len`;
- `prev_hash` на соответствие предыдущей версии;
- `version = old_version + 1`;
- неизменяемых полей: `login`, `created_at_ms`, `root_key`.
- `balance` не уменьшается:
- `new_balance = old_balance + additional_limit`.
- При `additional_limit > 0` берётся комиссия пополнения.
## 7. Параметры экономики/размера (settings)
См. `programs/shine_users/src/settings.rs`:
- `USER_PDA_SPACE = 768`
- `REGISTRATION_FEE_RECEIVER`
- `REGISTRATION_FEE_LAMPORTS`
- `LIMIT_STEP`
- `LAMPORTS_PER_LIMIT_STEP`
- `START_BONUS_LIMIT`
## 8. Root Key Rotation (пока не включено)
В `v1.0` `root_key` неизменяем.
Варианты расширения:
1. **Dual-signature rotate tx**:
- отдельный флаг операции rotate;
- запись подписывается и старым, и новым root key;
- контракт проверяет обе подписи.
2. **Two-step commit/confirm**:
- шаг 1: proposal смены root (`old root` подпись);
- шаг 2: confirm (`new root` подпись) в отдельной tx.
3. **Recovery guardians**:
- отдельная PDA для доверенных лиц;
- пороговая схема (например `m-of-n`) для восстановления root.
Для v1.0 решение отложено, но изменение root_key в update запрещено.

View File

@ -0,0 +1,215 @@
# SHINY USER FORMAT v1.0 (DRAFT)
Документ описывает целевой бинарный формат пользовательской записи в `user_pda` для программы `shine_users`.
## 1) Статус версии и цель
- Текущий on-chain формат: `v1.0`.
- Этот документ: `v1.0 (draft)` для текущего этапа.
- Цель текущей версии: зафиксировать рабочий формат и сразу оставить в нем поля для будущего расширения.
Новые статусные поля:
- `root_key_status`
- `blockchain_key_status`
- `device_key_status`
Текущее значение каждого статуса: `0` (ключ создан и не менялся).
## 2) Общие правила кодирования
- Числа: Little Endian (`LE`).
- Строки: `UTF-8` с префиксом длины `u8`.
- Публичные ключи: 32 байта (`Pubkey`).
- Подпись: 64 байта (Ed25519).
- Размер PDA фиксированный: `USER_PDA_SPACE` (сейчас 768 байт).
- `record_len` хранит длину полезной записи от `magic` до `signature` включительно (без `padding`).
## 3) Единый список полей в порядке хранения
1. `magic`
Размер: 5 байт.
Значение: `"SHiNE"`.
Назначение: маркер формата записи.
2. `format_major`
Размер: 1 байт (`u8`).
Текущее значение: `1`.
Назначение: major-версия формата.
3. `format_minor`
Размер: 1 байт (`u8`).
Текущее значение: `0`.
Назначение: minor-версия формата.
4. `record_len`
Размер: 2 байта (`u16`, LE).
Назначение: длина полезных данных записи (без `padding`).
5. `created_at_ms`
Размер: 8 байт (`u64`, LE).
Назначение: время создания записи (Unix time, ms).
6. `updated_at_ms`
Размер: 8 байт (`u64`, LE).
Назначение: время последнего обновления записи (Unix time, ms).
7. `record_number` (`version`)
Размер: 4 байта (`u32`, LE).
Назначение: порядковый номер записи пользователя.
Правило обновления: новая запись должна иметь `last_record_number + 1`; проверяется программой.
8. `prev_record_hash` (`prev_hash`)
Размер: 32 байта.
Назначение: хэш unsigned-части предыдущей записи для связи истории.
9. `login_len`
Размер: 1 байт (`u8`).
Назначение: длина поля `login` в байтах.
10. `login`
Размер: `login_len` байт (UTF-8).
Назначение: логин пользователя.
Текущие ограничения: от 1 до 25 символов, только `a-z`, `0-9`, `_`.
11. `root_key_status`
Размер: 1 байт (`u8`).
Текущее значение: `0`.
Назначение: статус `root_key`.
Комментарий: будущие статусы ротации зарезервированы, смена root-ключа пока не реализована.
12. `root_key`
Размер: 32 байта (`Pubkey`).
Назначение: корневой ключ пользователя для подписи записи.
13. `blockchain_key_status`
Размер: 1 байт (`u8`).
Текущее значение: `0`.
Назначение: статус `blockchain_key`.
Комментарий: будущие статусы ротации зарезервированы.
14. `blockchain_key`
Размер: 32 байта (`Pubkey`).
Назначение: рабочий блокчейн-ключ пользователя.
15. `device_key_status`
Размер: 1 байт (`u8`).
Текущее значение: `0`.
Назначение: статус `device_key`.
Комментарий: будущие статусы ротации зарезервированы.
16. `device_key`
Размер: 32 байта (`Pubkey`).
Назначение: ключ устройства пользователя.
17. `chain_number`
Размер: 2 байта (`u16`, LE).
Назначение: номер блокчейн-профиля пользователя.
Текущее использование: базовый сценарий с одним профилем (обычно `1`).
18. `balance`
Размер: 8 байт (`u64`, LE).
Назначение: лимит/баланс пользователя.
19. `is_server`
Размер: 1 байт (`u8`).
Значения: `0` или `1`.
Назначение: флаг серверного профиля.
20. `server_key` (только если `is_server = 1`)
Размер: 32 байта (`Pubkey`).
Назначение: публичный ключ сервера.
21. `server_address_len` (только если `is_server = 1`)
Размер: 1 байт (`u8`).
Назначение: длина строки `server_address`.
22. `server_address` (только если `is_server = 1`)
Размер: `server_address_len` байт (UTF-8).
Назначение: адрес сервера.
23. `connection_servers_count`
Размер: 1 байт (`u8`).
Назначение: количество серверов в списке подключения.
24. Повтор `connection_servers_count` раз:
`server_login_len` — 1 байт (`u8`),
`server_login``server_login_len` байт (UTF-8).
Назначение: логины серверов подключения.
25. `trusted_count`
Размер: 1 байт (`u8`).
Назначение: текущее число trusted-контактов.
Текущее состояние: пока только счетчик, без отдельной trusted-логики.
26. `reserved`
Размер: 5 байт.
Текущее значение: `0x00 0x00 0x00 0x00 0x00`.
Назначение: резерв под будущие расширения.
27. `signature`
Размер: 64 байта.
Назначение: Ed25519-подпись хэша unsigned-части записи.
28. `padding`
Размер: до полного `USER_PDA_SPACE`.
Текущее значение: `0x00`.
Назначение: добивка до фиксированного размера PDA.
## 4) Что подписывается
Подписывается SHA-256 от unsigned-части записи:
- от `magic` до `reserved` включительно;
- без `signature`;
- без `padding`.
## 5) Что сейчас работает в логике
Сейчас в рабочем потоке используются 2 операции:
1. `create_user_pda` — регистрация пользователя.
2. `update_user_pda` — обновление записи пользователя.
Через `update_user_pda` сейчас можно:
- увеличить `balance` через `additional_limit`;
- обновить серверные поля (`is_server`, `server_key`, `server_address`, `connection_servers`);
- увеличить `record_number` (`version`) на 1.
Оплата идет на адрес, заданный в `REGISTRATION_FEE_RECEIVER` (не в DAO по умолчанию).
## 6) Ограничения и отложенные расширения
Это функции и сценарии, которые предусмотрены структурой данных формата `v1.0`, но пока не реализованы программно.
1. Смена ключей пока недоступна
`root_key`, `blockchain_key`, `device_key` считаются без ротации; статусные поля пока фактически только `0`.
2. Multi-chain профили пока не реализованы
Пока используется один базовый профиль (`chain_number`), расширение до нескольких профилей/форков — отдельный этап.
3. Trusted-логика пока не реализована
Пока хранится только `trusted_count`; список trusted, очередь, таймеры и голосование будут добавляться отдельно.
4. Работа с несколькими серверами на уровне приложения ограничена
В записи можно хранить несколько `connection_servers`, но в клиентском приложении может использоваться только первый сервер до внедрения полной multi-server логики.
## 7) Константы и фиксированные значения (точки будущего расширения)
Ниже перечислены места, где сейчас используются константы/фиксированные значения, а в будущем возможна доработка:
1. Версия формата: `format_major = 1`, `format_minor = 0`.
Расширение: переход на следующую минорную/мажорную версию при изменении бинарной схемы.
2. Размер PDA: `USER_PDA_SPACE = 768`.
Расширение: увеличение размера или переход на иное хранение при росте структуры.
3. Статусы ключей: все три `*_key_status` пока равны `0`.
Расширение: добавить коды состояний для ротации/восстановления ключей.
4. `chain_number`: текущий рабочий сценарий с одним профилем (обычно `1`).
Расширение: поддержка нескольких блокчейн-форков.
5. `trusted_count`: пока только счетчик, обычно `0`.
Расширение: отдельные структуры trusted-списка, очередей и таймеров.
6. `reserved` (5 байт): сейчас всегда нули.
Расширение: использовать как флаги/дополнительные поля без слома общей схемы.

View File

@ -1,7 +0,0 @@
. Сделать новые форматы для пользователей что бы там было больше информации
. Протестировать работу и может доработать что бы можно было паралельно регистрировать 5 и более юзеров - за счёт передачи при вызове адресов PDA +1 +2 +3 +4 и т.д.
. - пока не надо - Сделать что бы в файле общей информации добавилась запись для будущей миграции пользователей (хотя можно потом и добавить будет :)))

View File

@ -1,19 +0,0 @@
Как вернуть деньги
Узнаём адрес buffer account:
solana program show <адрес_твоей_программы>
Там будет строчка Buffer: <PUBKEY>.
Закрываем буфер:
solana program close <PUBKEY> --recipient <адресошелька>
💡 --recipient — это куда вернуть SOL (обычно твой же кошелёк из ~/.config/solana/id.json).
Сколько вернётся
Если бинарник весит ~400 KB, с одного буфера вернётся ~0.35 SOL.
У тебя две программы (shine_users, shine_payments), значит можно вернуть ~0.7 SOL.

View File

@ -1,6 +0,0 @@
✅ Чтобы максимально уменьшить .so надо будет включить флаг оптимизации в Cargo.toml:
[profile.release]
opt-level = "z" # Максимальная компрессия
lto = true # Link Time Optimization
codegen-units = 1 # Уменьшает размер бинаря

View File

@ -1,44 +0,0 @@
#!/bin/bash
set -e # Завершаем при ошибке
set -o pipefail
PROGRAM_KEYPAIR="target/deploy/shine-keypair.json" # замени на свой путь
WALLET=$(solana address)
echo "🧹 Удаление старого ledger..."
rm -rf test-ledger
echo "🚀 Запуск solana-test-validator в фоне..."
solana-test-validator --ledger test-ledger --reset > validator.log 2>&1 &
VALIDATOR_PID=$!
# Убедимся, что validator запущен
echo "⏳ Ожидание запуска валидатора..."
until solana cluster-version &>/dev/null; do
sleep 1
done
sleep 2 # На всякий случай немного подождём
echo "💸 Airdrop 10 SOL на $WALLET..."
solana airdrop 10 HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA
solana airdrop 5 $WALLET
echo "🔨 Сборка контракта..."
anchor build
echo "📦 Деплой контракта..."
anchor deploy
echo "✅ Готово!"
# Не убиваем валидатор, чтобы он оставался запущенным
echo " Валидатор всё ещё работает (PID $VALIDATOR_PID)"
echo " Запускаем логи"
solana logs

View File

@ -1,23 +0,0 @@
#!/bin/bash
set -e # Завершаем при ошибке
set -o pipefail
kill -9 $(pgrep -f "solana-test-validator")
# 🔍 Ищем запущенный solana-test-validator
EXISTING_PID=$(pgrep -f "solana-test-validator")
if [ -n "$EXISTING_PID" ]; then
echo "🛑 Найден работающий solana-test-validator (PID $EXISTING_PID), останавливаем..."
bash kill -9 $(pgrep -f "solana-test-validator")
echo "✅ Пытаюсь остановить старый валидатор..."
# ждём завершения
while kill -0 "$EXISTING_PID" 2>/dev/null; do
sleep 0.5
done
echo "✅ Старый валидатор остановлен."
fi

View File

@ -1,145 +0,0 @@
Функции для работы с PDA:
🧩 create_pda(...)
Создаёт новый PDA, если он ещё не существует.
Проверяет, чтобы не было коллизии.
Без записи данных.
🧩 write_to_pda(...)
Просто записывает байты в существующий PDA.
Без создания.
🧩 create_and_write_pda(...)
Комбинированная функция.
Сначала проверяет, есть ли PDA — если нет, создаёт.
Затем записывает данные.
Очень удобна для инициализации одного PDA в один вызов.
🧩 safe_read_pda(...)
Возвращает Vec<u8> с содержимым PDA.
Никогда не паникует: если PDA не существует или пустой — просто отдаёт Vec::new().
Защита от двойного borrow'а (через try_borrow_data()).
Отличный инструмент для безопасного считывания данных.
💡 Да, с этим ты можешь:
Возможность Функция
📦 Создать PDA create_pda
💾 Записать в PDA write_to_pda
⚡ Создать и записать create_and_write_pda
📖 Безопасно прочитать safe_read_pda
🔧 create_pda(...)
🔹 Назначение:
Создаёт новый PDA-аккаунт, если он ещё не существует.
📥 Аргументы:
pda_account: &AccountInfo — аккаунт, который хотим создать
signer: &AccountInfo — аккаунт плательщика (обычно пользователь)
system_program: &AccountInfo — системная программа
program_id: &Pubkey — адрес текущей программы
seeds: &[&[u8]] — массив сидов, по которым создавался PDA
space: u64 — сколько байт выделить под данные
📤 Возвращает:
Result<()> — Ok если успешно, Err если PDA уже существует или при ошибке создания
🧠 Особенности:
Проверяет, что PDA ещё не создан (через pda_account.owner == Pubkey::default())
Выбрасывает ErrCode::PdaAlreadyExists, если уже существует
🔧 write_to_pda(...)
🔹 Назначение:
Записывает бинарные данные в существующий PDA.
📥 Аргументы:
pda_account: &AccountInfo — аккаунт, в который пишем
data: &[u8] — массив байт, которые нужно записать
📤 Возвращает:
Result<()> — Ok при успехе, Err если не удалось получить доступ к данным
🧠 Особенности:
⚠️ Только пишет, не создаёт PDA
Записывает в начало data-секции аккаунта
🔧 create_and_write_pda(...)
🔹 Назначение:
Если PDA ещё не существует — создаёт, затем сразу записывает данные.
📥 Аргументы:
pda_account: &AccountInfo — аккаунт для создания/записи
signer: &AccountInfo — кто оплачивает создание
system_program: &AccountInfo — системная программа
program_id: &Pubkey — адрес текущей программы
seeds: &[&[u8]] — сиды PDA
data: Vec<u8> — данные для записи
space: u64 — сколько байт выделить (при создании)
📤 Возвращает:
Result<()> — Ok при успехе, Err при ошибке создания или записи
🧠 Особенности:
Безопасно создаёт и пишет за один вызов
Не выбрасывает ошибку, если PDA уже существует — просто пишет
🔧 safe_read_pda(...)
🔹 Назначение:
Безопасно считывает байты из PDA. Никогда не паникует.
📥 Аргументы:
pda_account: &AccountInfo — аккаунт для чтения
📤 Возвращает:
Vec<u8> — массив байт с содержимым PDA
→ Если аккаунт не инициализирован или пустой, возвращает Vec::new()
🧠 Особенности:
Не выбрасывает ошибки — только логирует
Полностью безопасно: подходит для чтения read-only PDA в любой ситуации

View File

@ -1,29 +0,0 @@
#!/bin/bash
# Кол-во адресов для генерации
NUM_KEYS=5
# RPC endpoint (можешь поменять)
#RPC_URL="https://api.testnet.solana.com"
RPC_URL="http://127.0.0.1:8899"
echo "👉 Используем RPC: $RPC_URL"
for i in $(seq 1 $NUM_KEYS); do
KEYPAIR="temp-key-$i.json"
echo "🔐 Генерирую ключ №$i: $KEYPAIR"
solana-keygen new --outfile "$KEYPAIR" --no-bip39-passphrase --silent
PUBKEY=$(solana-keygen pubkey "$KEYPAIR")
echo "🪙 Публичный ключ: $PUBKEY"
echo "💸 Запрашиваю airdrop на $PUBKEY..."
solana airdrop 1 "$PUBKEY" --url "$RPC_URL"
echo "🔍 Проверяю баланс:"
solana balance "$PUBKEY" --url "$RPC_URL"
echo "-----------------------------"
done
echo "✅ Готово. Удаляю временные ключи..."
rm temp-key-*.json

View File

@ -1,5 +1,5 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke_signed, system_instruction, system_program};
use anchor_lang::solana_program::{program::invoke_signed, system_instruction};
/// сдесь коды всех ошибок

View File

@ -1,19 +0,0 @@
# Oracle Check
Мини-страница диагностики оракула Pyth для `SOL/USD`.
Файл:
- `index.html`
Что проверяет:
1. Чтение oracle account через RPC (`devnet`, `mainnet-beta`, `testnet`).
2. Парсинг по текущим оффсетам из UI (`74/90/94`).
3. Альтернативный парсинг (`73/89/93`) для проверки сдвига формата.
4. Сравнение с Hermes API (эталонный источник цены по feed id).
Запуск:
Открыть `index.html` в браузере и нажать кнопку «Проверить все сети».

View File

@ -1,255 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Проверка оракула Pyth (Devnet/Mainnet/Testnet)</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
--ok: #55d48a;
--warn: #ffbf5e;
--err: #ff7d7d;
--btn: #273247;
--btn-hover: #32415c;
--code: #1e2633;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
.wrap { width: 100%; max-width: 1800px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 9px 10px; min-width: 340px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
button:hover { background: var(--btn-hover); }
.muted { color: var(--muted); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--err); white-space: pre-wrap; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
</style>
</head>
<body>
<div class="wrap">
<h1>Диагностика оракула Pyth (SOL/USD)</h1>
<div class="panel">
<div class="muted">
Страница нужна, чтобы проверить, что именно возвращает аккаунт оракула в разных сетях и где ломается парсинг.
</div>
<div class="muted">
Проверяются три сети: <code>devnet</code>, <code>mainnet-beta</code>, <code>testnet</code>.
</div>
</div>
<div class="panel">
<h3>Настройки</h3>
<div class="row">
<label>Feed ID (hex с 0x):<br /><input id="feedId" value="0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d" /></label>
</div>
<div class="row">
<label>Oracle account (devnet):<br /><input id="oracleDevnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
</div>
<div class="row">
<label>Oracle account (mainnet-beta):<br /><input id="oracleMainnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
</div>
<div class="row">
<label>Oracle account (testnet):<br /><input id="oracleTestnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
</div>
<div class="row">
<button id="runBtn">Проверить все сети</button>
</div>
<div class="muted">
Если для сети аккаунт не существует, это тоже покажется в отчёте.
</div>
</div>
<div class="panel">
<h3>Результаты</h3>
<div id="out" class="muted">Нажмите «Проверить все сети».</div>
</div>
</div>
<script>
const NETWORKS = [
{ name: "devnet", rpc: "https://api.devnet.solana.com", inputId: "oracleDevnet" },
{ name: "mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", inputId: "oracleMainnet" },
{ name: "testnet", rpc: "https://api.testnet.solana.com", inputId: "oracleTestnet" },
];
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function readI64(data, offset) {
let x = readU64(data, offset);
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
return x;
}
function readU32(data, offset) {
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
}
function readI32(data, offset) {
let x = readU32(data, offset);
if (x > 0x7fffffff) x -= 0x100000000;
return x;
}
function toHex(bytes) {
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function priceToFloatStr(price, exponent) {
const p = Number(price);
if (!Number.isFinite(p)) return "NaN";
return trimZeros((p * Math.pow(10, exponent)).toFixed(12));
}
function fmtErr(e) {
const s = String(e?.message || e || "unknown error");
return s.length > 350 ? s.slice(0, 350) + "..." : s;
}
function parseWithOffsets(data, priceOffset, exponentOffset, publishOffset) {
const price = readI64(data, priceOffset);
const exponent = readI32(data, exponentOffset);
const publishTime = readI64(data, publishOffset);
return { price, exponent, publishTime, valueStr: priceToFloatStr(price, exponent) };
}
function parseFeedIdFromLikelyPosition(data) {
// Для текущего PriceUpdateV2 в аккаунте receiver:
// 0..7 discriminator, 8..39 write_authority, 40..40 verification enum, 41..72 feed_id.
if (data.length < 73) return null;
const feedRaw = data.slice(41, 73);
return "0x" + toHex(feedRaw);
}
async function rpcGetAccountInfo(rpcUrl, pubkey) {
const body = {
jsonrpc: "2.0",
id: 1,
method: "getAccountInfo",
params: [pubkey, { encoding: "base64", commitment: "confirmed" }],
};
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`RPC HTTP ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
return json.result?.value || null;
}
async function fetchHermesPrice(feedIdHex) {
const url = `https://hermes.pyth.network/v2/updates/price/latest?ids[]=${encodeURIComponent(feedIdHex)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Hermes HTTP ${res.status}`);
const json = await res.json();
const p = json?.parsed?.[0]?.price;
if (!p) throw new Error("Hermes: price not found");
return {
price: BigInt(p.price),
expo: Number(p.expo),
publishTime: BigInt(p.publish_time),
valueStr: priceToFloatStr(BigInt(p.price), Number(p.expo)),
};
}
async function runCheck() {
const out = document.getElementById("out");
out.textContent = "Проверка...";
const feedId = document.getElementById("feedId").value.trim().toLowerCase();
const rows = [];
let hermes = null;
let hermesErr = null;
try {
hermes = await fetchHermesPrice(feedId);
} catch (e) {
hermesErr = fmtErr(e);
}
for (const n of NETWORKS) {
const oracle = document.getElementById(n.inputId).value.trim();
try {
const ai = await rpcGetAccountInfo(n.rpc, oracle);
if (!ai) {
rows.push({
network: n.name,
status: "account not found",
details: "Аккаунт оракула не найден в этой сети",
});
continue;
}
const data = Uint8Array.from(atob(ai.data[0]), c => c.charCodeAt(0));
const feedFromData = parseFeedIdFromLikelyPosition(data);
const parsedCurrentUi = parseWithOffsets(data, 74, 90, 94); // как сейчас в UI проекта
const parsedShiftMinus1 = parseWithOffsets(data, 73, 89, 93); // диагностический вариант
const currentUiValid = parsedCurrentUi.price > 0n;
const shiftValid = parsedShiftMinus1.price > 0n;
const feedMatch = feedFromData === feedId;
rows.push({
network: n.name,
status: "ok",
details: `
<div>RPC: <code>${n.rpc}</code></div>
<div>Account owner: <code>${ai.owner}</code>, data len: <b>${data.length}</b></div>
<div>Feed ID (из аккаунта): <code>${feedFromData || "n/a"}</code> ${feedMatch ? '<span class="ok">совпадает</span>' : '<span class="warn">НЕ совпадает</span>'}</div>
<div>Парсер UI (offsets 74/90/94): price=<code>${parsedCurrentUi.price.toString()}</code>, exp=<code>${parsedCurrentUi.exponent}</code>, value=<b>${parsedCurrentUi.valueStr}</b> ${currentUiValid ? '<span class="ok">valid</span>' : '<span class="err">invalid</span>'}</div>
<div>Альт. парсер (offsets 73/89/93): price=<code>${parsedShiftMinus1.price.toString()}</code>, exp=<code>${parsedShiftMinus1.exponent}</code>, value=<b>${parsedShiftMinus1.valueStr}</b> ${shiftValid ? '<span class="ok">valid</span>' : '<span class="err">invalid</span>'}</div>
${hermes
? `<div>Hermes (эталон): price=<code>${hermes.price.toString()}</code>, exp=<code>${hermes.expo}</code>, value=<b>${hermes.valueStr}</b></div>`
: `<div class="warn">Hermes недоступен: ${hermesErr}</div>`
}
`,
});
} catch (e) {
rows.push({
network: n.name,
status: "error",
details: `<span class="err">${fmtErr(e)}</span>`,
});
}
}
out.innerHTML = `
<table>
<thead>
<tr>
<th>Сеть</th>
<th>Статус</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
${rows.map((r) => `
<tr>
<td><b>${r.network}</b></td>
<td>${r.status === "ok" ? '<span class="ok">ok</span>' : (r.status === "account not found" ? '<span class="warn">not found</span>' : '<span class="err">error</span>')}</td>
<td>${r.details}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
document.getElementById("runBtn").addEventListener("click", runCheck);
</script>
</body>
</html>

View File

@ -500,6 +500,61 @@ pub mod shine_payments {
Ok(())
}
pub fn change_ticket_recipient(
ctx: Context<ChangeTicketRecipient>,
args: ChangeTicketRecipientArgs,
) -> Result<()> {
let queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
let mut ticket = read_state::<TicketState>(&ctx.accounts.ticket_pda)?;
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
require_keys_eq!(
ctx.accounts.signer.key(),
ticket.recipient_wallet,
PaymentsError::UnauthorizedTicketOwner
);
let (expected_ticket_pda, _) = find_ticket_pda(ctx.program_id, ticket.queue_id, ticket.index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.ticket_pda.key(),
ErrCode::InvalidPdaAddress
);
let q1_pending = queues
.q1_tickets_total
.checked_sub(queues.q1_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
let q2_pending = queues
.q2_tickets_total
.checked_sub(queues.q2_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
if q1_pending > 0 || q2_pending > 0 {
let target_queue = if q1_pending > 0 { 1 } else { 2 };
let next_index = if target_queue == 1 {
queues
.q1_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?
} else {
queues
.q2_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?
};
require!(
!(ticket.queue_id == target_queue && ticket.index == next_index),
PaymentsError::CannotChangeRecipientForNextPayoutTicket
);
}
ticket.recipient_wallet = args.new_recipient_wallet;
write_state(&ctx.accounts.ticket_pda, &ticket)?;
Ok(())
}
}
#[derive(Accounts)]
@ -619,6 +674,19 @@ pub struct StepPayout<'info> {
pub sol_usd_price_update: Account<'info, PriceUpdateV2>,
}
#[derive(Accounts)]
pub struct ChangeTicketRecipient<'info> {
/// CHECK: подписант-владелец текущего recipient тикета.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA очередей, читается вручную.
#[account(mut)]
pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA тикета, читается и валидируется вручную.
#[account(mut)]
pub ticket_pda: AccountInfo<'info>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct UpdateCoefLimitArgs {
pub coef_ppm: u64,
@ -660,6 +728,11 @@ pub struct ManagerAddTicketArgs {
pub payout_usd_cents: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ChangeTicketRecipientArgs {
pub new_recipient_wallet: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ConfigState {
pub version: u8,
@ -758,6 +831,10 @@ pub enum PaymentsError {
InvalidManagerWallet,
#[msg("Лимит менеджера по выбранной очереди превышен")]
ManagerLimitExceeded,
#[msg("Только текущий получатель тикета может изменить получателя")]
UnauthorizedTicketOwner,
#[msg("Нельзя менять получателя у следующего тикета на выплату")]
CannotChangeRecipientForNextPayoutTicket,
#[msg("Оракул передан неверный")]
InvalidOracleAccount,
#[msg("Некорректный feed id оракула")]

View File

@ -35,6 +35,7 @@
.err { color: var(--err); white-space: pre-wrap; }
.paid { color: var(--ok); font-weight: 700; }
.waiting { color: var(--muted); }
.xfer { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style>
</head>
@ -266,6 +267,12 @@
if (q2Pending > 0n) return 2;
return 0;
}
function nextPayoutTicket(queues) {
const queue = nextStepQueue(queues);
if (queue === 0) return null;
const index = queue === 1 ? (queues.q1Paid + 1n) : (queues.q2Paid + 1n);
return { queue, index };
}
async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo");
@ -320,6 +327,17 @@
}
function renderTicketCard(core, pda, t) {
const next = nextPayoutTicket(core.queues);
const isNext = !!next && next.queue === t.queueId && next.index === t.index;
const isOwner = walletPubkey && walletPubkey.toBase58() === t.recipient.toBase58();
const canTransfer = !t.isPaid && isOwner && !isNext;
const whyBlocked = t.isPaid
? "Тикет уже выплачен"
: !isOwner
? "Передача доступна только текущему получателю тикета"
: isNext
? "Это следующий тикет на выплату, передача заблокирована"
: "";
return `
<div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
@ -327,10 +345,55 @@
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
<div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
<div class="xfer">
<div><b>Передача билета</b></div>
<div class="row">
<input id="newRecipient_${t.queueId}_${t.index.toString()}" placeholder="Новый получатель (Base58)" />
<button
class="transferBtn"
data-queue="${t.queueId}"
data-index="${t.index.toString()}"
data-pda="${pda.toBase58()}"
${canTransfer ? "" : "disabled"}
>Передать</button>
</div>
<div id="transferResult_${t.queueId}_${t.index.toString()}" class="${canTransfer ? "muted" : "warn"}">${canTransfer ? "Доступно для текущего владельца тикета." : whyBlocked}</div>
</div>
</div>
`;
}
async function changeTicketRecipient(queueId, index, ticketPdaBase58) {
const resultEl = document.getElementById(`transferResult_${queueId}_${index}`);
const inputEl = document.getElementById(`newRecipient_${queueId}_${index}`);
resultEl.className = "muted";
resultEl.textContent = "";
try {
if (!walletPubkey) await connectWallet();
const newRecipientRaw = (inputEl.value || "").trim();
if (!newRecipientRaw) throw new Error("Введите адрес нового получателя");
const newRecipient = new solanaWeb3.PublicKey(newRecipientRaw);
const core = cachedCore || await loadCoreState();
const disc = await ixDiscriminator("change_ticket_recipient");
const data = concat(disc, newRecipient.toBytes());
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(ticketPdaBase58), isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
resultEl.className = "ok";
resultEl.innerHTML = `Передача выполнена. Tx: <code>${sig}</code>`;
await refreshAll();
await findTickets();
} catch (e) {
resultEl.className = "err";
resultEl.textContent = String(e.message || e);
}
}
async function findTickets() {
const out = document.getElementById("ticketResult");
out.textContent = "";
@ -430,6 +493,14 @@
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("findBtn").addEventListener("click", findTickets);
document.getElementById("stepBtn").addEventListener("click", stepPayout);
document.getElementById("ticketResult").addEventListener("click", (e) => {
const btn = e.target.closest(".transferBtn");
if (!btn) return;
const queueId = Number(btn.dataset.queue);
const index = btn.dataset.index;
const pda = btn.dataset.pda;
changeTicketRecipient(queueId, index, pda);
});
refreshAll();
</script>
</body>

View File

@ -1,33 +0,0 @@
[package]
name = "shine_payments"
version = "0.1.0"
description = "Payments and investments smart contract"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "shine_payments"
test = false
doctest = false
bench = false
[dependencies]
anchor-lang = "0.31.1"
common = { path = "../common" }
# ==== добавлено для NFT-функционала ====
anchor-spl = { version = "0.31.1", features = ["associated_token", "token"] }
mpl-token-metadata = "5.1.1"
spl-token = { version = "4.0.0", features = ["no-entrypoint"] }
# ======================================
[features]
default = []
no-entrypoint = []
no-idl = []
no-log-ix-name = []
anchor-debug = []
custom-heap = []
custom-panic = []
cpi = []
idl-build = ["anchor-lang/idl-build"]

View File

@ -1,6 +0,0 @@
# Важно
Эта папка содержит устаревшую версию `shine_payments`.
- Не использовать для новых доработок.
- Актуальная реализация находится в `programs/shine_payments`.

View File

@ -1,30 +0,0 @@
#!/bin/bash
# Скрипт для копирования dApp на тестовый сервер
# Настройки
LOCAL_FILE="init.html"
REMOTE_USER="aidar"
REMOTE_HOST="shineup.me"
REMOTE_PATH="/home/aidar/Docker_server/site/dApp"
# Проверка, что файл существует
if [ ! -f "$LOCAL_FILE" ]; then
echo "Ошибка: файл $LOCAL_FILE не найден."
exit 1
fi
# Копирование файла
scp "$LOCAL_FILE" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
# Проверка результата
if [ $? -eq 0 ]; then
echo "Файл успешно загружен на сервер."
else
echo "Ошибка при загрузке файла на сервер."
exit 1
fi
#echo
#echo "Нажмите Enter, чтобы закрыть..."
#read

View File

@ -1,404 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta name="robots" content="noindex, nofollow">
<meta charset="UTF-8" />
<title>Shine Payments — Phantom demo (devnet, deep logs)</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 20px; }
h1 { font-size: 18px; margin-bottom: 12px; }
.row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
button { padding: 8px 12px; border-radius: 8px; border: 1px solid #ccc; cursor: pointer; }
button:hover { background: #f5f5f5; }
#log {
background: #0a0a0a; color: #d1d5db; padding: 12px; border-radius: 10px;
min-height: 220px; max-height: 60vh; overflow: auto; line-height: 1.4; white-space: pre-wrap;
box-shadow: inset 0 0 0 1px #222;
}
.muted { color: #6b7280; }
.ok { color: #86efac; }
.err { color: #fca5a5; }
/* ——— banner с логотипом и предупреждением ——— */
.banner {
display: flex; align-items: center; gap: 14px;
padding: 12px 14px; margin: 12px 0 18px;
border: 1px solid #f1e5a8; border-radius: 12px;
background: #fffbe6; color: #7a5d00;
}
.banner img { width: 44px; height: 44px; border-radius: 8px; flex: 0 0 auto; }
.banner .txt { line-height: 1.35; }
.banner .txt b { font-weight: 700; }
.banner .en { margin-top: 6px; color: #6b7280; font-size: 13px; }
</style>
</head>
<body>
<!-- DEV BANNER (logo + RU/EN notice) -->
<div class="banner" role="note" aria-label="Dev notice">
<img src="./shine_nft_logo_256.png" alt="Shine logo">
<div class="txt">
<div><b>ВНИМАНИЕ:</b> это тестовая страница для внутренней разработки. Я разбираюсь и пишу смарт-контракт и dApp для будущего токена и тестирую его работу в Devnet и подключение кошелька Phantom. Страница не является рабочим продуктом и предназначена только для внутренних тестов.</div>
<div class="en"><b>NOTICE:</b> this is a development test page. Im building a smart contract and dApp for a future token and testing it on Devnet and Phantom wallet connection. This page is not a live product and is intended for internal testing only.</div>
</div>
</div>
<h1>Shine Payments — Phantom wallet (devnet)</h1>
<div class="row">
<button id="btnConnect">Connect Phantom</button>
<button id="btnInfo" disabled>Показать адрес и баланс</button>
<button id="btnAirdrop" disabled>Airdrop 1 SOL (devnet)</button>
<button id="btnInit" disabled>Выполнить init()</button>
<button id="btn-delete-init" disabled>🗑️ Удалить PDA (delete_init)</button>
</div>
<div class="muted">
В Phantom выбери сеть <b>Devnet</b>. Логи ниже и в консоли (F12 → Console).
</div>
<pre id="log"></pre>
<!-- Единственная зависимость: web3.js -->
<script src="https://unpkg.com/@solana/web3.js@1.95.0/lib/index.iife.min.js"></script>
<script>
(async () => {
const logEl = document.getElementById("log");
const autoScroll = () => { logEl.scrollTop = logEl.scrollHeight; };
const log = (...a) => { console.log(...a); logEl.textContent += a.join(" ") + "\n"; autoScroll(); };
const logOk = (...a) => log('%c' + a.join(" "), 'color:#86efac');
const logErr = (...a) => log('%c' + a.join(" "), 'color:#fca5a5');
// ===== ПАРАМЕТРЫ ПРОЕКТА =====
const RPC_URL = "https://api.devnet.solana.com";
const PROGRAM_ID = new solanaWeb3.PublicKey("92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW");
const STATE_SEED = "shine_investments_state";
// Лучше "processed" для симуляций + "confirmed" для подтверждений
const connection = new solanaWeb3.Connection(RPC_URL, { commitment: "confirmed" });
const enc = new TextEncoder();
let provider = null; // Phantom provider (window.solana)
let walletPubkey = null; // PublicKey пользователя из Phantom
let logsSubId = null; // id подписки на логи программы
// ===== УТИЛИТЫ ОШИБОК/ЛОГОВ =====
function safeJson(v) {
try { return JSON.stringify(v, null, 2); } catch { return String(v); }
}
function printRpcError(prefix, e) {
// Структура ошибок Solana/Anchor часто лежит в e, e.message, e.data, e.logs, e.code
logErr(prefix);
if (!e) return;
if (e.message) logErr("message:", e.message);
if (e.code !== undefined) logErr("code:", e.code);
if (e.name) logErr("name:", e.name);
// web3.js/JSON-RPC иногда кладёт это сюда:
if (e.data) {
if (e.data.logs) {
logErr("logs:");
(e.data.logs || []).forEach(l => logErr(" " + l));
}
if (e.data.err) {
logErr("rpc err:", safeJson(e.data.err));
}
}
// Некоторые кошельки/обёртки кладут логи прямо в e.logs
if (e.logs) {
logErr("logs:");
(e.logs || []).forEach(l => logErr(" " + l));
}
// Стек — в конце
if (e.stack) {
logErr("stack:\n" + e.stack);
}
}
async function simulateAndLog(tx) {
// Симуляция перед отправкой — ключ к пониманию, где падает инструкция.
try {
const sim = await connection.simulateTransaction(tx, {
sigVerify: false, // подпись не требуется
commitment: "processed"
});
const v = sim.value;
log("🔎 simulate result — err:", safeJson(v.err));
if (v.logs?.length) {
log("🪵 simulate logs:");
v.logs.forEach(l => log(" " + l));
}
if (v.unitsConsumed !== undefined) {
log("⛽ compute units (simulate):", v.unitsConsumed);
}
return v;
} catch (e) {
printRpcError("❌ Ошибка simulateTransaction:", e);
return null;
}
}
async function confirmAndLog(signature, blockhashCtx) {
try {
const { blockhash, lastValidBlockHeight } = blockhashCtx;
log("🧱 confirm with blockhash/lastValid:", blockhash, "/", lastValidBlockHeight);
const res = await connection.confirmTransaction(
{ signature, blockhash, lastValidBlockHeight },
"confirmed"
);
log("📬 confirmation status:", safeJson(res.value));
return res.value;
} catch (e) {
printRpcError("❌ Ошибка confirmTransaction:", e);
return null;
}
}
async function getSigStatus(signature) {
try {
const st = await connection.getSignatureStatus(signature, { searchTransactionHistory: true });
log("🧾 signature status:", safeJson(st?.value));
return st?.value;
} catch (e) {
printRpcError("❌ Ошибка getSignatureStatus:", e);
}
}
// ===== Anchor discriminator (8 байт) =====
async function anchorDiscriminator8(name) {
const hash = await crypto.subtle.digest("SHA-256", enc.encode("global:" + name));
return new Uint8Array(hash).slice(0, 8);
}
// ===== PDA =====
async function getStatePda() {
const [pda] = await solanaWeb3.PublicKey.findProgramAddress(
[enc.encode(STATE_SEED)],
PROGRAM_ID
);
return pda;
}
// ===== Отправка через Phantom с расширенным логированием =====
async function sendViaPhantom(tx, blockhashCtx) {
// Вариант с signAndSendTransaction вернёт сразу signature,
// но иногда теряются preflight-детали. Мы дополнительно логируем simulate.
// Обязательно: транзакция без подписей, место под Lighthouse-инструкции.
await simulateAndLog(tx); // можно оставить, подпись не требуется
if (!provider.signAndSendTransaction) throw new Error("Phantom не поддерживает signAndSendTransaction");
const {signature} = await provider.signAndSendTransaction(tx);
logOk("✍️ отправлено, сигнатура:", signature);
await confirmAndLog(signature, blockhashCtx);
await getSigStatus(signature);
return signature;
}
function setButtonsEnabled(connected) {
document.getElementById("btnInfo").disabled = !connected;
document.getElementById("btnAirdrop").disabled = !connected;
document.getElementById("btnInit").disabled = !connected;
document.getElementById("btn-delete-init").disabled = !connected;
}
// ===== CONNECT =====
document.getElementById("btnConnect").addEventListener("click", async () => {
try {
if (!window.solana || !window.solana.isPhantom) {
logErr("❌ Phantom не найден. Установи расширение Phantom Wallet.");
return;
}
provider = window.solana;
// Подключаемся ТОЛЬКО по клику пользователя:
log("🔌 Ожидаем подключение Phantom…");
await provider.connect(); // без onlyIfTrusted — это уже явный жест пользователя
walletPubkey = provider.publicKey;
logOk("✅ Подключено:", walletPubkey.toBase58());
setButtonsEnabled(true);
// Подписка на логи программы — помогает увидеть то, что в simulate/confirm могло не попасть
try {
if (logsSubId) {
await connection.removeOnLogsListener(logsSubId);
logsSubId = null;
}
logsSubId = connection.onLogs(PROGRAM_ID, (ev) => {
log("🛰 onLogs:", ev.signature, "err:", safeJson(ev.err));
(ev.logs || []).forEach(l => log(" " + l));
}, "confirmed");
log("📡 Подписка на логи программы включена.");
} catch (e) {
printRpcError("⚠️ Не удалось подписаться на логи программы:", e);
}
provider.on?.("disconnect", async () => {
log("🔌 Отключено");
setButtonsEnabled(false);
walletPubkey = null;
if (logsSubId) {
try { await connection.removeOnLogsListener(logsSubId); } catch {}
logsSubId = null;
}
});
} catch (e) {
printRpcError("❌ Connect error:", e);
}
});
// ===== INFO =====
document.getElementById("btnInfo").addEventListener("click", async () => {
try {
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
const balanceLamports = await connection.getBalance(walletPubkey, "processed");
const statePda = await getStatePda();
log("👛 Кошелёк:", walletPubkey.toBase58());
log("💰 Баланс:", balanceLamports / solanaWeb3.LAMPORTS_PER_SOL, "SOL");
log("📦 statePda:", statePda.toBase58());
log("🌐 RPC:", RPC_URL);
} catch (e) {
printRpcError("❌ Ошибка INFO:", e);
}
});
// ===== AIRDROP (devnet) =====
document.getElementById("btnAirdrop").addEventListener("click", async () => {
try {
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
log("⛽ Запрос airdrop 1 SOL на", walletPubkey.toBase58());
const sig = await connection.requestAirdrop(walletPubkey, 1 * solanaWeb3.LAMPORTS_PER_SOL);
log("⏳ confirm airdrop…");
const { value: ctx } = await connection.getLatestBlockhashAndContext("processed");
await confirmAndLog(sig, ctx);
logOk("✅ Airdrop tx:", sig);
const bal = await connection.getBalance(walletPubkey);
log("💰 Новый баланс:", bal / solanaWeb3.LAMPORTS_PER_SOL, "SOL");
} catch (e) {
printRpcError("❌ Ошибка airdrop:", e);
}
});
// ===== INIT() =====
document.getElementById("btnInit").addEventListener("click", async () => {
try {
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
const statePda = await getStatePda();
log("🚀 Вызываем init()");
log(" payer: ", walletPubkey.toBase58());
log(" statePda: ", statePda.toBase58());
log(" programId:", PROGRAM_ID.toBase58());
// 8 байт дискриминатора Anchor для "global:init"
const data = await anchorDiscriminator8("init"); // Uint8Array длиной 8
// Аккаунты в том порядке, который ожидает on-chain метод
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, // payer (signer)
{ pubkey: statePda, isSigner: false, isWritable: true }, // state PDA
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
// Получаем блокхеш/lastValidBlockHeight и логируем
const { value: ctx } = await connection.getLatestBlockhashAndContext("processed");
const { blockhash, lastValidBlockHeight } = ctx;
log("⏱ blockhash:", blockhash);
log("⏱ lastValidBlockHeight:", lastValidBlockHeight);
const tx = new solanaWeb3.Transaction({
feePayer: walletPubkey,
recentBlockhash: blockhash,
}).add(ix);
// 1) Симуляция — сразу покажет логи Anchor/InstructionError
await simulateAndLog(tx);
log("📝 Подписываем в Phantom…");
// 2) Отправка + подтверждение с расширенными логами
const sig = await sendViaPhantom(tx, { blockhash, lastValidBlockHeight });
logOk("✅ Готово! tx:", sig);
log("🔗 Explorer:", `https://explorer.solana.com/tx/${sig}?cluster=devnet`);
} catch (e) {
// Печатаем максимально подробно
printRpcError("❌ Ошибка init:", e);
}
});
// ===== DELETE INIT =====
document.getElementById("btn-delete-init").addEventListener("click", async () => {
try {
// 1) Проверка подключения
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
// 2) PDA — те же сиды, что и в init
const statePda = await getStatePda();
log("🗑️ Вызываем delete_init()");
log(" signer: ", walletPubkey.toBase58());
log(" statePda: ", statePda.toBase58());
log(" programId:", PROGRAM_ID.toBase58());
// 3) Дискриминатор Anchor для "global:delete_init"
const data = await anchorDiscriminator8("delete_init"); // 8 байт
// 4) Аккаунты в порядке, который ожидает on-chain метод
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, // signer (получатель ренты)
{ pubkey: statePda, isSigner: false, isWritable: true }, // state_pda
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({
programId: PROGRAM_ID,
keys,
data, // только 8 байт дискриминатора, т.к. у метода нет аргументов
});
// 5) Блокхеш, формирование транзакции
const { value: ctx } = await connection.getLatestBlockhashAndContext("processed");
const { blockhash, lastValidBlockHeight } = ctx;
log("⏱ blockhash:", blockhash);
log("⏱ lastValidBlockHeight:", lastValidBlockHeight);
const tx = new solanaWeb3.Transaction({
feePayer: walletPubkey,
recentBlockhash: blockhash,
}).add(ix);
// 6) Симуляция → отправка → подтверждение
await simulateAndLog(tx);
log("📝 Подписываем в Phantom…");
const sig = await sendViaPhantom(tx, { blockhash, lastValidBlockHeight });
logOk("✅ delete_init выполнен. tx:", sig);
log("🔗 Explorer:", `https://explorer.solana.com/tx/${sig}?cluster=devnet`);
alert(`PDA удалён, рента возвращена подписанту.\nTx: ${sig}`);
} catch (e) {
printRpcError("❌ Ошибка delete_init:", e);
alert(`Ошибка delete_init: ${e.message || e}`);
}
});
// Больше НИКАКИХ автоконнектов на загрузке страницы.
if (window.solana?.isPhantom) {
provider = window.solana;
log(" Phantom найден. Нажми «Connect Phantom», чтобы подключиться.");
} else {
log(" Установи Phantom Wallet: https://phantom.app");
}
}
)();
</script>
</body>
</html>

View File

@ -1,326 +0,0 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke_signed, system_instruction};
use common::utils::*; // тянем общие PDA-хелперы из programs/common
// === добавлено: используем наш NFT-модуль ===
use crate::nft::{CreateNftParams, create_nft_with_freeze};
// ============================================
/// Утилита чтения структуры из PDA: читает байты и десериализует.
/// Возвращает ошибку, если данных нет/пустые/неверный формат.
fn read_state_from_pda(pda: &AccountInfo) -> Result<InvestState> {
let raw = safe_read_pda(pda); // ← берём Vec<u8> (или пустой)
require!(!raw.is_empty(), ErrCode::EmptyPdaData); // ← пусто — ошибка
let st = deserialize_invest_state(&raw)?; // ← десериализуем по формату
require!(st.format == INVEST_STATE_FORMAT_V1, ErrCode::UnsupportedFormat); // ← проверяем версию
Ok(st)
}
/// Утилита записи структуры в PDA: сериализует и пишет.
/// Важно: сам аккаунт уже должен существовать и быть #[account(mut)].
fn write_state_to_pda(pda: &AccountInfo, s: &InvestState) -> Result<()> {
let raw = serialize_invest_state_v1(s); // ← 24 байта
write_to_pda(pda, &raw) // ← записываем в начало data
}
/// ==============================================
/// Контексты инструкций (минимально необходимые)
/// ==============================================
/// init: создаём PDA и кладём в него PayStateV1 {format=1, coef=10, ...0}
#[derive(Accounts)]
pub struct Init<'info> {
/// Плательщик аренды за PDA; подписант транзакции.
#[account(mut)]
pub payer: Signer<'info>,
/// Наш PDA (с произвольным типом, чтобы работать через AccountInfo).
/// Проверку адреса делаем в handler (по seed + bump), чтобы избежать подмены.
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа.
pub system_program: Program<'info, System>,
}
/// Общие аккаунты для invest/add_bonus/claim:
/// Везде просто читаем/пишем одно и то же состояние из того же PDA.
#[derive(Accounts)]
pub struct UseState<'info> {
/// Любой платящий/подписант (в реальном коде — свои проверки).
pub signer: Signer<'info>,
/// Тот же PDA с состоянием (должен уже существовать).
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа (на всякий случай; может не понадобиться).
pub system_program: Program<'info, System>,
}
/// ==============================================
/// Программа
/// ==============================================
use super::*;
use anchor_lang::prelude::*;
/// ------------------------------------------
/// init: создаёт PDA и записывает в него дефолтное состояние.
/// format = 1, coef = 10, остальные поля = 0.
/// ------------------------------------------
pub fn init(ctx: Context<Init>) -> Result<()> {
let program_id = ctx.program_id; // ← адрес этой программы
// 1. Проверка что вызывает именно разрешённый ключ
/* todo пока все могут вызыватьно !! но в итоге будет добавленна проверка что бы только дао могло вызвать эту функцию один раз
require_keys_eq!(
ctx.accounts.payer.key(),
ALLOWED_INIT_CALLER,
ErrCode::InvalidSigner
);
*/
// 2. Проверка что PDA ещё не создан
if ctx.accounts.state_pda.data_len() > 0 && ctx.accounts.state_pda.owner != &System::id() {
return Err(error!(ErrCode::PdaAlreadyExists));
}
// 2. Ещё раз Проверка что PDA ещё не создан
if ctx.accounts.state_pda.owner != &System::id()
|| ctx.accounts.state_pda.lamports() > 0
{
// Если аккаунт уже создан и не пустой
return Err(error!(ErrCode::PdaAlreadyExists));
}
let pda_key_expected = Pubkey::find_program_address(&[crate::PDA_SEED_PREFIX], program_id).0; // ← вычисляем PDA
require_keys_eq!(
pda_key_expected,
ctx.accounts.state_pda.key(),
ErrCode::InvalidPdaAddress
); // ← убеждаемся, что нам подали именно правильный PDA
// Конструируем дефолтную структуру состояния.
let state = InvestState {
format: INVEST_STATE_FORMAT_V1, // ← 1
coef: crate::DEFAULT_COEF, // ← 10
q1_tokens: 0, // ← нули
sum1_bonus: 0,
q1_paid_tokens: 0,
sum1_paid_bonus: 0,
};
// Сериализуем в 24 байта.
let data = serialize_invest_state_v1(&state);
// Для подписи PDA нужен bump; здесь получим (ключ, bump).
let (_pda_key, bump) = Pubkey::find_program_address(&[crate::PDA_SEED_PREFIX], program_id);
// Сиды для invoke_signed: [seed, bump]
let seeds: [&[u8]; 2] = [crate::PDA_SEED_PREFIX, &[bump]];
// Создаём и сразу записываем, арендный минимум оплачивает payer.
create_and_write_pda(
&ctx.accounts.state_pda.to_account_info(), // куда пишем
&ctx.accounts.payer.to_account_info(), // кто платит
&ctx.accounts.system_program.to_account_info(),
program_id,
&seeds,
data,
crate::PAY_STATE_SPACE, // резерв с запасом
)?;
Ok(())
}
/// ------------------------------------------
/// invest: «внос инвестиций».
/// По заданию: в начале читаем состояние, в конце сохраняем.
/// (Здесь логика модификации не задана — оставляем как заглушку.)
/// ------------------------------------------
pub fn invest(ctx: Context<UseState>, _amount: u64) -> Result<()> {
// 1) читаем
let mut st = read_state_from_pda(&ctx.accounts.state_pda.to_account_info())?; // ← PayStateV1
// --- тут можно модифицировать st по твоей бизнес-логике ---
// Например, ничего не меняем сейчас (заглушка).
let _ = &mut st; // чтоб компилятор не ругался, если пока не используем
// 2) сохраняем
write_state_to_pda(&ctx.accounts.state_pda.to_account_info(), &st)?;
Ok(())
}
/// ------------------------------------------
/// add_bonus: «начисление бонусов» (обычно вызывать от DAO).
/// По заданию: читаем в начале, создаём/добавляем NFT в очередь, сохраняем в конце.
/// Для операций с NFT используем расширенный контекст AddBonusCtx (см. lib.rs).
/// ------------------------------------------
pub fn add_bonus(ctx: Context<crate::AddBonusCtx>, investor: Pubkey, amount: u64) -> Result<()> {
// 1) читаем состояние
let mut st = read_state_from_pda(&ctx.accounts.state_pda.to_account_info())?;
// 2) создаём/добавляем NFT через модуль nft (создание metadata, mint 1, freeze, master edition, verify)
let next_index = st.q1_tokens as u64 + 1;
let params = CreateNftParams {
name: format!("Bonus #{}", next_index),
symbol: "BN".to_string(),
uri: "https://example.com/nft.json".to_string(), // заглушка для devnet-теста
index: next_index,
recipient: investor,
};
// ВАЖНО: mint_pda должен быть создан ТЕСТОМ заранее с decimals=0, mint_authority=signer, freeze_authority=signer.
create_nft_with_freeze(&ctx, params)?;
// 3) обновляем агрегаты очереди (минимально: увеличим счётчик и сумму бонусов)
st.q1_tokens = st.q1_tokens.saturating_add(1);
let add = u32::try_from(core::cmp::min(amount, u64::from(u32::MAX))).unwrap_or(u32::MAX);
st.sum1_bonus = st.sum1_bonus.saturating_add(add);
// 4) сохраняем
write_state_to_pda(&ctx.accounts.state_pda.to_account_info(), &st)?;
Ok(())
}
/// ------------------------------------------
/// claim: «выплата».
/// По заданию: читаем в начале, сохраняем в конце.
/// ------------------------------------------
pub fn claim(ctx: Context<UseState>) -> Result<()> {
// 1) читаем
let mut st = read_state_from_pda(&ctx.accounts.state_pda.to_account_info())?;
// --- тут твоя логика списаний/выплат ---
let _ = &mut st; // заглушка
// 2) сохраняем
write_state_to_pda(&ctx.accounts.state_pda.to_account_info(), &st)?;
Ok(())
}
//todo
/// ==============================================
/// Коды ошибок (берём из твоего блока; можно расширять)
/// ==============================================
#[error_code]
pub enum ErrCode {
/// Система уже инициализирована и не может быть инициализирована повторно!
#[msg("Система уже инициализирована и не может быть инициализирована повторно!")]
SystemAlreadyInitialized = 1000,
#[msg("PDA не содержит данных или не инициализирован")]
EmptyPdaData = 1002,
#[msg("Пользователь уже зарегистрирован")]
UserAlreadyExists = 1003,
#[msg("Некорректный логин")]
InvalidLogin = 1004,
#[msg("Не совпадает PDA адрес")]
InvalidPdaAddress = 1006,
#[msg("Формат данных не поддерживается")]
UnsupportedFormat = 1011,
#[msg("Ошибка при десериализации")]
DeserializationError = 1012,
/// PDA уже существует, создание невозможно
#[msg("PDA-аккаунт уже существует и не может быть создан повторно.")]
PdaAlreadyExists = 1009,
#[msg("Подписавший не совпадает с ожидаемым пользователем (временное ограничение)")]
InvalidSigner = 1005,
/// Не получилось создать пользователя
#[msg("Не получилось создать пользователя, система уже перегружена, попробуйте позже!")]
NoSuitableIdPda = 1010,
}
use anchor_lang::prelude::*;
/// ================================
/// КОНСТАНТЫ ФОРМАТА / ДЛИНЫ ДАННЫХ
/// ================================
/// Версия формата хранения состояния.
/// Мы жёстко фиксируем «1», чтобы код мог отличать будущие версии.
pub const INVEST_STATE_FORMAT_V1: u32 = 1;
/// Сырые данные состояния V1 занимают ровно 6 * 4 = 24 байта.
pub const INVEST_STATE_RAW_LEN_V1: usize = 24; // байт
/// ================================
/// ОПИСАНИЕ СТРУКТУРЫ СОСТОЯНИЯ (V1)
/// ================================
#[derive(Clone, Copy, Debug, Default)]
pub struct InvestState {
pub format: u32,
pub coef: u32,
pub q1_tokens: u32,
pub sum1_bonus: u32,
pub q1_paid_tokens: u32,
pub sum1_paid_bonus: u32,
}
/// ========================================
/// СЕРИАЛИЗАЦИЯ (структура -> массив байт)
/// ========================================
pub fn serialize_invest_state_v1(s: &InvestState) -> Vec<u8> {
let mut out = Vec::with_capacity(INVEST_STATE_RAW_LEN_V1);
out.extend_from_slice(&INVEST_STATE_FORMAT_V1.to_le_bytes()); // [0..4)
out.extend_from_slice(&s.coef.to_le_bytes()); // [4..8)
out.extend_from_slice(&s.q1_tokens.to_le_bytes()); // [8..12)
out.extend_from_slice(&s.sum1_bonus.to_le_bytes()); // [12..16)
out.extend_from_slice(&s.q1_paid_tokens.to_le_bytes()); // [16..20)
out.extend_from_slice(&s.sum1_paid_bonus.to_le_bytes()); // [20..24)
debug_assert_eq!(out.len(), INVEST_STATE_RAW_LEN_V1);
out
}
/// ===========================================
/// ДЕСЕРИАЛИЗАЦИЯ (массив байт -> структура)
/// ===========================================
pub fn deserialize_invest_state(data: &[u8]) -> Result<InvestState> {
if data.len() < INVEST_STATE_RAW_LEN_V1 {
return Err(error!(ErrCode::DeserializationError));
}
fn read_u32_le(slice: &[u8], start: usize) -> u32 {
let bytes: [u8; 4] = slice[start..start + 4]
.try_into()
.expect("slice has enough length due to pre-check");
u32::from_le_bytes(bytes)
}
let format = read_u32_le(data, 0);
if format != INVEST_STATE_FORMAT_V1 {
return Err(error!(ErrCode::UnsupportedFormat));
}
let coef = read_u32_le(data, 4);
let q1_tokens = read_u32_le(data, 8);
let sum1_bonus = read_u32_le(data, 12);
let q1_paid_tokens = read_u32_le(data, 16);
let sum1_paid_bonus = read_u32_le(data, 20);
Ok(InvestState { format, coef, q1_tokens, sum1_bonus, q1_paid_tokens, sum1_paid_bonus })
}

View File

@ -1,158 +0,0 @@
use anchor_lang::prelude::*;
declare_id!("qpgnAKhsXgPPaqQWfXhpme7UnG8GyStssuoSjF6Fzy3");
/// Подключаем модуль с полной реализацией.
pub mod investments;
use investments::*; // импортируем всё в корень
// === модуль NFT ===
pub mod nft;
// ==============================================
// Константы формата / сидов / размеров
// ==============================================
/// Префикс (seed) для PDA, где храним глобальное состояние выплат.
/// Важно: сид — это просто набор байт; здесь он фиксированный.
pub const PDA_SEED_PREFIX: &[u8] = b"shine_investments_state";
/// Значение коэффициента «по умолчанию» при инициализации.
pub const DEFAULT_COEF: u32 = 10; // ← «коэффициент» = 10 при init
/// Ровно столько байт резервируем под PDA-данные.
/// (Можно добавить запас на будущее, но по заданию — только 28.)
pub const PAY_STATE_SPACE: u64 = 50; // просто сделал с запасом
// ==============================================
// Программа
// ==============================================
#[program]
pub mod shine_payments {
use super::*;
// Явно подтягиваем типы и функции, чтобы не было путаницы после предыдущих ошибок парсера
use crate::investments::{Init, UseState};
use crate::investments::{
add_bonus as inv_add_bonus, claim as inv_claim, init as inv_init, invest as inv_invest,
ErrCode,
};
/// init — создаёт PDA и кладёт дефолтное состояние.
pub fn init(ctx: Context<Init>) -> Result<()> {
inv_init(ctx)
}
/// invest — в начале читает состояние, в конце сохраняет (логика внутри модуля).
pub fn invest(ctx: Context<UseState>, amount: u64) -> Result<()> {
inv_invest(ctx, amount)
}
/// add_bonus — начисление бонусов (обычно от DAO).
/// Для NFT используем расширенный контекст AddBonusCtx (с аккаунтами коллекции и т.п.).
pub fn add_bonus(ctx: Context<AddBonusCtx>, investor: Pubkey, amount: u64) -> Result<()> {
inv_add_bonus(ctx, investor, amount)
}
/// claim — выплата.
pub fn claim(ctx: Context<UseState>) -> Result<()> {
inv_claim(ctx)
}
/// ВРЕМЕННАЯ ФУНКЦИЯ только для тестов (в итоговой версии её не будет):
/// deleteInit — удалить PDA из init и вернуть ренту подписанту.
pub fn delete_init(ctx: Context<DeleteInit>) -> Result<()> {
let program_id = ctx.program_id;
// PDA по тем же сид/бамп, что и в init
let (expected_pda, _bump) = Pubkey::find_program_address(&[PDA_SEED_PREFIX], program_id);
require_keys_eq!(
expected_pda,
ctx.accounts.state_pda.key(),
ErrCode::InvalidPdaAddress
);
// Рента уйдёт на счёт подписанта (signer)
common::utils::delete_pda_return_rent(
&ctx.accounts.state_pda.to_account_info(),
&ctx.accounts.signer.to_account_info(),
program_id,
)
}
}
// ==============================================
// Контексты вне #[program]
// ==============================================
/// Контекст для deleteInit (временный для тестов)
#[derive(Accounts)]
pub struct DeleteInit<'info> {
/// Подписант транзакции — ПОЛУЧАТЕЛЬ ренты
#[account(mut)]
pub signer: Signer<'info>,
/// Тот самый PDA из init
/// CHECK: адрес валидируем в хендлере по сид-у
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа
pub system_program: Program<'info, System>,
}
/// Контекст для add_bonus: полный набор аккаунтов для операций с NFT и коллекцией.
/// (Комменты по стилю проекта оставлены.)
#[derive(Accounts)]
pub struct AddBonusCtx<'info> {
/// Любой платящий/подписант (в реальном коде — свои проверки).
#[account(mut)]
pub signer: Signer<'info>,
/// Тот же PDA с состоянием (должен уже существовать).
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
// --- аккаунты минтимого NFT ---
/// Mint создаваемого NFT (должен быть создан заранее: decimals=0, mint_authority=signer, freeze_authority=signer)
/// CHECK
#[account(mut)]
pub mint_pda: UncheckedAccount<'info>,
/// ATA получателя (может быть предсоздан тестом)
/// CHECK
#[account(mut)]
pub recipient_ata: UncheckedAccount<'info>,
/// Владелец ATA (инвестор)
/// CHECK
pub recipient_owner: UncheckedAccount<'info>,
// --- аккаунты коллекции (уже созданной заранее) ---
/// CHECK
pub collection_mint: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_master_edition_pda: UncheckedAccount<'info>,
/// Апдейтер коллекции (update authority)
pub collection_update_authority: Signer<'info>,
// --- metadata + master edition для создаваемого NFT ---
/// CHECK
#[account(mut)]
pub metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub master_edition_pda: UncheckedAccount<'info>,
// --- программы ---
/// CHECK: проверяется по ID внутри nft.rs
pub token_metadata_program: UncheckedAccount<'info>,
pub token_program: Program<'info, anchor_spl::token::Token>,
pub associated_token_program: Program<'info, anchor_spl::associated_token::AssociatedToken>,
pub system_program: Program<'info, System>,
}

View File

@ -1,190 +0,0 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke, program::invoke_signed};
use anchor_spl::{associated_token::AssociatedToken, token::Token};
use mpl_token_metadata::{
ID as TM_ID,
instructions::{
CreateMasterEditionV3Builder,
CreateMetadataAccountV3Builder,
SetAndVerifySizedCollectionItemBuilder,
},
types::{Collection, Creator, DataV2, Uses, UseMethod},
};
use spl_token::instruction as spl_ix;
/// Параметры для минта NFT
#[derive(Clone)]
pub struct CreateNftParams {
pub name: String,
pub symbol: String,
pub uri: String,
pub index: u64,
pub recipient: Pubkey,
}
/// Создание metadata, чеканка 1 токена, freeze ATA, создание master edition, verify в коллекции.
pub fn create_nft_with_freeze(
ctx: &Context<crate::AddBonusCtx>,
params: CreateNftParams,
) -> Result<()> {
let a = &ctx.accounts;
// Проверяем что это именно программа Metaplex Token Metadata
require_keys_eq!(a.token_metadata_program.key(), TM_ID, CustomError::InvalidMetadataProgram);
// 1) Создание Metadata для нового NFT
let creators = Some(vec![Creator {
address: a.collection_update_authority.key(),
verified: true,
share: 100,
}]);
let data = DataV2 {
name: truncate(&params.name, 32),
symbol: truncate(&params.symbol, 10),
uri: truncate(&params.uri, 256),
seller_fee_basis_points: 0,
creators,
collection: Some(Collection {
verified: false, // отметим как часть коллекции позже через verify
key: a.collection_mint.key(),
}),
uses: Some(Uses {
use_method: UseMethod::Burn,
remaining: 1,
total: 1,
}),
};
// В mpl-token-metadata v5 update_authority(pubkey, is_signer: bool)
let ix_md = CreateMetadataAccountV3Builder::new()
.metadata(a.metadata_pda.key())
.mint(a.mint_pda.key())
.mint_authority(a.signer.key())
.payer(a.signer.key())
.update_authority(a.collection_update_authority.key(), true)
.system_program(a.system_program.key())
.data(data)
.is_mutable(true)
.instruction();
invoke_signed(
&ix_md,
&[
a.metadata_pda.to_account_info(),
a.mint_pda.to_account_info(),
a.signer.to_account_info(),
a.collection_update_authority.to_account_info(),
a.system_program.to_account_info(),
a.token_metadata_program.to_account_info(),
],
&[],
)?;
// 2) Чеканим 1 токен на ATA получателя
let ix_mint_to = spl_ix::mint_to(
&a.token_program.key(),
&a.mint_pda.key(),
&a.recipient_ata.key(),
&a.signer.key(),
&[],
1,
)?;
invoke(
&ix_mint_to,
&[
a.mint_pda.to_account_info(),
a.recipient_ata.to_account_info(),
a.signer.to_account_info(),
a.token_program.to_account_info(),
],
)?;
// 3) Замораживаем ATA получателя (freeze authority = signer)
let ix_freeze = spl_ix::freeze_account(
&a.token_program.key(),
&a.recipient_ata.key(),
&a.mint_pda.key(),
&a.signer.key(),
&[],
)?;
invoke(
&ix_freeze,
&[
a.recipient_ata.to_account_info(),
a.mint_pda.to_account_info(),
a.signer.to_account_info(),
a.token_program.to_account_info(),
],
)?;
// 4) Создаём Master Edition
let ix_me = CreateMasterEditionV3Builder::new()
.edition(a.master_edition_pda.key())
.mint(a.mint_pda.key())
.update_authority(a.collection_update_authority.key())
.mint_authority(a.signer.key())
.payer(a.signer.key())
.metadata(a.metadata_pda.key())
.token_program(a.token_program.key())
.system_program(a.system_program.key())
.max_supply(0)
.instruction();
invoke_signed(
&ix_me,
&[
a.master_edition_pda.to_account_info(),
a.mint_pda.to_account_info(),
a.collection_update_authority.to_account_info(),
a.signer.to_account_info(),
a.metadata_pda.to_account_info(),
a.token_program.to_account_info(),
a.system_program.to_account_info(),
a.token_metadata_program.to_account_info(),
],
&[],
)?;
// 5) Verify как часть коллекции
// Метод называется collection_master_edition_account(...)
let ix_verify = SetAndVerifySizedCollectionItemBuilder::new()
.metadata(a.metadata_pda.key())
.collection_authority(a.collection_update_authority.key())
.payer(a.signer.key())
.update_authority(a.collection_update_authority.key())
.collection_mint(a.collection_mint.key())
.collection(a.collection_metadata_pda.key())
.collection_master_edition_account(a.collection_master_edition_pda.key())
.instruction();
invoke_signed(
&ix_verify,
&[
a.metadata_pda.to_account_info(),
a.collection_update_authority.to_account_info(),
a.signer.to_account_info(),
a.collection_update_authority.to_account_info(),
a.collection_mint.to_account_info(),
a.collection_metadata_pda.to_account_info(),
a.collection_master_edition_pda.to_account_info(),
a.token_metadata_program.to_account_info(),
],
&[],
)?;
msg!("NFT создан, заморожен, мастер-издание создано и верифицировано в коллекции (index={})", params.index);
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() } else { s.chars().take(max).collect() }
}
#[error_code]
pub enum CustomError {
#[msg("Invalid Token Metadata program account")]
InvalidMetadataProgram,
}

View File

@ -14,6 +14,7 @@ use std::str::FromStr;
const MAGIC: &[u8; 5] = b"SHiNE";
const FORMAT_MAJOR: u8 = 1;
const FORMAT_MINOR: u8 = 0;
const KEY_STATUS_CREATED: u8 = 0;
const RESERVED_BYTES: [u8; 5] = [0, 0, 0, 0, 0];
const ZERO_HASH: [u8; 32] = [0; 32];
@ -58,8 +59,11 @@ pub struct UserRecord {
pub version: u32,
pub prev_hash: [u8; 32],
pub login: String,
pub root_key_status: u8,
pub root_key: Pubkey,
pub blockchain_key_status: u8,
pub blockchain_key: Pubkey,
pub device_key_status: u8,
pub device_key: Pubkey,
pub chain_number: u16,
pub balance: u64,
@ -133,8 +137,11 @@ pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) ->
version: 0,
prev_hash: ZERO_HASH,
login: args.login.clone(),
root_key_status: KEY_STATUS_CREATED,
root_key: args.root_key,
blockchain_key_status: KEY_STATUS_CREATED,
blockchain_key: args.fields.blockchain_key,
device_key_status: KEY_STATUS_CREATED,
device_key: args.fields.device_key,
chain_number: args.fields.chain_number,
balance: start_balance,
@ -226,6 +233,12 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
args.root_key,
ErrCode::ImmutableFieldChanged
);
require!(
old_record.root_key_status == KEY_STATUS_CREATED
&& old_record.blockchain_key_status == KEY_STATUS_CREATED
&& old_record.device_key_status == KEY_STATUS_CREATED,
ErrCode::InvalidRecordData
);
require!(
args.version == old_record.version.saturating_add(1),
ErrCode::InvalidVersion
@ -250,8 +263,11 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
version: args.version,
prev_hash: provided_prev_hash,
login: old_record.login.clone(),
root_key_status: old_record.root_key_status,
root_key: old_record.root_key,
blockchain_key_status: old_record.blockchain_key_status,
blockchain_key: args.fields.blockchain_key,
device_key_status: old_record.device_key_status,
device_key: args.fields.device_key,
chain_number: args.fields.chain_number,
balance: new_balance,
@ -320,8 +336,11 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>> {
out.push(login_bytes.len() as u8);
out.extend_from_slice(login_bytes);
out.push(record.root_key_status);
out.extend_from_slice(record.root_key.as_ref());
out.push(record.blockchain_key_status);
out.extend_from_slice(record.blockchain_key.as_ref());
out.push(record.device_key_status);
out.extend_from_slice(record.device_key.as_ref());
out.extend_from_slice(&record.chain_number.to_le_bytes());
@ -384,8 +403,11 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord> {
let prev_hash = read_fixed_32(useful, &mut cursor)?;
let login = read_len_prefixed_string(useful, &mut cursor)?;
let root_key_status = read_u8(useful, &mut cursor)?;
let root_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
let blockchain_key_status = read_u8(useful, &mut cursor)?;
let blockchain_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
let device_key_status = read_u8(useful, &mut cursor)?;
let device_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
let chain_number = read_u16(useful, &mut cursor)?;
@ -423,8 +445,11 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord> {
version,
prev_hash,
login,
root_key_status,
root_key,
blockchain_key_status,
blockchain_key,
device_key_status,
device_key,
chain_number,
balance,
@ -551,7 +576,7 @@ fn le_u16(data: &[u8], offset: usize) -> Result<u16> {
fn validate_login(login: &str) -> Result<()> {
require!(!login.is_empty(), ErrCode::InvalidLogin);
require!(login.len() <= 30, ErrCode::InvalidLogin);
require!(login.len() <= 25, ErrCode::InvalidLogin);
for ch in login.chars() {
if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') {
return Err(error!(ErrCode::InvalidLogin));

View File

@ -0,0 +1,38 @@
#!/usr/bin/env node
"use strict";
const path = require("path");
const BN = require("bn.js");
const { Connection, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createMintToInstruction, TOKEN_PROGRAM_ID } = require("@solana/spl-token");
const { withDepositGoverningTokens, PROGRAM_VERSION_V3, getTokenOwnerRecordAddress } = require("@solana/spl-governance");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, PublicKey } = require("./js_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const voter2 = loadKeypair(path.resolve(__dirname, cfg.VOTER2_KEYPAIR));
const realm = new PublicKey(cfg.REALM);
const mint = new PublicKey(cfg.GOVERNING_MINT);
const govPid = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const ata = getAssociatedTokenAddressSync(mint, voter2.publicKey, false, TOKEN_PROGRAM_ID);
const ix1 = [
createAssociatedTokenAccountIdempotentInstruction(main.publicKey, ata, voter2.publicKey, mint, TOKEN_PROGRAM_ID),
createMintToInstruction(mint, ata, main.publicKey, 1n, [], TOKEN_PROGRAM_ID),
];
const sigMint = await sendAndConfirmTransaction(conn, new Transaction().add(...ix1), [main], { commitment: "confirmed" });
const tor = await getTokenOwnerRecordAddress(govPid, realm, mint, voter2.publicKey);
const ai = await conn.getAccountInfo(tor, "confirmed");
let sigDeposit = null;
if (!ai) {
const ix2 = [];
await withDepositGoverningTokens(ix2, govPid, PROGRAM_VERSION_V3, realm, ata, mint, voter2.publicKey, main.publicKey, voter2.publicKey, new BN(1), true);
sigDeposit = await sendAndConfirmTransaction(conn, new Transaction().add(...ix2), [main, voter2], { commitment: "confirmed" });
}
console.log("prepare done");
console.log("mint tx:", sigMint);
console.log("deposit tx:", sigDeposit || "already exists");
}
main().catch((e) => { console.error(e?.message || e); process.exit(1); });

View File

@ -0,0 +1,56 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const BN = require("bn.js");
const { Connection, Keypair, SystemProgram, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const {
TOKEN_2022_PROGRAM_ID,
ExtensionType,
getMintLen,
createInitializeMintInstruction,
createInitializePermanentDelegateInstruction,
createInitializeNonTransferableMintInstruction,
getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction,
createMintToInstruction,
createSetAuthorityInstruction,
AuthorityType,
} = require("@solana/spl-token");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, nowStamp, PublicKey } = require("./js_common");
async function main() {
const target = process.argv[3];
if (!target) throw new Error("Usage: node 01_create_nft_for_wallet_admin.js <config.env> <target_wallet>");
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const governance = new PublicKey(cfg.GOVERNANCE);
const targetPk = new PublicKey(target);
const mint = Keypair.generate();
const mintLen = getMintLen([ExtensionType.NonTransferable, ExtensionType.PermanentDelegate]);
const rentMint = await conn.getMinimumBalanceForRentExemption(mintLen, "confirmed");
const ata = getAssociatedTokenAddressSync(mint.publicKey, targetPk, false, TOKEN_2022_PROGRAM_ID);
const tx = new Transaction().add(
SystemProgram.createAccount({ fromPubkey: main.publicKey, newAccountPubkey: mint.publicKey, space: mintLen, lamports: rentMint, programId: TOKEN_2022_PROGRAM_ID }),
createInitializeNonTransferableMintInstruction(mint.publicKey, TOKEN_2022_PROGRAM_ID),
createInitializePermanentDelegateInstruction(mint.publicKey, main.publicKey, TOKEN_2022_PROGRAM_ID),
createInitializeMintInstruction(mint.publicKey, 0, main.publicKey, main.publicKey, TOKEN_2022_PROGRAM_ID),
createAssociatedTokenAccountIdempotentInstruction(main.publicKey, ata, targetPk, mint.publicKey, TOKEN_2022_PROGRAM_ID),
createMintToInstruction(mint.publicKey, ata, main.publicKey, 1n, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint.publicKey, main.publicKey, AuthorityType.MintTokens, governance, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint.publicKey, main.publicKey, AuthorityType.FreezeAccount, governance, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint.publicKey, main.publicKey, AuthorityType.PermanentDelegate, governance, [], TOKEN_2022_PROGRAM_ID)
);
const sig = await sendAndConfirmTransaction(conn, tx, [main, mint], { commitment: "confirmed" });
const runs = path.resolve(__dirname, cfg.RUNS_DIR || "./runs");
fs.mkdirSync(runs, { recursive: true });
const report = { createdAt: new Date().toISOString(), mint: mint.publicKey.toBase58(), owner: targetPk.toBase58(), ata: ata.toBase58(), tx: sig };
const rp = path.join(runs, `${nowStamp()}_admin_create_nft_${targetPk.toBase58().slice(0,8)}.json`);
fs.writeFileSync(rp, JSON.stringify(report, null, 2));
console.log("NFT created and delegated to governance");
console.log("mint:", report.mint);
console.log("owner:", report.owner);
console.log("report:", rp);
}
main().catch((e)=>{console.error(e?.message||e);process.exit(1);});

View File

@ -0,0 +1,44 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { Connection, Keypair, SystemProgram, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const {
TOKEN_2022_PROGRAM_ID,
ExtensionType,
getMintLen,
createInitializeMintInstruction,
createInitializePermanentDelegateInstruction,
createInitializeNonTransferableMintInstruction,
createSetAuthorityInstruction,
AuthorityType,
} = require("@solana/spl-token");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, nowStamp, PublicKey } = require("./js_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const governance = new PublicKey(cfg.GOVERNANCE);
const mint = Keypair.generate();
const mintLen = getMintLen([ExtensionType.NonTransferable, ExtensionType.PermanentDelegate]);
const rentMint = await conn.getMinimumBalanceForRentExemption(mintLen, "confirmed");
const tx = new Transaction().add(
SystemProgram.createAccount({ fromPubkey: main.publicKey, newAccountPubkey: mint.publicKey, space: mintLen, lamports: rentMint, programId: TOKEN_2022_PROGRAM_ID }),
createInitializeNonTransferableMintInstruction(mint.publicKey, TOKEN_2022_PROGRAM_ID),
createInitializePermanentDelegateInstruction(mint.publicKey, main.publicKey, TOKEN_2022_PROGRAM_ID),
createInitializeMintInstruction(mint.publicKey, 0, main.publicKey, main.publicKey, TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint.publicKey, main.publicKey, AuthorityType.MintTokens, governance, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint.publicKey, main.publicKey, AuthorityType.FreezeAccount, governance, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint.publicKey, main.publicKey, AuthorityType.PermanentDelegate, governance, [], TOKEN_2022_PROGRAM_ID)
);
const sig = await sendAndConfirmTransaction(conn, tx, [main, mint], { commitment: "confirmed" });
const runs = path.resolve(__dirname, cfg.RUNS_DIR || "./runs");
fs.mkdirSync(runs, { recursive: true });
const rp = path.join(runs, `${nowStamp()}_empty_nft_template.json`);
fs.writeFileSync(rp, JSON.stringify({ mint: mint.publicKey.toBase58(), tx: sig, createdAt: new Date().toISOString() }, null, 2));
console.log("EMPTY NFT template created");
console.log("mint:", mint.publicKey.toBase58());
console.log("report:", rp);
}
main().catch((e)=>{console.error(e?.message||e);process.exit(1);});

View File

@ -0,0 +1,60 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const BN = require("bn.js");
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createMintToInstruction } = require("@solana/spl-token");
const {
PROGRAM_VERSION_V3, Vote, YesNoVote, VoteType,
withCreateProposal, withInsertTransaction, withSignOffProposal, withCastVote,
getTokenOwnerRecordAddress, getProposalTransactionAddress
} = require("@solana/spl-governance");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, nowStamp, toInstructionData } = require("./js_common");
async function main() {
const targetWallet = process.argv[3];
const nftMintStr = process.argv[4];
if (!targetWallet || !nftMintStr) throw new Error("Usage: node 02_propose_vote_mint_nft.js <config.env> <target_wallet> <nft_mint>");
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const realm = new PublicKey(cfg.REALM); const governance = new PublicKey(cfg.GOVERNANCE);
const governingMint = new PublicKey(cfg.GOVERNING_MINT); const govPid = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const nftMint = new PublicKey(nftMintStr); const target = new PublicKey(targetWallet);
const mainTor = await getTokenOwnerRecordAddress(govPid, realm, governingMint, main.publicKey);
const targetAta = getAssociatedTokenAddressSync(nftMint, target, false, TOKEN_2022_PROGRAM_ID);
const ataExists = (await conn.getAccountInfo(targetAta, "confirmed")) !== null;
if (!ataExists) throw new Error(`Target ATA not found. Create it first: ${targetAta.toBase58()}`);
const ixCreate = [];
const proposal = await withCreateProposal(ixCreate, govPid, PROGRAM_VERSION_V3, realm, governance, mainTor, `Mint NFT to ${target.toBase58().slice(0,8)}`, "https://arweave.net/", governingMint, main.publicKey, undefined, VoteType.SINGLE_CHOICE, ["Approve"], true, main.publicKey);
const txCreate = await sendAndConfirmTransaction(conn, new Transaction().add(...ixCreate), [main], { commitment: "confirmed" });
const mintIx = [createMintToInstruction(nftMint, targetAta, governance, 1n, [], TOKEN_2022_PROGRAM_ID)];
const insertData = mintIx.map(toInstructionData);
const ixInsert = [];
const proposalTx = await withInsertTransaction(ixInsert, govPid, PROGRAM_VERSION_V3, governance, proposal, mainTor, main.publicKey, 0, 0, 0, insertData, main.publicKey);
const txInsert = await sendAndConfirmTransaction(conn, new Transaction().add(...ixInsert), [main], { commitment: "confirmed" });
const ixSign = [];
withSignOffProposal(ixSign, govPid, PROGRAM_VERSION_V3, realm, governance, proposal, main.publicKey, undefined, mainTor);
const txSign = await sendAndConfirmTransaction(conn, new Transaction().add(...ixSign), [main], { commitment: "confirmed" });
const vote = Vote.fromYesNoVote(YesNoVote.Yes);
const ixVote1 = [];
await withCastVote(ixVote1, govPid, PROGRAM_VERSION_V3, realm, governance, proposal, mainTor, mainTor, main.publicKey, governingMint, vote, main.publicKey);
const txVote1 = await sendAndConfirmTransaction(conn, new Transaction().add(...ixVote1), [main], { commitment: "confirmed" });
const computedTx = await getProposalTransactionAddress(govPid, PROGRAM_VERSION_V3, proposal, 0, 0);
if (!computedTx.equals(proposalTx)) throw new Error("proposal tx mismatch");
const runs = path.resolve(__dirname, cfg.RUNS_DIR || "./runs"); fs.mkdirSync(runs, { recursive: true });
const report = { type: "mint_nft", realm: realm.toBase58(), governance: governance.toBase58(), proposal: proposal.toBase58(), proposalTransaction: proposalTx.toBase58(), nftMint: nftMint.toBase58(), targetWallet: target.toBase58(), targetAta: targetAta.toBase58(), txCreate, txInsert, txSign, txVote1 };
const rp = path.join(runs, `${nowStamp()}_proposal_mint_${target.toBase58().slice(0,8)}.json`);
fs.writeFileSync(rp, JSON.stringify(report, null, 2));
console.log("proposal mint created and voted");
console.log("report:", rp);
console.log("execute command:");
console.log(`node 03_execute_mint_nft.js ${resolveConfigPath(process.argv[2])} ${proposal.toBase58()} ${proposalTx.toBase58()} ${nftMint.toBase58()} ${target.toBase58()}`);
}
main().catch((e)=>{console.error(e?.message||e);process.exit(1);});

View File

@ -0,0 +1,30 @@
#!/usr/bin/env node
"use strict";
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createMintToInstruction } = require("@solana/spl-token");
const { PROGRAM_VERSION_V3, withExecuteTransaction } = require("@solana/spl-governance");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, toInstructionData } = require("./js_common");
const path = require("path");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const proposal = new PublicKey(process.argv[3]);
const proposalTx = new PublicKey(process.argv[4]);
const nftMint = new PublicKey(process.argv[5]);
const target = new PublicKey(process.argv[6]);
if (!process.argv[6]) throw new Error("Usage: node 03_execute_mint_nft.js <config.env> <proposal> <proposalTx> <nftMint> <targetWallet>");
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const governance = new PublicKey(cfg.GOVERNANCE); const govPid = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const targetAta = getAssociatedTokenAddressSync(nftMint, target, false, TOKEN_2022_PROGRAM_ID);
const ataExists = (await conn.getAccountInfo(targetAta, "confirmed")) !== null;
if (!ataExists) throw new Error(`Target ATA not found. Create it first: ${targetAta.toBase58()}`);
const mintIx = [
createMintToInstruction(nftMint, targetAta, governance, 1n, [], TOKEN_2022_PROGRAM_ID),
].map(toInstructionData);
const ix = [];
await withExecuteTransaction(ix, govPid, PROGRAM_VERSION_V3, governance, proposal, proposalTx, mintIx);
const sig = await sendAndConfirmTransaction(conn, new Transaction().add(...ix), [main], { commitment: "confirmed" });
console.log("execute mint done:", sig);
}
main().catch((e)=>{console.error(e?.message||e);process.exit(1);});

View File

@ -0,0 +1,53 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createBurnCheckedInstruction } = require("@solana/spl-token");
const {
PROGRAM_VERSION_V3, Vote, YesNoVote, VoteType,
withCreateProposal, withInsertTransaction, withSignOffProposal, withCastVote,
getTokenOwnerRecordAddress
} = require("@solana/spl-governance");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, nowStamp, toInstructionData } = require("./js_common");
async function main() {
const targetWallet = process.argv[3];
const nftMintStr = process.argv[4];
if (!targetWallet || !nftMintStr) throw new Error("Usage: node 04_propose_vote_burn_nft.js <config.env> <target_wallet> <nft_mint>");
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const realm = new PublicKey(cfg.REALM); const governance = new PublicKey(cfg.GOVERNANCE);
const governingMint = new PublicKey(cfg.GOVERNING_MINT); const govPid = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const nftMint = new PublicKey(nftMintStr); const target = new PublicKey(targetWallet);
const mainTor = await getTokenOwnerRecordAddress(govPid, realm, governingMint, main.publicKey);
const sourceAta = getAssociatedTokenAddressSync(nftMint, target, false, TOKEN_2022_PROGRAM_ID);
const ixCreate = [];
const proposal = await withCreateProposal(ixCreate, govPid, PROGRAM_VERSION_V3, realm, governance, mainTor, `Burn NFT ${nftMint.toBase58().slice(0,8)}`, "https://arweave.net/", governingMint, main.publicKey, undefined, VoteType.SINGLE_CHOICE, ["Approve"], true, main.publicKey);
const txCreate = await sendAndConfirmTransaction(conn, new Transaction().add(...ixCreate), [main], { commitment: "confirmed" });
const burnIx = [toInstructionData(createBurnCheckedInstruction(sourceAta, nftMint, governance, 1n, 0, [], TOKEN_2022_PROGRAM_ID))];
const ixInsert = [];
const proposalTx = await withInsertTransaction(ixInsert, govPid, PROGRAM_VERSION_V3, governance, proposal, mainTor, main.publicKey, 0, 0, 0, burnIx, main.publicKey);
const txInsert = await sendAndConfirmTransaction(conn, new Transaction().add(...ixInsert), [main], { commitment: "confirmed" });
const ixSign = [];
withSignOffProposal(ixSign, govPid, PROGRAM_VERSION_V3, realm, governance, proposal, main.publicKey, undefined, mainTor);
const txSign = await sendAndConfirmTransaction(conn, new Transaction().add(...ixSign), [main], { commitment: "confirmed" });
const vote = Vote.fromYesNoVote(YesNoVote.Yes);
const ixVote1 = [];
await withCastVote(ixVote1, govPid, PROGRAM_VERSION_V3, realm, governance, proposal, mainTor, mainTor, main.publicKey, governingMint, vote, main.publicKey);
const txVote1 = await sendAndConfirmTransaction(conn, new Transaction().add(...ixVote1), [main], { commitment: "confirmed" });
const runs = path.resolve(__dirname, cfg.RUNS_DIR || "./runs"); fs.mkdirSync(runs, { recursive: true });
const report = { type: "burn_nft", realm: realm.toBase58(), governance: governance.toBase58(), proposal: proposal.toBase58(), proposalTransaction: proposalTx.toBase58(), nftMint: nftMint.toBase58(), targetWallet: target.toBase58(), sourceAta: sourceAta.toBase58(), txCreate, txInsert, txSign, txVote1 };
const rp = path.join(runs, `${nowStamp()}_proposal_burn_${target.toBase58().slice(0,8)}.json`);
fs.writeFileSync(rp, JSON.stringify(report, null, 2));
console.log("proposal burn created and voted");
console.log("report:", rp);
console.log("execute command:");
console.log(`node 05_execute_burn_nft.js ${resolveConfigPath(process.argv[2])} ${proposal.toBase58()} ${proposalTx.toBase58()} ${nftMint.toBase58()} ${target.toBase58()}`);
}
main().catch((e)=>{console.error(e?.message||e);process.exit(1);});

View File

@ -0,0 +1,26 @@
#!/usr/bin/env node
"use strict";
const path = require("path");
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createBurnCheckedInstruction } = require("@solana/spl-token");
const { PROGRAM_VERSION_V3, withExecuteTransaction } = require("@solana/spl-governance");
const { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, toInstructionData } = require("./js_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const proposal = new PublicKey(process.argv[3]);
const proposalTx = new PublicKey(process.argv[4]);
const nftMint = new PublicKey(process.argv[5]);
const target = new PublicKey(process.argv[6]);
if (!process.argv[6]) throw new Error("Usage: node 05_execute_burn_nft.js <config.env> <proposal> <proposalTx> <nftMint> <targetWallet>");
const conn = new Connection(clusterUrl(cfg.CLUSTER), "confirmed");
const main = loadKeypair(path.resolve(__dirname, cfg.MAIN_KEYPAIR));
const governance = new PublicKey(cfg.GOVERNANCE); const govPid = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const sourceAta = getAssociatedTokenAddressSync(nftMint, target, false, TOKEN_2022_PROGRAM_ID);
const burnIx = [toInstructionData(createBurnCheckedInstruction(sourceAta, nftMint, governance, 1n, 0, [], TOKEN_2022_PROGRAM_ID))];
const ix = [];
await withExecuteTransaction(ix, govPid, PROGRAM_VERSION_V3, governance, proposal, proposalTx, burnIx);
const sig = await sendAndConfirmTransaction(conn, new Transaction().add(...ix), [main], { commitment: "confirmed" });
console.log("execute burn done:", sig);
}
main().catch((e)=>{console.error(e?.message||e);process.exit(1);});

View File

@ -0,0 +1,66 @@
# CreateGovernmentNFTAndDAO
## RU
Скрипты для Devnet, чтобы управлять NFT через DAO (Realms/SPL Governance):
1) создать предложение на выпуск NFT (`mint`) и выполнить его;
2) создать предложение на сжигание NFT (`burn`) и выполнить его.
### Что лежит в папке
- `config.env` — параметры кластера, DAO, ключей.
- `keypairs/` — ключи оператора и второго участника.
- `runs/` — отчёты запусков (proposal, tx и т.д.).
- `00_prepare_voter2_deposit.js` — депонирование governance-токена для второго голосующего.
- `01_create_nft_for_wallet_admin.js` — создать NFT на кошелёк и делегировать право governance PDA.
- `01b_create_empty_nft_template.js` — создать пустой NFT mint-шаблон (supply=0) для будущего DAO mint.
- `02_propose_vote_mint_nft.js` — создать+подписать+проголосовать за proposal на mint.
- `03_execute_mint_nft.js` — выполнить proposal mint.
- `04_propose_vote_burn_nft.js` — создать+подписать+проголосовать за proposal на burn.
- `05_execute_burn_nft.js` — выполнить proposal burn.
### Важно перед запуском
1. Нужен `node`, `@solana/web3.js`, `@solana/spl-token`, `@solana/spl-governance`.
2. В `config.env` должен быть корректный `REALM`, `GOVERNANCE`, `GOVERNING_MINT`, `MAIN_KEYPAIR`.
3. Для `mint via DAO` целевой ATA должен существовать заранее (скрипт `02` это проверяет).
### Быстрый полный тест (mint + burn)
1. Создать NFT-шаблон (куда DAO будет минтить):
- `node 01b_create_empty_nft_template.js ./config.env`
2. Создать ATA для целевого кошелька и этого mint (если ещё нет).
3. Поднять proposal на mint:
- `node 02_propose_vote_mint_nft.js ./config.env <target_wallet> <nft_mint>`
4. Выполнить proposal (команду берёшь из консоли шага 3):
- `node 03_execute_mint_nft.js ./config.env <proposal> <proposal_tx> <nft_mint> <target_wallet>`
5. Создать NFT для burn-теста:
- `node 01_create_nft_for_wallet_admin.js ./config.env <wallet_with_nft>`
6. Поднять proposal на burn:
- `node 04_propose_vote_burn_nft.js ./config.env <wallet_with_nft> <nft_mint>`
7. Выполнить proposal burn (команда из шага 6):
- `node 05_execute_burn_nft.js ./config.env <proposal> <proposal_tx> <nft_mint> <wallet_with_nft>`
### Как проверить результат
Смотри JSON-отчёты в `runs/`: там есть `proposal`, `proposalTransaction`, tx подписи и mint/кошельки.
Для проверки через час:
1) поднимаешь proposal (скрипт `02` или `04`);
2) ждёшь;
3) запускаешь соответствующий `execute` скрипт с параметрами из отчёта.
### Проверка DAO
В текущем `config.env`:
- Realm: `2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7`
- Governance PDA: `EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD`
- Governing mint: `F1KctLRvVzqwcBYNGsivnjR39gY8Uvq5U3uyaqEBNASg`
## EN
Devnet scripts for DAO-governed NFT flow (Realms/SPL Governance):
- propose/sign/vote/execute NFT mint to a wallet;
- propose/sign/vote/execute NFT burn from a wallet.
Main idea: first script in each pair creates proposal and vote, second script executes proposal later.

View File

@ -0,0 +1,37 @@
# TEMP Devnet Report (2026-05-15)
## Актуальный DAO из config.env
- Realm: `2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7`
- Governance PDA: `EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD`
- Governing mint: `F1KctLRvVzqwcBYNGsivnjR39gY8Uvq5U3uyaqEBNASg`
## Подтверждённый успешный цикл (mint + burn через DAO)
- NFT minted by DAO:
- mint: `4xU8omSH3RfTyDHxWVCEm1HVTcf97YWkT8H67GvVpssz`
- target ATA: `2fUHqDDdcCcxyG62dZwyNiT2H9xZc3Rgy9oFtWiTdCXr`
- result: `amount=1`, `supply=1`
- NFT burned by DAO:
- mint: `9Up5SURRoBybsrfZnR7nKZC5gHarrccdVuMzoxeU3Xia`
- source ATA: `3Q3cfBrNgyagEpMNPEZngYpCgw9U8Bd2KK4BbBfzvL5u`
- result: `amount=0`, `supply=0`
Стоимость этого полного цикла: **`0.22709372 SOL`**.
## Тест "запустить сейчас, проверить/выполнить через час"
Создан новый proposal на mint (уже signed + voted):
- report: `runs/2026-05-15_18-29-59_proposal_mint_HMww7YSV.json`
- proposal: `CKV2RSJ4HiUGQvdhGyizub89csTF52TD2Z2HciQFPkdW`
- proposalTx: `AAvEV3q9MeHedbJMsTk1C8nLg3LJtTLUBWt9fgi2wnqC`
- nft mint: `hssoT46Vp7KzisNAffBSQxGLmtxfzcswVeeEi4eq8gW`
- target wallet: `HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA`
Команда для выполнения через час:
```bash
node 03_execute_mint_nft.js ./config.env CKV2RSJ4HiUGQvdhGyizub89csTF52TD2Z2HciQFPkdW AAvEV3q9MeHedbJMsTk1C8nLg3LJtTLUBWt9fgi2wnqC hssoT46Vp7KzisNAffBSQxGLmtxfzcswVeeEi4eq8gW HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA
```
Если execute пройдёт успешно — DAO путь для mint подтверждён повторно.

View File

@ -0,0 +1,13 @@
CLUSTER="devnet"
SPL_GOVERNANCE_PROGRAM_ID="GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw"
# DAO for NFT governance flow (created 2026-05-15, single-voter supply)
REALM="2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7"
GOVERNANCE="EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD"
GOVERNING_MINT="F1KctLRvVzqwcBYNGsivnjR39gY8Uvq5U3uyaqEBNASg"
# voters
MAIN_KEYPAIR="./keypairs/main_FUc28.json"
VOTER2_KEYPAIR="./keypairs/voter2_HMww7.json"
RUNS_DIR="./runs"

View File

@ -0,0 +1,50 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { PublicKey, Keypair, clusterApiUrl } = require("@solana/web3.js");
const { InstructionData, AccountMetaData } = require("@solana/spl-governance");
function parseEnvConfig(configPath) {
const raw = fs.readFileSync(configPath, "utf8");
const out = {};
for (const line of raw.split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i < 0) continue;
const k = t.slice(0, i).trim();
let v = t.slice(i + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[k] = v;
}
return out;
}
function resolveConfigPath(argvPath) {
return argvPath ? path.resolve(argvPath) : path.resolve(__dirname, "config.env");
}
function loadKeypair(fp) {
return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(fs.readFileSync(fp, "utf8"))));
}
function clusterUrl(cluster) {
if (cluster === "devnet" || cluster === "mainnet-beta" || cluster === "testnet") return clusterApiUrl(cluster);
return cluster;
}
function nowStamp() {
const d = new Date(); const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
}
function toInstructionData(ix) {
return new InstructionData({
programId: ix.programId,
accounts: ix.keys.map((k) => new AccountMetaData({ pubkey: k.pubkey, isSigner: !!k.isSigner, isWritable: !!k.isWritable })),
data: Uint8Array.from(ix.data),
});
}
module.exports = { parseEnvConfig, resolveConfigPath, loadKeypair, clusterUrl, nowStamp, toInstructionData, PublicKey };

View File

@ -0,0 +1 @@
[221,119,143,125,90,136,155,115,191,198,210,85,228,111,251,118,168,138,27,60,249,62,247,24,121,228,139,112,218,69,55,143,215,21,229,69,219,1,74,36,10,239,63,163,48,240,58,208,237,251,209,37,17,202,215,77,13,165,178,18,141,21,193,64]

View File

@ -0,0 +1 @@
[241,27,187,46,105,28,241,60,120,167,90,61,230,106,125,146,230,244,198,39,162,46,76,224,131,59,229,157,27,45,29,78,243,24,184,76,185,238,209,112,35,54,121,13,2,104,76,182,80,33,144,21,30,188,173,23,63,146,192,200,40,126,139,145]

View File

@ -0,0 +1,7 @@
{
"createdAt": "2026-05-15T15:06:41.395Z",
"mint": "3HJQNtkpP4pv1uKe1XDWRkp1KjY5qmEMEZqzNwDBHuph",
"owner": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"ata": "CHr24KW5RY3hR1dnQATEMq9ZxFuqorDmoc95TZsmJLQG",
"tx": "5nSjS43xRUdVJH4V5xZygcdUjzz1dFUJBVQc4rQwyZZAm7juGeVtKdWHrhb8tXLLcnZmKasZWPAqfqQpXWYtqhs"
}

View File

@ -0,0 +1,5 @@
{
"mint": "HgbScrAw5HN8dx5VG9w5JvGQqgXvinbLwL48VvVfBS6d",
"tx": "4Zk2FBjiEaP9C1kVdADkFX3gD12Lsw9vF7X3E3wYH58CN1fGceU2vnEYE8c3kcevAFeqw7LEr4JPxMbkU7ujREwU",
"createdAt": "2026-05-15T15:06:48.909Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "mint_nft",
"realm": "H35UPU98sC2bo265Q4R6PWdvbaotbbCvjDcuvwjDvewf",
"governance": "9aD18P5nun1RPVpEeeCeG5ensyry9WKrwjdX4stVa7qP",
"proposal": "FE5Bmd1ojeSNL9mVYP9E5EgjqMKwpGeaj5fjXBuh1yUB",
"proposalTransaction": "AxCuhhgk1DjYMkijePe3fpr6t9LCb8bNaYq1ksEbKKa1",
"nftMint": "HgbScrAw5HN8dx5VG9w5JvGQqgXvinbLwL48VvVfBS6d",
"targetWallet": "HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA",
"targetAta": "65FUT9jtrscCCTjsxd3z1DMwG4S1Yq2Sd9ZupcocpoK",
"txCreate": "4iu3DmStaP94J3rZ3mmCNpv4XycLa82PyXj8HEyijUiX3BDcrjaL6GQahjbUjLhXaREz1RiGNTsq8ChDGtJjFTMp",
"txInsert": "kiWnMQGvVwckLXrjPVNVStKQyXyHMhAn2pY9tSeyeW898zat84KUCYQrDDkhs6pLe5PrczUPViGVCDNzFiMe16w",
"txSign": "52dD55YaitkKLQmASeJDPiy64eeGTWiNktDTAcTEB4u7cVisxtoVGPyPbatEKDbcMZKkUBc7VMtBuDz7xefMyfn5",
"txVote1": "hApj3uocwAep4a58GSqsVW2vt7bwPZMawfPRs3hwNvYNEtQBWrcW32eGPWuPYjtcVunbxizy5kDXe8QwMiA4h8k"
}

View File

@ -0,0 +1,16 @@
{
"createdAt": "2026-05-15T15:08:51.409Z",
"cluster": "devnet",
"realmName": "NFT-DAO-0515-E",
"governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw",
"governingMint": "2yf1CwPzdBoDM7a376CGSfyEAf58CHtA4rZRGwNeUTNd",
"operator": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"realm": "8yRYrnMWTrhHieegyGniMzskwPFZTka8JNZeKppoD6Zw",
"governance": "8V676kt5LAY3CawCDsbpLheTds9RPJSn6CdwpHew6GD6",
"nativeTreasury": "642nn5B6nvsHrTnM75Jb8wW4JFye11n37Mzc3u4b7GNz",
"tokenOwnerRecord": "EfW4fSPKqXcWYEEYZWXY1Nk3FtmaE1VJij8uZNjnCUwR",
"txRealm": "A6Rmb6jKDKrx5mc3Lge8iPe2nJMNpoczw7mmpQBS9Z7131viztzzGtx1z3xwzNfh1nU5rDekyLrcCn4ZpkDF6kE",
"txDeposit": "4hk68TjzKVrnpjse2SUSXuCzQfYEHUr2jvKVuUGDFABkvrKFHopfuDvcrsWwKoKCS9crM9oXnUy1asnPTAsZya69",
"txGovernanceTreasury": "4gGjGwjrPzvWYG7unBbWQrrCRcfuRiD7E6FQBGv6WcJtnuerPgo9nCWo6dygNEAeWPpYQRDRA5sjmKwDBc2yWUDM",
"txSetRealmAuthority": "W6zWxPKAge7Nu4o9uUiL5AxKYBuhc1g6C8BtqQrhqdtTgYPNxFwZDpvFsm1WEEM1ewhxUqn5qHWvhJDDtrPXvPc"
}

View File

@ -0,0 +1,16 @@
{
"createdAt": "2026-05-15T15:09:35.073Z",
"cluster": "devnet",
"realmName": "NFT-DAO-0515-F",
"governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw",
"governingMint": "F1KctLRvVzqwcBYNGsivnjR39gY8Uvq5U3uyaqEBNASg",
"operator": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"nativeTreasury": "3L9neAHgGb158ieWs7LBbHUaH3qrSN6AnLGPmNNpF5Mq",
"tokenOwnerRecord": "94JCGgmCxoen3JfN75rV3DRHpJ5QA7TaisW8Y9CnwvcY",
"txRealm": "2j2cDJ6joMn6XuRv4sttm7fbAsSRTh8e17TCSTEpV7k1LSFYnRHTmccCVew2w86vWc7gQCJnZKXzJTrRxeopXqAR",
"txDeposit": "4ccx97DF3ezqpFaUhLLE1n9L7HwFibYyv8mwnPcE5EGQJnL4Tq61vUa79ES1mD7qJrak6kwGTeZa1ELVSJy3R6Wi",
"txGovernanceTreasury": "66drnSSkhB4ES6nFMSdQbAK9khMo469c6DwA71XJU2jzUKog1UgzYZ99d3SBCsRHpJhakT7J71xmP2MvgfTiuGun",
"txSetRealmAuthority": "2zsYuJd1nGTPoUenS4eDeVxwHrEtTLss4hBRBjcciZm8Xv3zsMqaJwGnHUETNquPijeUAcn4vn9EDHpAfGTzAA4b"
}

View File

@ -0,0 +1,7 @@
{
"createdAt": "2026-05-15T15:09:52.941Z",
"mint": "744E11uXxhuy2AwVcrYwrC6yeF5GzEtmcL6Z9o6NzZV",
"owner": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"ata": "8xQnr4dh3aYpnePn39qsA3EC6uFEMBa5eNptCjMd2ufZ",
"tx": "2KiztENxtLJz61A9T8seFWJS5BcJQn3oQr5jL6WtRPWckG26vPrjv8TRurqVVusA8qkXBBW2Zk7ZydP1G5PFY5Zz"
}

View File

@ -0,0 +1,5 @@
{
"mint": "G4UewUZ3M7eohHGYMMmASbsxyi62ovdb3PcvvWQLNWj",
"tx": "4pgepoc4mCxJxg73JWg9Dqxa96Y6owbSFXjbvAE9J4g2SXRBhPvphFCb5WT8MTTQYNzMQdweuTta9vr7VfBmT6DL",
"createdAt": "2026-05-15T15:09:52.455Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "mint_nft",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"proposal": "HfUPpCUyN4f9fydw7zPQf8EmhLFxsarF3oLQ54hRkHmP",
"proposalTransaction": "QDoGm745Evej1B78qAyCjGEosskxQJC77hGBiDhKNnS",
"nftMint": "G4UewUZ3M7eohHGYMMmASbsxyi62ovdb3PcvvWQLNWj",
"targetWallet": "HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA",
"targetAta": "q9Q74ajue4S5qr7xXX1fz3PbkQ9wugQfW4NBnjtjKKZ",
"txCreate": "7WaFb6kzibkUMXyTVrahxuu12ydCdCcDU6bYgWP6juA6uYhEoWtMXrYAR5J8pBDMbx89T5pqrTdHyC1pmiR8mmi",
"txInsert": "5EedBVP48q13RySrGMxdhwrMqXa59bwCYLcUL1EZykDJbecDyoFagDZsEjHosGUeTYepzRDXqSkb7S2ZykcsWAeP",
"txSign": "3VsA4wwyervDjaAaw1eU72mvuvZorDCEbyczUJnvSemcfWrthSYM8VPV3EzWLAd7hhT2wZKkrDXQRk3nrswg15ri",
"txVote1": "66CzrV93qqccquAJt3Px2AFDH16Qkk6rJkCTDZkuLWnqb5GHs6ff1g944g9xtjNMpSiBz9YLwXUULSykuMB52f9v"
}

View File

@ -0,0 +1,14 @@
{
"type": "mint_nft",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"proposal": "GcSrXHmxBNxGskXeE16e9EX7GgWtAP3X6x2DCuTcDdp8",
"proposalTransaction": "Hgjnsd8qJxrYbtemsHLyerBjgyHpK55RhoSftW4HvwXR",
"nftMint": "G4UewUZ3M7eohHGYMMmASbsxyi62ovdb3PcvvWQLNWj",
"targetWallet": "HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA",
"targetAta": "q9Q74ajue4S5qr7xXX1fz3PbkQ9wugQfW4NBnjtjKKZ",
"txCreate": "4WmFfSry92sv5GnMXHQiL28NERQ8j7jcrc2sQSHANaznLePwwSfeMQFVnxvdK2TPwr2dQ4uAJt1ARNCyuVhR5AH2",
"txInsert": "2iKF1EbRKatnpz566WmShHyUcYHkvKhMtBvdFTtoqKoUrrKwSCfFGmv6UxD998hGCuCmJ5sgtzR7aQQNcPTQrJdf",
"txSign": "4iZHCAMxoJMPmzqNKccf48Qhg1kBV7eurdSQ3cGsALihGZK8nxE4vrCyyUQiTJffGKBc4KZNTSSxphQsGmFMSWen",
"txVote1": "2jxbmxUFSc9CkwiXhE3gnMXS6w83ZdXYSMmfzDUv3PYHSnWQUAttbFQMixmUiho1Gs2XgostMqvKJ2AVL8rAec51"
}

View File

@ -0,0 +1,14 @@
{
"type": "burn_nft",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"proposal": "BYuwup4JQUEXcigREwwMoHW1TKvDM29ohmjXDGwZfb47",
"proposalTransaction": "6TZdVa1dgN5hdWvW2rmZTMhYHLCP2GddeNttdhTvpu32",
"nftMint": "744E11uXxhuy2AwVcrYwrC6yeF5GzEtmcL6Z9o6NzZV",
"targetWallet": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"sourceAta": "8xQnr4dh3aYpnePn39qsA3EC6uFEMBa5eNptCjMd2ufZ",
"txCreate": "4pzzFSnd6F89VriF8HaUjoR2zvtLxxvKRU1UBrYy9capswmrsjx9DkmY5aHAaUgSrVM34H49G8UmV5CicPbfScem",
"txInsert": "4E5FggG9dSPcYXiaHZLiMuw54QMzAFFbom48NfxxR2SfsifGZ9SBbNb7sthohcG8nMALUCxoZiNGw3Nsvogf2163",
"txSign": "4CidBfaWqX3FWtABRgELhHdH99TcKHtiDiakwDx129ZZ8j9kSvsrnAQsFiZWeTabaWDFnzm3dB6EQ8HcUTS4XeL5",
"txVote1": "VkiFThKY1W41Uy8baxWoz4vBgJQZNMGDwewKxTbMsbFqQHAejztFreKmdryesCHnv4h3PPejBkV3EmV37qQLdA7"
}

View File

@ -0,0 +1,7 @@
{
"createdAt": "2026-05-15T15:14:04.947Z",
"mint": "9Up5SURRoBybsrfZnR7nKZC5gHarrccdVuMzoxeU3Xia",
"owner": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"ata": "3Q3cfBrNgyagEpMNPEZngYpCgw9U8Bd2KK4BbBfzvL5u",
"tx": "NvaRG6LUNf4RqG5J4uZzKHkKSkyACWTPYC9WDGovdFedUjUR3JA2Wbfhx9H6X35iXkuMz6jfjqMq2jHHo3FHdfy"
}

View File

@ -0,0 +1,5 @@
{
"mint": "4xU8omSH3RfTyDHxWVCEm1HVTcf97YWkT8H67GvVpssz",
"tx": "4KLKJaG6w67YX5RidkYvgKuA4UAb9v8N1AbxWY6cmHJYSbZA2nevM2v8qBtTRe7t6jxGqFATttGpXogNK2Teyse7",
"createdAt": "2026-05-15T15:14:04.949Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "mint_nft",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"proposal": "EspTK14DG6WAbip3hdh27zWLYSCjEiMqj7ZbdoxZVrPH",
"proposalTransaction": "DYH4dFzVsxYFyLpcTSGL3ce7VN1xNFbrcc3AMpWqA2XT",
"nftMint": "4xU8omSH3RfTyDHxWVCEm1HVTcf97YWkT8H67GvVpssz",
"targetWallet": "HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA",
"targetAta": "2fUHqDDdcCcxyG62dZwyNiT2H9xZc3Rgy9oFtWiTdCXr",
"txCreate": "xwWL7W2VTgc2zyswo7ZdkvV7exEJA6FGzpiZan4DtDVjjNhf5wL1VkhH1Cxng2z4ACmT8TQHXyct6tkSZvPuTrj",
"txInsert": "4fmyYG4AJ7pZLa6is5qrPY2znLtVe3YuXDEKhqrwVZZsLm64nUrMdoQ6WPQR17HsGqsxt2WqWgQkCSg9QzERLunn",
"txSign": "4ouuHY2kpp573y3YB2w5TuaneNvwy1hbCYz8n6z2SRxV52YGe5fVdTMgoiFwNPwj9XeCxpb1CVfKvvsSttFgSUqB",
"txVote1": "48gz4HjvaK8seAs6otZYnf1viiy8jtmBFvPZCsjsjZ6ig5tqVMfksMvxf1orZRtYe5BXMozMD6uW7NmNyzMasZgP"
}

View File

@ -0,0 +1,14 @@
{
"type": "burn_nft",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"proposal": "BbwBWxzB3Tkf7r8QftycZAsFAKNtKz6nWZGsabjhSSxc",
"proposalTransaction": "4SbdRwFyDN52CRETNpHqe79ABiTSkeSwuttHUAnq99om",
"nftMint": "9Up5SURRoBybsrfZnR7nKZC5gHarrccdVuMzoxeU3Xia",
"targetWallet": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"sourceAta": "3Q3cfBrNgyagEpMNPEZngYpCgw9U8Bd2KK4BbBfzvL5u",
"txCreate": "5D6P7tPnvVy2B3NjrYQCYRwVKczkwD2D8PQCeTNy1SuiNoMdP31DiHDTi4k9B5fQbiEo5x2FCp5CR28muFTTnrWp",
"txInsert": "vwmnX2w9uJxbNoVK8jn2pLSvjbmEYYN7QcGnEm3zGSfXaQsNZpS8FsyH6584XpB2831A78MZxdNX6Fdi4a97Zn6",
"txSign": "4ViTxSiumXdkFMaHsN6Hghdegc6hNrzH4LJPuvkPRXxThsLbevdFBs4q5u4bKRCDGKBkBF4Ftx24beq34AqzNDN3",
"txVote1": "2Z367p3DztZHkVo25V649HBRzsdxk69mAByTEPg7PfBRLHRtiZ4KJeb2Hm5vkAj9g7EGHxKKfY35jPyaaPfXhKfC"
}

View File

@ -0,0 +1,5 @@
{
"mint": "hssoT46Vp7KzisNAffBSQxGLmtxfzcswVeeEi4eq8gW",
"tx": "5LwmJLfUskcJgz9ZhZH3ojnXDvMjeBe3TiLLtLVndYxnb5oVUfGkrpGsdvh5RYGgC1zahATpQUHc9bJMtvNe4UW9",
"createdAt": "2026-05-15T15:29:37.181Z"
}

View File

@ -0,0 +1,14 @@
{
"type": "mint_nft",
"realm": "2DTh1ivaekAW8kRYzGPsL2taFLJFFkBjEwqPisebxsS7",
"governance": "EMZ8vmr1xB4HZBDCFL9rHB98m1C5cYrGnRA8ZHayyGwD",
"proposal": "CKV2RSJ4HiUGQvdhGyizub89csTF52TD2Z2HciQFPkdW",
"proposalTransaction": "AAvEV3q9MeHedbJMsTk1C8nLg3LJtTLUBWt9fgi2wnqC",
"nftMint": "hssoT46Vp7KzisNAffBSQxGLmtxfzcswVeeEi4eq8gW",
"targetWallet": "HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA",
"targetAta": "9wxC9Zb5FCbkrhjcHkyhft1Kn6FVdzamijk99zcQz5xZ",
"txCreate": "3z2otjQtptrDgDXPQUWKos6EhDXqK9zifAXaSdZjKEPyHTxaFS32TXoCHy2QKBEMv5NAMVXqDRF9mifFLSbbhUXK",
"txInsert": "Sys1JeebsUyACPb3TjUQ5NciBXwAjAhUVj5geRpabmxB23MhsQsw9USCGqCKqX9zpdUAg6rQ7qt8v4QFtMB3cDt",
"txSign": "5wdYVUgYtew7PPmtHCyNPWVZvruw58FmiQhwbVGHngPKbfL1s1LTtZtNiAAVfzBzpaavzA8y2CjUYrSizS7hQuh9",
"txVote1": "DtWj3CCq48JBVRmSfwWu2k2uQSHuArMAbJbz2KSXpMDBqtTsaBr9Duintn8gFz5Dnmm4VjT4Cf6L5yB2PxyW8Cc"
}

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
#
# RU: Создает governance token (Token-2022, NonTransferable + PermanentDelegate)
# с настройками из governance_token.config.env.
# EN: Creates governance token (Token-2022, NonTransferable + PermanentDelegate)
# using settings from governance_token.config.env.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
node "$SCRIPT_DIR/js/01_create_governance_token_exec.js" "$CONFIG_PATH"

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
#
# RU: Выпускает ровно 1 membership-токен на указанный кошелек.
# Если у кошелька уже есть >=1 токен, скрипт завершится ошибкой.
# EN: Mints exactly 1 membership token to the given wallet.
# If wallet already has >=1 token, script exits with error.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
WALLET="${1:-}"
if [[ -z "$WALLET" ]]; then
echo "Использование:"
echo " $0 <wallet>"
echo "Usage:"
echo " $0 <wallet>"
exit 1
fi
node "$SCRIPT_DIR/js/02_mint_membership_to_wallet_exec.js" "$CONFIG_PATH" "$WALLET"

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
#
# RU: Принудительно сжигает 1 membership-токен на указанном кошельке.
# EN: Force-burns exactly 1 membership token from the given wallet.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
WALLET="${1:-}"
if [[ -z "$WALLET" ]]; then
echo "Использование:"
echo " $0 <wallet>"
echo "Usage:"
echo " $0 <wallet>"
exit 1
fi
node "$SCRIPT_DIR/js/03_force_burn_from_wallet_exec.js" "$CONFIG_PATH" "$WALLET"

View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
#
# RU: Создает DAO (Realm + Governance + Treasury) на уже существующем governance mint.
# EN: Creates DAO (Realm + Governance + Treasury) using existing governance mint.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
node "$SCRIPT_DIR/js/05_create_dao_exec.js" "$CONFIG_PATH"

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
#
# RU: Передает права Mint/Freeze/PermanentDelegate на Governance PDA из конфига.
# Перед отправкой транзакции внутри JS будет подтверждение "yes".
# EN: Transfers Mint/Freeze/PermanentDelegate authorities to Governance PDA
# from config. JS script asks for "yes" confirmation before sending.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
node "$SCRIPT_DIR/js/04_transfer_rights_to_governance_pda_exec.js" "$CONFIG_PATH"

View File

@ -0,0 +1,74 @@
# CreateGovernmentTokenAndDAO
## RU
Единый набор скриптов для:
1. создания governance token,
2. выдачи/сжигания membership токенов,
3. передачи прав на Governance PDA,
4. создания DAO (Realm/Governance/Treasury).
### Важная структура ключей
Используются две папки:
- `keypairs/dao_creator/` — ключ инициатора DAO и плательщика (ровно 1 `*.json`).
- `keypairs/government_token/` — ключ mint governance token (ровно 1 `*.json`).
Скрипты автоматически берут единственный файл из этих папок.
Если в папке `government_token` 0 файлов или больше 1 — скрипт завершится ошибкой.
### Скрипты
```bash
./01_create_governance_token.sh
./02_mint_token_to_wallet.sh <WALLET>
./03_force_burn_from_wallet.sh <WALLET>
./04_create_dao.sh
./05_transfer_rights_to_governance_pda.sh
./grind_vanity_mint.sh [PREFIX] [COUNT] [ignore-case]
```
### Базовый порядок
1. (Опционально) `grind_vanity_mint.sh`, затем ОБЯЗАТЕЛЬНО скопировать выбранный json в `keypairs/government_token/`.
Пример:
```bash
cp ./runs/<FOUND_KEYPAIR>.json ./keypairs/government_token/selected_mint.json
```
2. `01_create_governance_token.sh`
3. В `governance_token.config.env` указать `GT_MINT_ADDRESS`.
4. `02_mint_token_to_wallet.sh <WALLET>`
5. `03_force_burn_from_wallet.sh <WALLET>`
6. `04_create_dao.sh`
7. Внести полученный Governance PDA в `GT_GOVERNANCE_PDA`.
8. `05_transfer_rights_to_governance_pda.sh`
---
## EN
Unified scripts for:
1. governance token creation,
2. membership mint/burn,
3. authority transfer to Governance PDA,
4. DAO creation (Realm/Governance/Treasury).
### Required keypair layout
Two folders are used:
- `keypairs/dao_creator/` — DAO creator/payer keypair (exactly 1 `*.json`).
- `keypairs/government_token/` — governance token mint keypair (exactly 1 `*.json`).
Scripts auto-detect the single file in each folder.
If `government_token` has 0 files or more than 1 file, script fails with error.
### Scripts
```bash
./01_create_governance_token.sh
./02_mint_token_to_wallet.sh <WALLET>
./03_force_burn_from_wallet.sh <WALLET>
./04_create_dao.sh
./05_transfer_rights_to_governance_pda.sh
./grind_vanity_mint.sh [PREFIX] [COUNT] [ignore-case]
```

View File

@ -0,0 +1,33 @@
# Конфиг CreateGovernmentTokenAndDAO
# devnet | mainnet-beta
GT_CLUSTER="devnet"
# Папка с keypair инициатора DAO/плательщика.
# Внутри должен быть ровно 1 json-файл.
GT_DAO_CREATOR_KEYPAIR_DIR="./keypairs/dao_creator"
# Папка с keypair governance token mint.
# Внутри должен быть ровно 1 json-файл (или 0, тогда 01-скрипт создаст selected_mint.json).
GT_GOVERNMENT_TOKEN_KEYPAIR_DIR="./keypairs/government_token"
# Governance PDA (сюда передаем управляющие права после создания DAO)
GT_GOVERNANCE_PDA="REPLACE_WITH_GOVERNANCE_PDA"
# Явный mint-адрес (если указан, приоритетнее keypair-папки)
GT_MINT_ADDRESS=""
# Папка для результатов/логов
GT_RUNS_DIR="./runs"
# Дефолт для vanity-подбора (05)
GT_VANITY_PREFIX="SHiNE"
# ===== DAO create settings (05_create_dao.sh) =====
DAO_REALM_NAME="CreateDAO Test 2026-05-15"
DAO_VOTING_TIME_SEC="3600"
DAO_APPROVAL_THRESHOLD_PERCENT="51"
DAO_RUNS_DIR="./runs"
# SPL Governance program
SPL_GOVERNANCE_PROGRAM_ID="GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw"

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
#
# RU: Подбирает vanity mint keypair через `solana-keygen grind`.
# Параметры: [PREFIX] [COUNT] [ignore-case]
# EN: Finds vanity mint keypair using `solana-keygen grind`.
# Args: [PREFIX] [COUNT] [ignore-case]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
PREFIX="${1:-}"
COUNT="${2:-1}"
IGNORE_CASE="${3:-}"
if [[ -n "$PREFIX" ]]; then
node "$SCRIPT_DIR/js/grind_vanity_mint_exec.js" "$CONFIG_PATH" "$PREFIX" "$COUNT" "$IGNORE_CASE"
else
node "$SCRIPT_DIR/js/grind_vanity_mint_exec.js" "$CONFIG_PATH"
fi

View File

@ -0,0 +1,40 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { Connection, SystemProgram, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_PROGRAM_ID, getMintLen, createInitializeMintInstruction } = require("@solana/spl-token");
const { parseEnvConfig, assertRequired, resolveConfigPath, loadKeypair, findSingleJsonFile, saveKeypair, parseCluster, nowStamp, ui, getOperatorKeypairFromConfig } = require("./_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
assertRequired(cfg, "GT_CLUSTER"); assertRequired(cfg, "GT_RUNS_DIR");
const operator = getOperatorKeypairFromConfig(cfg);
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
const gtDir = path.resolve(cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR || path.join(__dirname, "..", "keypairs", "government_token"));
fs.mkdirSync(gtDir, { recursive: true });
const mintKeypairPath = findSingleJsonFile(gtDir);
const mint = loadKeypair(mintKeypairPath);
const mintLen = getMintLen([]);
const rent = await connection.getMinimumBalanceForRentExemption(mintLen, "confirmed");
ui.title("=== Создание governance token (SPL classic) / Create governance token (SPL classic) ===");
const tx = new Transaction().add(
SystemProgram.createAccount({ fromPubkey: operator.publicKey, newAccountPubkey: mint.publicKey, space: mintLen, lamports: rent, programId: TOKEN_PROGRAM_ID }),
createInitializeMintInstruction(mint.publicKey, 0, operator.publicKey, operator.publicKey, TOKEN_PROGRAM_ID)
);
const sig = await sendAndConfirmTransaction(connection, tx, [operator, mint], { commitment: "confirmed" });
const runsDir = path.resolve(cfg.GT_RUNS_DIR); fs.mkdirSync(runsDir, { recursive: true });
const outMintPath = mintKeypairPath;
saveKeypair(outMintPath, mint);
fs.writeFileSync(path.join(runsDir, `${nowStamp()}_create_token.json`), JSON.stringify({ mint: mint.publicKey.toBase58(), txCreateMint: sig }, null, 2));
ui.ok(`OK: Mint ${mint.publicKey.toBase58()}`);
ui.info(`RU: Использован keypair: ${mintKeypairPath}`);
ui.info(`EN: Used keypair: ${mintKeypairPath}`);
ui.info(`RU: Вставьте этот mint в файл: ${path.resolve(__dirname, "..", "governance_token.config.env")}`);
ui.info(`RU: Строка: GT_MINT_ADDRESS="${mint.publicKey.toBase58()}"`);
ui.info(`EN: Put this mint into file: ${path.resolve(__dirname, "..", "governance_token.config.env")}`);
ui.info(`EN: Line: GT_MINT_ADDRESS="${mint.publicKey.toBase58()}"`);
ui.info(`Mint keypair: ${outMintPath}`);
ui.info(`Tx: ${sig}`);
}
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });

View File

@ -0,0 +1,34 @@
#!/usr/bin/env node
"use strict";
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createMintToInstruction, getAccount } = require("@solana/spl-token");
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, ui, getMintPublicKeyFromConfig, getOperatorKeypairFromConfig } = require("./_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const receiver = new PublicKey(process.argv[3]);
if (!process.argv[3]) throw new Error("Использование / Usage: node .../02...js <config.env> <receiver_wallet>");
assertRequired(cfg, "GT_CLUSTER");
const mint = getMintPublicKeyFromConfig(cfg);
const operator = getOperatorKeypairFromConfig(cfg);
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
const ata = getAssociatedTokenAddressSync(mint, receiver, false, TOKEN_2022_PROGRAM_ID);
const ataInfo = await connection.getAccountInfo(ata, "confirmed");
if (ataInfo) {
const tokenAcc = await getAccount(connection, ata, "confirmed", TOKEN_2022_PROGRAM_ID);
if (tokenAcc.amount >= 1n) {
throw new Error(
`На кошельке уже есть membership token / Wallet already has membership token. wallet=${receiver.toBase58()} amount=${tokenAcc.amount.toString()}`
);
}
}
const ix = [
createAssociatedTokenAccountIdempotentInstruction(operator.publicKey, ata, receiver, mint, TOKEN_2022_PROGRAM_ID),
createMintToInstruction(mint, ata, operator.publicKey, 1n, [], TOKEN_2022_PROGRAM_ID),
];
ui.title("=== Выпуск 1 membership токена / Mint 1 membership token ===");
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ix), [operator], { commitment: "confirmed" });
ui.ok("Успешно / Success");
ui.info(`Mint: ${mint.toBase58()}`); ui.info(`Wallet: ${receiver.toBase58()}`); ui.info(`Tx: ${sig}`);
}
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });

View File

@ -0,0 +1,22 @@
#!/usr/bin/env node
"use strict";
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createBurnCheckedInstruction } = require("@solana/spl-token");
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, ui, getMintPublicKeyFromConfig, getOperatorKeypairFromConfig } = require("./_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const targetOwner = new PublicKey(process.argv[3]);
if (!process.argv[3]) throw new Error("Использование / Usage: node .../03...js <config.env> <target_owner_wallet>");
assertRequired(cfg, "GT_CLUSTER");
const mint = getMintPublicKeyFromConfig(cfg);
const operator = getOperatorKeypairFromConfig(cfg);
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
const targetAta = getAssociatedTokenAddressSync(mint, targetOwner, false, TOKEN_2022_PROGRAM_ID);
const ix = createBurnCheckedInstruction(targetAta, mint, operator.publicKey, 1n, 0, [], TOKEN_2022_PROGRAM_ID);
ui.title("=== Принудительное сжигание 1 токена / Force burn 1 token ===");
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [operator], { commitment: "confirmed" });
ui.ok("Успешно / Success");
ui.info(`Mint: ${mint.toBase58()}`); ui.info(`Wallet: ${targetOwner.toBase58()}`); ui.info(`Tx: ${sig}`);
}
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });

View File

@ -0,0 +1,28 @@
#!/usr/bin/env node
"use strict";
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
const { TOKEN_2022_PROGRAM_ID, AuthorityType, createSetAuthorityInstruction } = require("@solana/spl-token");
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, askYes, ui, getMintPublicKeyFromConfig, getOperatorKeypairFromConfig } = require("./_common");
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
assertRequired(cfg, "GT_CLUSTER"); assertRequired(cfg, "GT_GOVERNANCE_PDA");
const mint = getMintPublicKeyFromConfig(cfg);
const operator = getOperatorKeypairFromConfig(cfg);
const governancePda = new PublicKey(cfg.GT_GOVERNANCE_PDA);
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
ui.title("=== Передача прав DAO / Transfer rights to DAO ===");
ui.warn(`RU: Будут переданы права Mint/Freeze/PermanentDelegate от ${operator.publicKey.toBase58()} на ${governancePda.toBase58()}`);
ui.warn(`EN: Mint/Freeze/PermanentDelegate authorities will be transferred to governance PDA.`);
const ok = await askYes("Введите yes / Type yes to continue: ");
if (!ok) return ui.warn("Отменено / Cancelled");
const ixs = [
createSetAuthorityInstruction(mint, operator.publicKey, AuthorityType.MintTokens, governancePda, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint, operator.publicKey, AuthorityType.FreezeAccount, governancePda, [], TOKEN_2022_PROGRAM_ID),
createSetAuthorityInstruction(mint, operator.publicKey, AuthorityType.PermanentDelegate, governancePda, [], TOKEN_2022_PROGRAM_ID),
];
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ixs), [operator], { commitment: "confirmed" });
ui.ok("Успешно / Success");
ui.info(`Mint: ${mint.toBase58()}`); ui.info(`DAO PDA: ${governancePda.toBase58()}`); ui.info(`Tx: ${sig}`);
}
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });

View File

@ -0,0 +1,191 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const BN = require("bn.js");
const {
Connection,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} = require("@solana/web3.js");
const {
getAssociatedTokenAddressSync,
TOKEN_2022_PROGRAM_ID,
TOKEN_PROGRAM_ID,
} = require("@solana/spl-token");
const {
MintMaxVoteWeightSource,
VoteThreshold,
VoteThresholdType,
VoteTipping,
GovernanceConfig,
PROGRAM_VERSION_V3,
GoverningTokenConfigAccountArgs,
GoverningTokenType,
withCreateRealm,
withDepositGoverningTokens,
withCreateGovernance,
withCreateNativeTreasury,
withSetRealmAuthority,
SetRealmAuthorityAction,
} = require("@solana/spl-governance");
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, nowStamp, getOperatorKeypairFromConfig, getMintPublicKeyFromConfig, ui } = require("./_common");
async function main() {
const configPath = resolveConfigPath(process.argv[2]);
const cfg = parseEnvConfig(configPath);
[
"GT_CLUSTER", "DAO_REALM_NAME", "SPL_GOVERNANCE_PROGRAM_ID", "DAO_VOTING_TIME_SEC", "DAO_APPROVAL_THRESHOLD_PERCENT"
].forEach((k) => assertRequired(cfg, k));
const cluster = cfg.GT_CLUSTER;
const connection = new Connection(parseCluster(cluster), "confirmed");
const operator = getOperatorKeypairFromConfig(cfg);
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const mint = getMintPublicKeyFromConfig(cfg);
const votingTimeSec = Number(cfg.DAO_VOTING_TIME_SEC);
const thresholdPct = Number(cfg.DAO_APPROVAL_THRESHOLD_PERCENT);
const runsDir = path.resolve(cfg.DAO_RUNS_DIR || path.join(__dirname, "runs"));
fs.mkdirSync(runsDir, { recursive: true });
const mintAi = await connection.getAccountInfo(mint, "confirmed");
if (!mintAi) throw new Error(`Governing mint not found: ${mint.toBase58()}`);
if (!mintAi.owner.equals(TOKEN_PROGRAM_ID)) {
throw new Error(
`Этот CreateDAO ожидает governing mint под классическим SPL Token (${TOKEN_PROGRAM_ID.toBase58()}). ` +
`Текущий mint owner: ${mintAi.owner.toBase58()}`
);
}
const [realmPda] = PublicKey.findProgramAddressSync(
[Buffer.from("governance"), Buffer.from(cfg.DAO_REALM_NAME, "utf8")],
governanceProgramId
);
const realmExists = (await connection.getAccountInfo(realmPda)) !== null;
if (realmExists) throw new Error(`Realm already exists: ${realmPda.toBase58()}`);
const ownerAtaToken2022 = getAssociatedTokenAddressSync(mint, operator.publicKey, false, TOKEN_2022_PROGRAM_ID);
const ownerAtaToken = getAssociatedTokenAddressSync(mint, operator.publicKey, false, TOKEN_PROGRAM_ID);
let ownerAta = ownerAtaToken2022;
let ownerAtaInfo = await connection.getAccountInfo(ownerAtaToken2022, "confirmed");
let tokenProgramId = TOKEN_2022_PROGRAM_ID;
if (!ownerAtaInfo) {
ownerAta = ownerAtaToken;
ownerAtaInfo = await connection.getAccountInfo(ownerAtaToken, "confirmed");
tokenProgramId = TOKEN_PROGRAM_ID;
}
if (!ownerAtaInfo) throw new Error("Operator ATA for governing mint not found. Mint at least 1 token to operator first.");
const programVersion = PROGRAM_VERSION_V3;
const ixRealm = [];
const communityTokenConfig = new GoverningTokenConfigAccountArgs({
voterWeightAddin: undefined,
maxVoterWeightAddin: undefined,
tokenType: GoverningTokenType.Membership,
});
const realmPk = await withCreateRealm(
ixRealm,
governanceProgramId,
programVersion,
cfg.DAO_REALM_NAME,
operator.publicKey,
mint,
operator.publicKey,
undefined,
MintMaxVoteWeightSource.FULL_SUPPLY_FRACTION,
new BN(1),
communityTokenConfig,
undefined
);
const sigRealm = await sendAndConfirmTransaction(connection, new Transaction().add(...ixRealm), [operator], { commitment: "confirmed" });
const ixDeposit = [];
const tokenOwnerRecordPk = await withDepositGoverningTokens(
ixDeposit,
governanceProgramId,
programVersion,
realmPk,
ownerAta,
mint,
operator.publicKey,
operator.publicKey,
operator.publicKey,
new BN(1),
true,
tokenProgramId
);
const sigDeposit = await sendAndConfirmTransaction(connection, new Transaction().add(...ixDeposit), [operator], { commitment: "confirmed" });
const governanceConfig = new GovernanceConfig({
communityVoteThreshold: new VoteThreshold({ type: VoteThresholdType.YesVotePercentage, value: thresholdPct }),
minCommunityTokensToCreateProposal: new BN(1),
minInstructionHoldUpTime: 0,
baseVotingTime: votingTimeSec,
communityVoteTipping: VoteTipping.Early,
minCouncilTokensToCreateProposal: new BN(0),
councilVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
councilVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
communityVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
councilVoteTipping: VoteTipping.Disabled,
votingCoolOffTime: 0,
depositExemptProposalCount: 0,
});
const ixGov = [];
const governancePk = await withCreateGovernance(
ixGov,
governanceProgramId,
programVersion,
realmPk,
realmPk,
governanceConfig,
tokenOwnerRecordPk,
operator.publicKey,
operator.publicKey
);
const treasuryPk = await withCreateNativeTreasury(ixGov, governanceProgramId, programVersion, governancePk, operator.publicKey);
const sigGov = await sendAndConfirmTransaction(connection, new Transaction().add(...ixGov), [operator], { commitment: "confirmed" });
const ixRealmAuthority = [];
withSetRealmAuthority(
ixRealmAuthority,
governanceProgramId,
programVersion,
realmPk,
operator.publicKey,
governancePk,
SetRealmAuthorityAction.SetChecked
);
const sigSetRealmAuthority = await sendAndConfirmTransaction(connection, new Transaction().add(...ixRealmAuthority), [operator], { commitment: "confirmed" });
const report = {
createdAt: new Date().toISOString(),
cluster,
realmName: cfg.DAO_REALM_NAME,
governanceProgramId: governanceProgramId.toBase58(),
governingMint: mint.toBase58(),
operator: operator.publicKey.toBase58(),
realm: realmPk.toBase58(),
governance: governancePk.toBase58(),
nativeTreasury: treasuryPk.toBase58(),
tokenOwnerRecord: tokenOwnerRecordPk.toBase58(),
txRealm: sigRealm,
txDeposit: sigDeposit,
txGovernanceTreasury: sigGov,
txSetRealmAuthority: sigSetRealmAuthority,
};
const reportPath = path.join(runsDir, `${nowStamp()}_create_dao.json`);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
ui.ok("DAO created successfully / DAO успешно создан");
ui.info(`Realm: ${realmPk.toBase58()}`);
ui.info(`Governance PDA: ${governancePk.toBase58()}`);
ui.info(`Treasury: ${treasuryPk.toBase58()}`);
ui.info(`Report: ${reportPath}`);
}
main().catch((e) => {
console.error("CreateDAO error:", e?.message || e);
process.exit(1);
});

View File

@ -0,0 +1,118 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const { Keypair, PublicKey, clusterApiUrl } = require("@solana/web3.js");
function parseEnvConfig(configPath) {
const raw = fs.readFileSync(configPath, "utf8");
const out = {};
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let val = trimmed.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
val = val.replace(/\$HOME/g, process.env.HOME || "");
out[key] = val;
}
return out;
}
function assertRequired(cfg, key) {
if (!cfg[key]) throw new Error(`В конфиге отсутствует обязательный параметр / Missing config key: ${key}`);
}
function resolveConfigPath(argvPath) {
return argvPath ? path.resolve(argvPath) : path.resolve(__dirname, "..", "governance_token.config.env");
}
function loadKeypair(filePath) {
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
return Keypair.fromSecretKey(Uint8Array.from(arr));
}
function findSingleJsonFile(dirPath) {
const abs = path.resolve(dirPath);
if (!fs.existsSync(abs)) throw new Error(`Папка не найдена / Directory not found: ${abs}`);
const files = fs.readdirSync(abs).filter((f) => {
const p = path.join(abs, f);
return fs.statSync(p).isFile() && f.endsWith(".json");
});
if (files.length !== 1) {
throw new Error(`В папке должен быть ровно 1 json-файл / Directory must contain exactly 1 json file: ${abs}. Сейчас: ${files.length}`);
}
return path.join(abs, files[0]);
}
function saveKeypair(filePath, keypair) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(Array.from(keypair.secretKey)));
}
function parseCluster(cluster) {
if (cluster === "devnet" || cluster === "mainnet-beta" || cluster === "testnet") return clusterApiUrl(cluster);
return cluster;
}
function nowStamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
}
async function askYes(prompt) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) => rl.question(prompt, resolve));
rl.close();
return answer.trim() === "yes";
}
function colors(s, code) { return `\x1b[${code}m${s}\x1b[0m`; }
const ui = {
info: (s) => console.log(colors(s, "36")),
ok: (s) => console.log(colors(s, "32")),
warn: (s) => console.log(colors(s, "33")),
err: (s) => console.log(colors(s, "31")),
title: (s) => console.log(colors(s, "1;35")),
};
function getMintPublicKeyFromConfig(cfg) {
if (cfg.GT_MINT_ADDRESS && cfg.GT_MINT_ADDRESS.trim()) return new PublicKey(cfg.GT_MINT_ADDRESS.trim());
if (cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR && cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR.trim()) {
const kpPath = findSingleJsonFile(path.resolve(cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR));
return loadKeypair(kpPath).publicKey;
}
if (cfg.GT_MINT_KEYPAIR_PATH && cfg.GT_MINT_KEYPAIR_PATH.trim()) return loadKeypair(path.resolve(cfg.GT_MINT_KEYPAIR_PATH)).publicKey;
throw new Error("Не задан mint: укажите GT_MINT_ADDRESS или положите 1 keypair в GT_GOVERNMENT_TOKEN_KEYPAIR_DIR");
}
function getOperatorKeypairFromConfig(cfg) {
if (cfg.GT_DAO_CREATOR_KEYPAIR_DIR && cfg.GT_DAO_CREATOR_KEYPAIR_DIR.trim()) {
const kpPath = findSingleJsonFile(path.resolve(cfg.GT_DAO_CREATOR_KEYPAIR_DIR));
return loadKeypair(kpPath);
}
if (cfg.GT_OPERATOR_KEYPAIR_PATH && cfg.GT_OPERATOR_KEYPAIR_PATH.trim()) {
return loadKeypair(path.resolve(cfg.GT_OPERATOR_KEYPAIR_PATH));
}
throw new Error("Не задан ключ оператора: укажите GT_DAO_CREATOR_KEYPAIR_DIR или GT_OPERATOR_KEYPAIR_PATH");
}
module.exports = {
parseEnvConfig,
assertRequired,
resolveConfigPath,
loadKeypair,
findSingleJsonFile,
saveKeypair,
parseCluster,
nowStamp,
askYes,
ui,
getMintPublicKeyFromConfig,
getOperatorKeypairFromConfig,
};

View File

@ -0,0 +1,36 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { parseEnvConfig, resolveConfigPath, nowStamp, ui } = require("./_common");
const DEFAULT_PREFIX = "SHi";
async function main() {
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
const runsDir = path.resolve(cfg.GT_RUNS_DIR || path.join(__dirname, "..", "runs"));
fs.mkdirSync(runsDir, { recursive: true });
const prefix = process.argv[3] || cfg.GT_VANITY_PREFIX || DEFAULT_PREFIX;
if (!/^[1-9A-HJ-NP-Za-km-z]+$/.test(prefix)) throw new Error("Префикс Base58 без 0/O/I/l");
ui.title("=== Vanity подбор mint keypair / Vanity mint keypair grind ===");
ui.info(`Prefix: ${prefix}`);
const args = ["grind", "--starts-with", `${prefix}:1`];
const p = spawn("solana-keygen", args, { cwd: runsDir, stdio: ["ignore", "pipe", "pipe"] });
const lines = [];
const on = (d) => {
for (const l of String(d).split("\n")) {
const line = l.trim(); if (!line) continue;
lines.push(line); console.log(line);
}
};
p.stdout.on("data", on); p.stderr.on("data", on);
const code = await new Promise((resolve) => p.on("close", resolve));
if (code !== 0) throw new Error(`solana-keygen grind exit code ${code}`);
const rp = path.join(runsDir, `${nowStamp()}_vanity_grind_report.json`);
fs.writeFileSync(rp, JSON.stringify({ createdAt: new Date().toISOString(), prefix, command: `solana-keygen ${args.join(" ")}`, outputLog: lines }, null, 2));
ui.ok("Готово / Done");
ui.info(`Report: ${rp}`);
ui.info(`RU: Скопируйте выбранный keypair из runs в keypairs/government_token/ (один json-файл).`);
ui.info(`EN: Copy selected keypair from runs to keypairs/government_token/ (single json file).`);
}
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });

View File

@ -0,0 +1 @@
[221,119,143,125,90,136,155,115,191,198,210,85,228,111,251,118,168,138,27,60,249,62,247,24,121,228,139,112,218,69,55,143,215,21,229,69,219,1,74,36,10,239,63,163,48,240,58,208,237,251,209,37,17,202,215,77,13,165,178,18,141,21,193,64]

View File

@ -0,0 +1 @@
[134,197,95,124,255,199,219,182,107,27,148,43,9,167,197,238,72,191,98,205,70,227,160,213,107,110,89,3,33,49,199,29,6,122,106,78,28,40,164,141,120,125,226,194,56,246,248,203,15,90,120,32,226,163,174,32,67,73,246,167,52,25,64,236]

View File

@ -1,5 +1,5 @@
{
"createdAt": "2026-05-15T12:20:22.373Z",
"createdAt": "2026-05-15T12:33:36.911Z",
"prefix": "DAo",
"count": 1,
"ignoreCase": false,
@ -11,6 +11,6 @@
"outputLog": [
"Searching with 16 threads for:",
"1 pubkey that starts with 'DAo' and ends with ''",
"Wrote keypair to DAou7SeaykoMooghA5SURLYhkkU8NEhV5Y2T6fsXD7rn.json"
"Wrote keypair to DAoHZ4bmVZU7Cx9xnSsc1xJJfTJnP2s4TeMgci7x6AsG.json"
]
}

View File

@ -0,0 +1,17 @@
{
"createdAt": "2026-05-15T12:34:25.973Z",
"prefix": "SHi",
"count": 1,
"ignoreCase": false,
"runsDir": "/home/ai/work/SOLANA/shine-solana/shine/scripts/governance_token/runs",
"avgExpectedTriesPerMatch": 195112,
"attemptsObserved": 1000000,
"foundHintsInOutput": 1,
"command": "solana-keygen grind --starts-with SHi:1",
"outputLog": [
"Searching with 16 threads for:",
"1 pubkey that starts with 'SHi' and ends with ''",
"Searched 1000000 keypairs in 4s. 0 matches found.",
"Wrote keypair to SHiwSvDUGjsye9ZE8YAttXdycuDcprWq95oqr69WP9f.json"
]
}

View File

@ -0,0 +1,13 @@
{
"createdAt": "2026-05-15T12:46:32.749Z",
"cluster": "devnet",
"operator": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"mint": "SHiwSvDUGjsye9ZE8YAttXdycuDcprWq95oqr69WP9f",
"tokenProgram": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"nonTransferable": true,
"permanentDelegate": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"mintAuthority": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"freezeAuthority": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"mintKeypairPath": "/home/ai/work/SOLANA/shine-solana/shine/scripts/governance_token/mint_keypairs/selected_mint.json",
"txCreateMint": "4oBPaP4L6E1z4UZUgLKXAA3ZBJcxtPgDcTL6MCfQJkmG8dsX3sARp7dDKYrqYT9B4H326N4HZpTwAJytfjnDfYQb"
}

View File

@ -0,0 +1,10 @@
{
"createdAt": "2026-05-15T13:57:13.110Z",
"prefix": "SHi",
"command": "solana-keygen grind --starts-with SHi:1",
"outputLog": [
"Searching with 16 threads for:",
"1 pubkey that starts with 'SHi' and ends with ''",
"Wrote keypair to SHiEKwoeggb2Nd6AvkqKBFgh3ubBmW5YtVES4xu5j7h.json"
]
}

View File

@ -0,0 +1,4 @@
{
"mint": "SHiEKwoeggb2Nd6AvkqKBFgh3ubBmW5YtVES4xu5j7h",
"txCreateMint": "64cHd8ez4EuPs5TbaVLEqyHjp6ufiuZUyoErg3X98XUPoysWnTgeq4FUafJUqS97Hq8C7AuwbiQZPBVwjdCfKF5W"
}

View File

@ -0,0 +1,16 @@
{
"createdAt": "2026-05-15T14:07:22.593Z",
"cluster": "devnet",
"realmName": "DAO-Cost-0515-B",
"governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw",
"governingMint": "DrKQHbtacwD2jqgxZCatLk2PCbNvgPrNQFswVP72hC98",
"operator": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"realm": "H35UPU98sC2bo265Q4R6PWdvbaotbbCvjDcuvwjDvewf",
"governance": "9aD18P5nun1RPVpEeeCeG5ensyry9WKrwjdX4stVa7qP",
"nativeTreasury": "FbupU7ivym2P2UFi4qwypNGu1eTPDyb7Uctha1XEALCy",
"tokenOwnerRecord": "GvVc8DDrDXbPtYWzsdvPD6SXzWp4cXVmvUQ9Y68rDKz6",
"txRealm": "Xz5GF29JtWHZV1eTuy1gZsRM5RuDGWoEjJtotwzJrJ6yrhrHD87miJjDcJrHfn95Dcu8ELtxaJScBKYpmnh8QjQ",
"txDeposit": "3CStA356srRnXREyEjSf6nbEKDqiwWj7tA3EvMqsHSAfj8d6rtm5MC1t7gNvtgAYH3GMVhdE7zHx1iR1pgE1TNMY",
"txGovernanceTreasury": "omxdAHQtrFgA6qaDzX7QWC51ofuFWbkDTUjweksXwBqeT7tJr84hRsR7FMKAr45mi6b7xoDR7B9chq1qhfygtVg",
"txSetRealmAuthority": "5E61sgqx6Ax3bThG6AVFjxfbqaoNCUXQuECSWDshgKVsnyxNT9NSmkpqD4Gu3tC9B8s5nmYLVTb6DtfepMZSj6y9"
}

View File

@ -0,0 +1 @@
[63,203,53,184,240,127,53,242,32,8,61,128,100,43,24,37,2,59,78,168,105,230,234,235,6,193,28,26,127,173,235,154,180,206,206,50,137,55,225,129,136,21,78,124,42,104,92,200,135,65,248,101,101,217,247,196,235,54,104,253,117,198,199,95]

View File

@ -0,0 +1 @@
[134,197,95,124,255,199,219,182,107,27,148,43,9,167,197,238,72,191,98,205,70,227,160,213,107,110,89,3,33,49,199,29,6,122,106,78,28,40,164,141,120,125,226,194,56,246,248,203,15,90,120,32,226,163,174,32,67,73,246,167,52,25,64,236]

View File

@ -0,0 +1 @@
[175,188,30,40,32,154,227,126,97,66,48,147,223,9,161,80,124,65,129,226,43,249,24,216,42,36,22,39,172,158,72,190,6,122,109,215,230,183,230,136,221,4,43,131,22,137,145,82,134,161,14,135,252,49,35,44,166,15,180,139,72,11,94,118]

View File

@ -1,114 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const {
Connection,
Keypair,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
} = require("@solana/web3.js");
const {
TOKEN_2022_PROGRAM_ID,
ExtensionType,
getMintLen,
createInitializeMintInstruction,
createInitializeNonTransferableMintInstruction,
createInitializePermanentDelegateInstruction,
} = require("@solana/spl-token");
const {
parseEnvConfig,
assertRequired,
resolveConfigPath,
loadKeypair,
saveKeypair,
parseCluster,
nowStamp,
} = require("./_common");
async function main() {
const configPath = resolveConfigPath(process.argv[2]);
const cfg = parseEnvConfig(configPath);
assertRequired(cfg, "GT_CLUSTER");
assertRequired(cfg, "GT_OPERATOR_KEYPAIR_PATH");
assertRequired(cfg, "GT_RUNS_DIR");
const operator = loadKeypair(path.resolve(cfg.GT_OPERATOR_KEYPAIR_PATH));
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
let mint;
if (cfg.GT_MINT_KEYPAIR_PATH) {
mint = loadKeypair(path.resolve(cfg.GT_MINT_KEYPAIR_PATH));
} else {
mint = Keypair.generate();
}
const extensions = [ExtensionType.NonTransferable, ExtensionType.PermanentDelegate];
const mintLen = getMintLen(extensions);
const rent = await connection.getMinimumBalanceForRentExemption(mintLen, "confirmed");
const tx = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: operator.publicKey,
newAccountPubkey: mint.publicKey,
space: mintLen,
lamports: rent,
programId: TOKEN_2022_PROGRAM_ID,
}),
createInitializeNonTransferableMintInstruction(mint.publicKey, TOKEN_2022_PROGRAM_ID),
createInitializePermanentDelegateInstruction(
mint.publicKey,
operator.publicKey,
TOKEN_2022_PROGRAM_ID
),
createInitializeMintInstruction(
mint.publicKey,
0,
operator.publicKey,
operator.publicKey,
TOKEN_2022_PROGRAM_ID
)
);
const sig = await sendAndConfirmTransaction(connection, tx, [operator, mint], {
commitment: "confirmed",
});
const runsDir = path.resolve(cfg.GT_RUNS_DIR);
fs.mkdirSync(runsDir, { recursive: true });
const outMintPath =
cfg.GT_MINT_KEYPAIR_PATH && cfg.GT_MINT_KEYPAIR_PATH.trim()
? path.resolve(cfg.GT_MINT_KEYPAIR_PATH)
: path.join(runsDir, `${nowStamp()}_mint-keypair.json`);
saveKeypair(outMintPath, mint);
const report = {
createdAt: new Date().toISOString(),
cluster: cfg.GT_CLUSTER,
operator: operator.publicKey.toBase58(),
mint: mint.publicKey.toBase58(),
tokenProgram: TOKEN_2022_PROGRAM_ID.toBase58(),
nonTransferable: true,
permanentDelegate: operator.publicKey.toBase58(),
mintAuthority: operator.publicKey.toBase58(),
freezeAuthority: operator.publicKey.toBase58(),
mintKeypairPath: outMintPath,
txCreateMint: sig,
};
const reportPath = path.join(runsDir, `${nowStamp()}_create_token.json`);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log("Governance token создан.");
console.log("Mint:", mint.publicKey.toBase58());
console.log("Mint keypair:", outMintPath);
console.log("Tx:", sig);
console.log("Report:", reportPath);
}
main().catch((e) => {
console.error("Ошибка создания governance token:", e?.message || e);
process.exit(1);
});

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/governance_token.config.env}"
node "$SCRIPT_DIR/01_create_governance_token_exec.js" "$CONFIG_PATH"

View File

@ -1,82 +0,0 @@
#!/usr/bin/env node
"use strict";
const path = require("path");
const {
Connection,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} = require("@solana/web3.js");
const {
TOKEN_2022_PROGRAM_ID,
getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction,
createMintToInstruction,
} = require("@solana/spl-token");
const {
parseEnvConfig,
assertRequired,
resolveConfigPath,
loadKeypair,
parseCluster,
} = require("./_common");
async function main() {
const configPath = resolveConfigPath(process.argv[2]);
const mint = new PublicKey(process.argv[3]);
const receiver = new PublicKey(process.argv[4]);
const amount = BigInt(process.argv[5] || "1");
if (!process.argv[4]) {
throw new Error(
"Использование: node 02_mint_membership_to_wallet_exec.js <config.env> <mint> <receiver_wallet> [amount]"
);
}
if (amount <= 0n) throw new Error("amount должен быть > 0");
const cfg = parseEnvConfig(configPath);
assertRequired(cfg, "GT_CLUSTER");
assertRequired(cfg, "GT_OPERATOR_KEYPAIR_PATH");
const operator = loadKeypair(path.resolve(cfg.GT_OPERATOR_KEYPAIR_PATH));
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
const ata = getAssociatedTokenAddressSync(
mint,
receiver,
false,
TOKEN_2022_PROGRAM_ID
);
const ix = [
createAssociatedTokenAccountIdempotentInstruction(
operator.publicKey,
ata,
receiver,
mint,
TOKEN_2022_PROGRAM_ID
),
createMintToInstruction(
mint,
ata,
operator.publicKey,
amount,
[],
TOKEN_2022_PROGRAM_ID
),
];
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ix), [operator], {
commitment: "confirmed",
});
console.log("Mint выполнен.");
console.log("Receiver:", receiver.toBase58());
console.log("ATA:", ata.toBase58());
console.log("Amount:", amount.toString());
console.log("Tx:", sig);
}
main().catch((e) => {
console.error("Ошибка mint membership:", e?.message || e);
process.exit(1);
});

View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/governance_token.config.env}"
MINT="${2:-}"
WALLET="${3:-}"
AMOUNT="${4:-1}"
if [[ -z "$MINT" || -z "$WALLET" ]]; then
echo "Использование:"
echo " $0 [config.env] <mint> <wallet> [amount]"
exit 1
fi
node "$SCRIPT_DIR/02_mint_membership_to_wallet_exec.js" "$CONFIG_PATH" "$MINT" "$WALLET" "$AMOUNT"

View File

@ -1,73 +0,0 @@
#!/usr/bin/env node
"use strict";
const path = require("path");
const {
Connection,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} = require("@solana/web3.js");
const {
TOKEN_2022_PROGRAM_ID,
getAssociatedTokenAddressSync,
createBurnCheckedInstruction,
} = require("@solana/spl-token");
const {
parseEnvConfig,
assertRequired,
resolveConfigPath,
loadKeypair,
parseCluster,
} = require("./_common");
async function main() {
const configPath = resolveConfigPath(process.argv[2]);
const mint = new PublicKey(process.argv[3]);
const targetOwner = new PublicKey(process.argv[4]);
const amount = BigInt(process.argv[5] || "1");
if (!process.argv[4]) {
throw new Error(
"Использование: node 03_force_burn_from_wallet_exec.js <config.env> <mint> <target_owner_wallet> [amount]"
);
}
if (amount <= 0n) throw new Error("amount должен быть > 0");
const cfg = parseEnvConfig(configPath);
assertRequired(cfg, "GT_CLUSTER");
assertRequired(cfg, "GT_OPERATOR_KEYPAIR_PATH");
const operator = loadKeypair(path.resolve(cfg.GT_OPERATOR_KEYPAIR_PATH));
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
const targetAta = getAssociatedTokenAddressSync(
mint,
targetOwner,
false,
TOKEN_2022_PROGRAM_ID
);
const ix = createBurnCheckedInstruction(
targetAta,
mint,
operator.publicKey,
amount,
0,
[],
TOKEN_2022_PROGRAM_ID
);
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [operator], {
commitment: "confirmed",
});
console.log("Force burn выполнен.");
console.log("Target owner:", targetOwner.toBase58());
console.log("Target ATA:", targetAta.toBase58());
console.log("Amount:", amount.toString());
console.log("Tx:", sig);
}
main().catch((e) => {
console.error("Ошибка force burn:", e?.message || e);
process.exit(1);
});

View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/governance_token.config.env}"
MINT="${2:-}"
WALLET="${3:-}"
AMOUNT="${4:-1}"
if [[ -z "$MINT" || -z "$WALLET" ]]; then
echo "Использование:"
echo " $0 [config.env] <mint> <wallet> [amount]"
exit 1
fi
node "$SCRIPT_DIR/03_force_burn_from_wallet_exec.js" "$CONFIG_PATH" "$MINT" "$WALLET" "$AMOUNT"

View File

@ -1,99 +0,0 @@
#!/usr/bin/env node
"use strict";
const path = require("path");
const {
Connection,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} = require("@solana/web3.js");
const {
TOKEN_2022_PROGRAM_ID,
AuthorityType,
createSetAuthorityInstruction,
} = require("@solana/spl-token");
const {
parseEnvConfig,
assertRequired,
resolveConfigPath,
loadKeypair,
parseCluster,
askYes,
} = require("./_common");
async function main() {
const configPath = resolveConfigPath(process.argv[2]);
const mint = new PublicKey(process.argv[3]);
if (!process.argv[3]) {
throw new Error(
"Использование: node 04_transfer_rights_to_governance_pda_exec.js <config.env> <mint>"
);
}
const cfg = parseEnvConfig(configPath);
assertRequired(cfg, "GT_CLUSTER");
assertRequired(cfg, "GT_OPERATOR_KEYPAIR_PATH");
assertRequired(cfg, "GT_GOVERNANCE_PDA");
const operator = loadKeypair(path.resolve(cfg.GT_OPERATOR_KEYPAIR_PATH));
const governancePda = new PublicKey(cfg.GT_GOVERNANCE_PDA);
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
console.log("============================================================");
console.log("ПЕРЕДАЧА ПРАВ УПРАВЛЕНИЯ ТОКЕНОМ НА GOVERNANCE PDA");
console.log("------------------------------------------------------------");
console.log("Сеть: ", cfg.GT_CLUSTER);
console.log("Mint: ", mint.toBase58());
console.log("Текущий оператор: ", operator.publicKey.toBase58());
console.log("Новый authority (DAO PDA): ", governancePda.toBase58());
console.log("Будет передано:");
console.log(" 1) MintTokens authority");
console.log(" 2) FreezeAccount authority");
console.log(" 3) PermanentDelegate authority");
console.log("После этого текущий оператор утратит эти права.");
console.log("============================================================");
const ok = await askYes("Введите yes для подтверждения: ");
if (!ok) {
console.log("Отменено пользователем.");
return;
}
const ixs = [
createSetAuthorityInstruction(
mint,
operator.publicKey,
AuthorityType.MintTokens,
governancePda,
[],
TOKEN_2022_PROGRAM_ID
),
createSetAuthorityInstruction(
mint,
operator.publicKey,
AuthorityType.FreezeAccount,
governancePda,
[],
TOKEN_2022_PROGRAM_ID
),
createSetAuthorityInstruction(
mint,
operator.publicKey,
AuthorityType.PermanentDelegate,
governancePda,
[],
TOKEN_2022_PROGRAM_ID
),
];
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ixs), [operator], {
commitment: "confirmed",
});
console.log("Готово. Tx:", sig);
}
main().catch((e) => {
console.error("Ошибка передачи прав:", e?.message || e);
process.exit(1);
});

View File

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/governance_token.config.env}"
MINT="${2:-}"
if [[ -z "$MINT" ]]; then
echo "Использование:"
echo " $0 [config.env] <mint>"
exit 1
fi
node "$SCRIPT_DIR/04_transfer_rights_to_governance_pda_exec.js" "$CONFIG_PATH" "$MINT"

View File

@ -1,118 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const {
parseEnvConfig,
resolveConfigPath,
nowStamp,
} = require("./_common");
// Можно править прямо в начале файла
const DEFAULT_PREFIX = "sh";
const DEFAULT_MATCH_COUNT = 1;
const DEFAULT_IGNORE_CASE = false;
function expectedTries(prefixLen) {
return Math.pow(58, prefixLen);
}
function ensureSolanaKeygen() {
return new Promise((resolve, reject) => {
const p = spawn("solana-keygen", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
p.on("error", reject);
p.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error("solana-keygen не найден или недоступен"));
});
});
}
async function main() {
const configPath = resolveConfigPath(process.argv[2]);
const cfg = parseEnvConfig(configPath);
const runsDir = path.resolve(cfg.GT_RUNS_DIR || path.join(__dirname, "runs"));
fs.mkdirSync(runsDir, { recursive: true });
const prefix = process.argv[3] || cfg.GT_VANITY_PREFIX || DEFAULT_PREFIX;
const count = Number(process.argv[4] || DEFAULT_MATCH_COUNT);
const ignoreCase = (process.argv[5] || "").toLowerCase() === "ignore-case" || DEFAULT_IGNORE_CASE;
if (!/^[1-9A-HJ-NP-Za-km-z]+$/.test(prefix)) {
throw new Error("Префикс должен быть в Base58 (без 0 O I l)");
}
if (!Number.isInteger(count) || count <= 0) throw new Error("count должен быть целым > 0");
await ensureSolanaKeygen();
const expected = expectedTries(prefix.length);
console.log("============================================================");
console.log("VANITY MINT KEYPAIR GRIND");
console.log("------------------------------------------------------------");
console.log("Prefix:", prefix);
console.log("Count:", count);
console.log("Runs dir:", runsDir);
console.log("Среднее ожидание на 1 совпадение:", expected.toLocaleString("en-US"), "попыток");
console.log("============================================================");
const args = ["grind", "--starts-with", `${prefix}:${count}`];
if (ignoreCase) args.push("--ignore-case");
const child = spawn("solana-keygen", args, {
cwd: runsDir,
stdio: ["ignore", "pipe", "pipe"],
});
const lines = [];
let attempts = 0;
let found = 0;
const onLine = (raw) => {
const line = String(raw).trim();
if (!line) return;
lines.push(line);
console.log(line);
const mAttempts = line.match(/Searched\s+([0-9,]+)\s+keypairs/i);
if (mAttempts) {
attempts = Number(mAttempts[1].replace(/,/g, "")) || attempts;
const pct = Math.min(100, (attempts / expected) * 100);
console.log(
`[progress] attempts=${attempts.toLocaleString("en-US")} ~${pct.toFixed(2)}% от среднего ожидания`
);
}
if (/Wrote keypair to/i.test(line) || /Found matching key/i.test(line)) found += 1;
};
child.stdout.on("data", (d) => String(d).split("\n").forEach(onLine));
child.stderr.on("data", (d) => String(d).split("\n").forEach(onLine));
const code = await new Promise((resolve) => child.on("close", resolve));
if (code !== 0) {
throw new Error(`solana-keygen grind завершился с кодом ${code}`);
}
const report = {
createdAt: new Date().toISOString(),
prefix,
count,
ignoreCase,
runsDir,
avgExpectedTriesPerMatch: expected,
attemptsObserved: attempts,
foundHintsInOutput: found,
command: `solana-keygen ${args.join(" ")}`,
outputLog: lines,
};
const reportPath = path.join(runsDir, `${nowStamp()}_vanity_grind_report.json`);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log("Report:", reportPath);
console.log("Готово. Keypair-файлы сохранены в:", runsDir);
}
main().catch((e) => {
console.error("Ошибка vanity grind:", e?.message || e);
process.exit(1);
});

View File

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/governance_token.config.env}"
PREFIX="${2:-}"
COUNT="${3:-1}"
IGNORE_CASE="${4:-}"
if [[ -n "$PREFIX" ]]; then
node "$SCRIPT_DIR/05_vanity_mint_keypair_grind_exec.js" "$CONFIG_PATH" "$PREFIX" "$COUNT" "$IGNORE_CASE"
else
node "$SCRIPT_DIR/05_vanity_mint_keypair_grind_exec.js" "$CONFIG_PATH"
fi

View File

@ -1,51 +0,0 @@
# Governance Token Scripts
Скрипты для управления governance token на Token-2022:
- `NonTransferable`
- `PermanentDelegate`
## Конфиг
Файл: `scripts/governance_token/governance_token.config.env`
Ключи:
- `GT_CLUSTER` (`devnet` / `mainnet-beta`)
- `GT_OPERATOR_KEYPAIR_PATH`
- `GT_GOVERNANCE_PDA`
- `GT_MINT_KEYPAIR_PATH` (опционально)
- `GT_RUNS_DIR`
- `GT_VANITY_PREFIX`
## Скрипты
1. Создать новый governance token:
```bash
node scripts/governance_token/01_create_governance_token_exec.js scripts/governance_token/governance_token.config.env
```
2. Выпустить токен участнику:
```bash
node scripts/governance_token/02_mint_membership_to_wallet_exec.js scripts/governance_token/governance_token.config.env <MINT> <WALLET> [AMOUNT]
```
3. Принудительно сжечь токен у участника:
```bash
node scripts/governance_token/03_force_burn_from_wallet_exec.js scripts/governance_token/governance_token.config.env <MINT> <WALLET> [AMOUNT]
```
4. Передать права на Governance PDA (с подтверждением `yes`):
```bash
node scripts/governance_token/04_transfer_rights_to_governance_pda_exec.js scripts/governance_token/governance_token.config.env <MINT>
```
5. Vanity-подбор mint keypair через `solana-keygen grind`:
```bash
node scripts/governance_token/05_vanity_mint_keypair_grind_exec.js scripts/governance_token/governance_token.config.env [PREFIX] [COUNT] [ignore-case]
```
Результаты сохраняются в `GT_RUNS_DIR`.

View File

@ -1,88 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const { Keypair, PublicKey, clusterApiUrl } = require("@solana/web3.js");
function parseEnvConfig(configPath) {
const raw = fs.readFileSync(configPath, "utf8");
const out = {};
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let val = trimmed.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
val = val.replace(/\$HOME/g, process.env.HOME || "");
out[key] = val;
}
return out;
}
function assertRequired(cfg, key) {
if (!cfg[key]) throw new Error(`В конфиге отсутствует обязательный параметр: ${key}`);
}
function resolveConfigPath(argvPath) {
return argvPath
? path.resolve(argvPath)
: path.resolve(__dirname, "governance_token.config.env");
}
function loadKeypair(filePath) {
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
return Keypair.fromSecretKey(Uint8Array.from(arr));
}
function saveKeypair(filePath, keypair) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(Array.from(keypair.secretKey)));
}
function parseCluster(cluster) {
if (cluster === "devnet" || cluster === "mainnet-beta" || cluster === "testnet") {
return clusterApiUrl(cluster);
}
return cluster;
}
function nowStamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(
d.getMinutes()
)}-${p(d.getSeconds())}`;
}
async function askYes(prompt) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) => rl.question(prompt, resolve));
rl.close();
return answer.trim() === "yes";
}
function toPublicKey(v, fieldName) {
try {
return new PublicKey(v);
} catch (_) {
throw new Error(`Некорректный pubkey в ${fieldName}: ${v}`);
}
}
module.exports = {
parseEnvConfig,
assertRequired,
resolveConfigPath,
loadKeypair,
saveKeypair,
parseCluster,
nowStamp,
askYes,
toPublicKey,
};

Some files were not shown because too many files have changed in this diff Show More