Compare commits

...

18 Commits

Author SHA1 Message Date
AidarKC
b5bd253b8c Направить комиссии shine_users только в inflow_vault shine_payments 2026-05-16 18:56:52 +03:00
AidarKC
32bc2c9dc3 Убрать фиксированного менеджера из config и хранить только DAO-управляемых менеджеров 2026-05-16 18:36:36 +03:00
AidarKC
a701c1cbba Вынести деплой-константы в common/deploy_config и подробно документировать settings 2026-05-16 18:25:12 +03:00
AidarKC
7d2f50b6e1 Вынести экономику shine_users в отдельный PDA и добавить управление в UI 2026-05-16 18:18:07 +03:00
AidarKC
deef20c517 Удалить устаревший проект solana-shine-client-lib и очистить документацию 2026-05-16 17:31:44 +03:00
AidarKC
890e10de9f Commit remaining workspace changes 2026-05-16 17:17:41 +03:00
AidarKC
bbe406ac01 Restore accidentally removed shine_payments_legacy files 2026-05-16 17:15:33 +03:00
AidarKC
08c070bd55 Add ticket transfer UI and deploy config checklist 2026-05-16 17:15:23 +03:00
AidarKC
8ce804eb77 shine_users: switch user record to v1.0 status-key layout and login<=25 2026-05-16 15:33:20 +03:00
AidarKC
61fcfd0e3b docs: rename and finalize user format v1.0 draft 2026-05-16 15:29:34 +03:00
AidarKC
ec8221fab2 chore: update pyth-crosschain submodule pointer 2026-05-16 15:00:54 +03:00
AidarKC
f6c57d5366 chore: commit all pending workspace changes 2026-05-16 15:00:35 +03:00
AidarKC
a43a929250 docs: refine shiny user format draft with key status and field constraints 2026-05-16 14:59:05 +03:00
AidarKC
61c6a3208a Закоммичены все текущие изменения и добавлено правило русских commit message 2026-05-15 15:29:24 +03:00
AidarKC
9fd2f8f495 Fix vanity script defaults and local runs dir 2026-05-15 15:28:11 +03:00
AidarKC
34c1142173 Add governance token scripts with shell wrappers and vanity grind 2026-05-15 15:15:40 +03:00
AidarKC
c680b16e58 shine_payments: switch to USD cents + Pyth SOL/USD and update devnet UI 2026-05-11 16:16:02 +03:00
AidarKC
ea25b908c1 Обновить логику Shine Payments, UI и задеплоить с новыми Program ID 2026-05-11 14:34:20 +03:00
204 changed files with 15688 additions and 6935 deletions

View File

@ -3,8 +3,8 @@
/workspace.xml /workspace.xml
# Editor-based HTTP Client requests # Editor-based HTTP Client requests
/httpRequests/ /httpRequests/
# Environment-dependent path to Maven home directory # Ignored default folder with query files
/mavenHomeManager.xml /queries/
# Datasource local storage ignored files # Datasource local storage ignored files
/dataSources/ /dataSources/
/dataSources.local.xml /dataSources.local.xml

View File

@ -4,12 +4,12 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$/solana-shine-client-lib" />
<option name="gradleHome" value="" /> <option name="gradleJvm" value="21" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$/solana-shine-client-lib" />
<option value="$PROJECT_DIR$/solana-shine-lib" /> <option value="$PROJECT_DIR$/solana-shine-client-lib/solana-shine-lib" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>

7
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/shine-solana.iml" filepath="$PROJECT_DIR$/.idea/shine-solana.iml" />
</modules>
</component>
</project>

9
.idea/shine-solana.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

@ -0,0 +1,2 @@
#Mon May 11 16:21:48 MSK 2026
gradle.version=8.14.4

View File

@ -0,0 +1 @@
Subproject commit 67c9fd0b0ce55f79d36091da199ba6b534f8ed03

View File

@ -4,9 +4,9 @@
В проекте есть спецификация пользовательской PDA-записи: В проекте есть спецификация пользовательской PDA-записи:
- `doc/SHINE_USER_PDA_V1.md` - актуальные документы в `doc/`.
Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, этот документ нужно обновлять в том же изменении. Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, соответствующую документацию в `doc/` нужно обновлять в том же изменении.
## Language Rule ## Language Rule
@ -31,3 +31,19 @@
- `GITEA_TOKEN` - `GITEA_TOKEN`
Push выполнять через `http.extraHeader` (Authorization) без вывода токена в логи. Push выполнять через `http.extraHeader` (Authorization) без вывода токена в логи.
## Rule: Commit Messages
Текст commit message писать на русском языке.
Это обязательное правило для всех новых коммитов в этом репозитории.
## Rule: UI Deploy
Деплой UI Shine Payments выполнять через Gradle из папки `shine`:
1. `gradle deployUi`
2. `gradle checkUiRemote`
Где смотреть детали (пути деплоя, путь Caddy, рабочие URL):
- комментарии в `build.gradle` (в корне `shine/`).

View File

@ -6,12 +6,12 @@ resolution = true
skip-lint = false skip-lint = false
[programs.devnet] [programs.devnet]
shine_payments = "4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE" shine_payments = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR"
shine_users = "8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ" shine_users = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"
[programs.localnet] [programs.localnet]
shine_payments = "4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE" shine_payments = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR"
shine_users = "5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t" shine_users = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"
[registry] [registry]
url = "https://api.apr.dev" url = "https://api.apr.dev"

5700
shine/Cargo.lock generated

File diff suppressed because it is too large Load Diff

45
shine/build.gradle Normal file
View File

@ -0,0 +1,45 @@
/*
* Gradle-задачи для утилитного деплоя UI Shine Payments.
*
* Куда деплоим файлы UI:
* /home/player/sites/test-solana-tickets.shineup.me
*
* Где расположен Caddy-конфиг на сервере:
* /home/player/SHiNE/caddy/Caddyfile
*
* По каким URL должен работать UI:
* https://test-solana-tickets.shineup.me
* https://sol.shiningpeople.ru
*/
tasks.register("deployUi", Exec) {
group = "deploy"
description = "Деплой HTML UI Shine Payments на 45.136.124.227 в /home/player/sites/test-solana-tickets.shineup.me (URL: test-solana-tickets.shineup.me, sol.shiningpeople.ru)"
// Источник локальных UI-страниц:
// shine/programs/shine_payments/web/
def localUiDir = "${projectDir}/programs/shine_payments/web/"
// Целевая директория на сервере:
// /home/player/sites/test-solana-tickets.shineup.me
def remoteTarget = "player@45.136.124.227:/home/player/sites/test-solana-tickets.shineup.me/"
commandLine "rsync", "-av", "--delete", localUiDir, remoteTarget
}
tasks.register("checkUiRemote", Exec) {
group = "deploy"
description = "Проверка на сервере: Caddy-конфиг и наличие новых Program ID в UI"
commandLine "ssh", "-o", "StrictHostKeyChecking=no", "player@45.136.124.227",
"set -e; " +
"echo 'Caddy file:'; " +
"ls -la /home/player/SHiNE/caddy/Caddyfile; " +
"echo; " +
"echo 'Домены в Caddy:'; " +
"grep -n 'test-solana-tickets.shineup.me\\|sol.shiningpeople.ru' /home/player/SHiNE/caddy/Caddyfile; " +
"echo; " +
"echo 'Program ID в загруженных html:'; " +
"grep -R -n 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR' /home/player/sites/test-solana-tickets.shineup.me/*.html"
}

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

47
shine/doc/FUNDS_FLOW.md Normal file
View File

@ -0,0 +1,47 @@
# Движение Средств (Shine)
Документ описывает, как перемещаются средства между счетами в текущей схеме.
## 1) Регистрация и увеличение лимита (`shine_users`)
### Регистрация пользователя (`create_user_pda`)
1. Плательщик: кошелек `signer` (кто отправил транзакцию).
2. Получатель комиссии: `inflow_vault` (PDA в программе `shine_payments`).
3. Сумма перевода:
- `registration_fee_lamports` из economy-конфига `shine_users`;
- плюс комиссия за `additional_limit` (по формуле через `limit_step` и `lamports_per_limit_step`).
### Увеличение лимита (`update_user_pda`)
1. Плательщик: кошелек `signer`.
2. Получатель комиссии: `inflow_vault` (тот же PDA `shine_payments`).
3. Сумма перевода:
- только комиссия за `additional_limit` (без регистрационной части).
## 2) Покупка билета (`shine_payments`)
### Покупка (`buy_ticket`, `buy_ticket_usd`, `buy_ticket_sol`)
1. Плательщик: кошелек покупателя (`signer`).
2. Получатель: `dao_wallet` (казна DAO из `ConfigState`).
3. В `inflow_vault` на этом шаге средства не зачисляются.
## 3) Шаг выплат (`shine_payments::step_payout`)
Источник выплат: `inflow_vault` (`ConfigState.inflow_vault`).
При шаге выплаты:
1. Из `inflow_vault` переводится `ticket` получателю тикета.
2. Из `inflow_vault` переводится DAO-часть в `dao_wallet`.
3. Из `inflow_vault` переводится `call_reward_lamports` вызывающему шаг.
Если очереди пусты:
1. Весь доступный остаток `inflow_vault` переводится в `dao_wallet`.
## 4) Какие адреса задаются настройками
1. `dao_wallet` — хранится в `ConfigState` (`shine_payments`), задается при `init`.
2. `inflow_vault` — PDA `shine_payments`, вычисляется по seed и program id.
3. Для `shine_users` получатель комиссии не настраивается отдельно:
- всегда используется PDA `inflow_vault` программы `shine_payments`.

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,98 +0,0 @@
# SHINE Payments v2
## Назначение
`shine_payments` v2 — контракт очереди выплат с двумя очередями:
1. обычная покупка билета (очередь 1);
2. менеджерское добавление билетов (очередь 1 и очередь 2 по лимитам от DAO);
3. пошаговые выплаты из inflow-вольта с приоритетом очереди 1.
Сейчас тестовый этап в Devnet: расчеты в SOL/lamports.
Следующий этап — модель расчета в USDT по курсу.
## PDA
1. `config_pda` (`shine_payments_v2_config`)
- `dao_wallet`
- `manager_wallet` (права на смену coef/limit)
- `inflow_vault`
- `call_reward_lamports`
2. `coef_limit_pda` (`shine_payments_v2_coef_limit`)
- `coef_ppm` (fixed-point, scale = 1_000_000)
- `limit_lamports` (лимит долга очереди 1 для обычной покупки)
3. `queues_pda` (`shine_payments_v2_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_v2_inflow_vault`)
- входящий PDA-вольт программы для выплат.
5. `ticket_pda`
- очередь 1: `shine_payments_v2_q1_ticket + index_le_u64`
- очередь 2: `shine_payments_v2_q2_ticket + index_le_u64`
- поля тикета:
- `queue_id`
- `index`
- `is_paid`
- `recipient_wallet`
- `payout_lamports`
- `debt_before_lamports`
6. `manager_allowance_pda` (`shine_payments_v2_manager_allowance + manager_pubkey`)
- `manager_wallet`
- `q1_available_lamports`
- `q2_available_lamports`
## Методы
1. `init`
- вызывается один раз (кто угодно);
- создает `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
2. `update_coef_limit` (только `manager_wallet` из config)
- меняет коэффициент и лимит покупки в очередь 1.
3. `grant_manager_limits` (только `dao_wallet` из config)
- DAO выдает/добавляет лимиты менеджеру:
- `add_q1_lamports`
- `add_q2_lamports`
- если PDA менеджера нет — создается;
- если есть — лимиты увеличиваются.
4. `buy_ticket`
- обычная покупка билета в очередь 1;
- сумма покупки идет в DAO;
- тикет получает выплату `input * coef_ppm / 1_000_000`.
5. `manager_add_ticket`
- менеджер добавляет тикет в очередь 1 или 2;
- без денежного перевода;
- списывает лимит менеджера по выбранной очереди.
6. `step_payout`
- выбирает очередь по приоритету:
1. сначала очередь 1;
2. если в 1-й нет ожидания — очередь 2.
- шаг выплаты:
- `X` получателю тикета,
- `X` в DAO,
- `reward` вызывающему.
- если обе очереди пусты/выплачены:
- переводит весь доступный остаток inflow-вольта в DAO (без reward).
## Стартовые настройки
См. `programs/shine_payments/src/settings.rs`:
- `START_COEF_PPM = 5_000_000` (коэффициент 5.0)
- `START_LIMIT_LAMPORTS = 100 SOL`
- `START_CALL_REWARD_LAMPORTS = 0.008 SOL`
- `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

@ -0,0 +1,58 @@
# Кошельки Devnet (тестовые, приватные ключи в Base58)
> Только для тестов. Не использовать в production.
>
> Балансы ниже актуальны на 2026-05-08 (Devnet), могут меняться после транзакций.
## DAO (текущий для Shine Payments)
- public: `6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY`
- private (base58): `3VYfYZZ3ugmgwisiQQAfcimX9T65AE9BmwmYVixAUj4jyneccSE9rzbC3g5twvH7ECZ8xgp7emJo3pR4yQqCwjGn`
- роль: основной DAO-кошелёк в `shine_payments` (получает DAO-часть выплат, выдаёт лимиты менеджерам)
## Manager / deploy wallet (текущий системный)
- public: `4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv`
- private (base58): `2pCe8GWpXL7hYf3tFQZg4azfPw3hm1UwvybRbJ6j5rKBvTjBme2DuWESKM5e5jd2JLw7b4D5sWno4YbxRdot4Gap`
- роль:
- upgrade authority и кошелёк деплоя программ;
- `MANAGER_WALLET` в настройках (имеет право менять `coef/limit`).
## Тестовый кошелёк key1
- public: `HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA`
- private (base58): `5pbFo9Zq1VsNheHwbEp6AZKa6R62CZHoGkJFZnugpMEtCmkQFjuUP7TgA5hSPqv4NABGmPP62qVnDPHmRqEAwvJc`
- роль: дополнительный тестовый пользователь (покупка билетов, получение выплат)
## Тестовый кошелёк key2
- public: `E3ZDHbWv1qiFvDTmaRc9wjFCgbQw6UmKJLJYbaTNvjAh`
- private (base58): `5qm1GJGXB1fFJ3YsU5Y3XXgTiQfaimqBWk79oEveFASH9D2of3jqUoT7dumBvS449fW5j5Sw8MgAMH2QBMmFPdry`
- роль: дополнительный тестовый пользователь (второй участник сценариев)
## Тестовый кошелёк phantomWallet (твой основной рабочий)
- public: `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P`
- private (base58): `5RpEoxRKSr2norQP3vEnq9XokQGh9EbGN8q8xUUVAdm1M5mTD1vMuyJPYJfViMWFf6c8qT5mj2bt64gLE2zm6VG3`
- роль:
- основной кошелёк для ручного тестирования через UI;
- можно использовать как менеджер очередей (после выдачи лимитов DAO через `grant_manager_limits`).
## testDaoCreator (Phantom-совместимый)
- public: `A9AP6TMUuxbXwR8H2xN8hA7SXddnnxRH1vhP2qwEuG2r`
- private (base58): `5JR37dCQUB4jtjSzoLp3pkGMkCoTpNLxZLXJDrGZnYCDcBgX9cmqmHVbQz2VbYgGZnG2StNSQ5cMgJf1PSa8gvpv`
- роль: резервный тестовый кошелёк (можно использовать как отдельный DAO/менеджер в экспериментах)
## Program keypair (для деплоя)
- `shine_payments`
- program id: `4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE`
- private key (base58): `3Fk2zYKTfoLMoU1tqMypGBsxj9zFF7PdXNEtodamv9aqtm8KWJNqsmT5DE9Z8pyDLCSqwdLM59LxbfdYp99b4xGg`
- роль: ключ адреса программы (не пользовательский кошелёк)
- баланс Devnet (program account): `0.00114144 SOL`
- `shine_users`
- program id: `8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ`
- private key (base58): `2Zi4zNSFv69s3PPWr2vCtySiKSxiLA37TSSvHHVTygEQrehHScPoDkjKXYjVXDAHXi1Kg6LiZvYAq1ftV6aPSdrN`
- роль: ключ адреса программы (не пользовательский кошелёк)
- баланс Devnet (program account): `0.00114144 SOL`

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

@ -0,0 +1 @@
[112,191,53,183,50,74,71,149,251,216,158,157,67,198,143,219,67,188,22,43,49,6,18,31,92,254,78,219,18,245,187,50,50,133,54,215,248,187,162,135,70,192,63,182,33,87,44,169,230,248,155,7,216,43,188,126,253,71,54,123,202,174,140,97]

View File

@ -0,0 +1 @@
[78,57,36,25,42,130,147,16,232,65,139,10,4,54,133,0,204,50,65,112,18,99,223,142,226,60,119,36,253,192,185,121,112,54,249,197,203,152,82,234,162,32,176,193,110,19,217,20,149,116,215,16,153,121,28,243,143,248,181,55,186,250,95,103]

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

3021
shine/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,16 +5,23 @@
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
}, },
"dependencies": { "dependencies": {
"@coral-xyz/anchor": "^0.31.1" "@coral-xyz/anchor": "^0.31.1",
"@metaplex-foundation/mpl-token-metadata": "^3.4.0",
"@metaplex-foundation/mpl-toolbox": "^0.10.0",
"@metaplex-foundation/umi": "^1.5.1",
"@metaplex-foundation/umi-bundle-defaults": "^1.5.1",
"@metaplex-foundation/umi-web3js-adapters": "^1.5.1",
"@solana/spl-token": "^0.4.14",
"@solana/spl-governance": "^0.3.28"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.3.4",
"mocha": "^9.0.3",
"ts-mocha": "^10.0.0",
"@types/bn.js": "^5.1.0", "@types/bn.js": "^5.1.0",
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
"@types/mocha": "^9.0.0", "@types/mocha": "^9.0.0",
"typescript": "^5.7.3", "chai": "^4.3.4",
"prettier": "^2.6.2" "mocha": "^9.0.3",
"prettier": "^2.6.2",
"ts-mocha": "^10.0.0",
"typescript": "^5.7.3"
} }
} }

View File

@ -0,0 +1,40 @@
//! Единый деплой-конфиг проекта SHINE.
//! Здесь хранятся адреса и параметры, которые зависят от окружения деплоя.
// =========================
// Program IDs
// =========================
/// `SHINE_PAYMENTS_PROGRAM_ID` — адрес программы `shine_payments` для текущего окружения.
pub const SHINE_PAYMENTS_PROGRAM_ID: &str = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR";
/// `SHINE_USERS_PROGRAM_ID` — адрес программы `shine_users` для текущего окружения.
pub const SHINE_USERS_PROGRAM_ID: &str = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
// =========================
// DAO / роли управления
// =========================
/// `DAO_AUTHORITY` — кошелек DAO/управления, который имеет право менять защищенные настройки.
pub const DAO_AUTHORITY: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
/// `DAO_TREASURY_WALLET` — кошелек казны DAO для поступления DAO-части выплат в `shine_payments`.
pub const DAO_TREASURY_WALLET: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
// =========================
// Комиссии / получатели
// =========================
/// `REGISTRATION_FEE_RECEIVER` — кошелек получателя комиссии за регистрацию в `shine_users`.
pub const REGISTRATION_FEE_RECEIVER: &str = "9vXFoN9ngfN1gpqQ3HT5n3y9Wp2r7HnSQckirgwVwWwb";
// =========================
// Оракул (Pyth SOL/USD)
// =========================
/// `PYTH_SOL_USD_FEED_ID` — feed id Pyth для пары SOL/USD (используется для проверки feed внутри аккаунта).
pub const PYTH_SOL_USD_FEED_ID: &str =
"0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
/// `PYTH_SOL_USD_ACCOUNT` — адрес Solana-аккаунта обновлений цены Pyth для SOL/USD.
pub const PYTH_SOL_USD_ACCOUNT: &str = "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE";

View File

@ -1 +1,2 @@
pub mod utils; pub mod utils;
pub mod deploy_config;

View File

@ -1,23 +1,14 @@
use anchor_lang::prelude::*; use anchor_lang::prelude::*;
use anchor_lang::solana_program::{ use anchor_lang::solana_program::{program::invoke_signed, system_instruction};
program::invoke_signed,
system_instruction,
system_program
};
/// сдесь коды всех ошибок
/// сдесь коды всех ошибок
#[error_code] #[error_code]
pub enum ErrCode { pub enum ErrCode {
/// Система уже инициализирована и не может быть инициализирована повторно! /// Система уже инициализирована и не может быть инициализирована повторно!
#[msg("Система уже инициализирована и не может быть инициализирована повторно!")] #[msg("Система уже инициализирована и не может быть инициализирована повторно!")]
SystemAlreadyInitialized = 1000, SystemAlreadyInitialized = 1000,
#[msg("PDA не содержит данных или не инициализирован")] #[msg("PDA не содержит данных или не инициализирован")]
EmptyPdaData = 1002, EmptyPdaData = 1002,
@ -40,7 +31,6 @@ pub enum ErrCode {
#[msg("PDA-аккаунт уже существует и не может быть создан повторно.")] #[msg("PDA-аккаунт уже существует и не может быть создан повторно.")]
PdaAlreadyExists = 1009, PdaAlreadyExists = 1009,
#[msg("Подписавший не совпадает с ожидаемым пользователем (это потому что пока временно можно регистрировать пользователя с другово аккаунта")] #[msg("Подписавший не совпадает с ожидаемым пользователем (это потому что пока временно можно регистрировать пользователя с другово аккаунта")]
InvalidSigner = 1005, InvalidSigner = 1005,
@ -86,29 +76,8 @@ pub enum ErrCode {
#[msg("Невалидная magic-сигнатура записи")] #[msg("Невалидная magic-сигнатура записи")]
InvalidRecordMagic = 1025, InvalidRecordMagic = 1025,
} }
///---------------------------------------------------------------------------------------------------------- ///----------------------------------------------------------------------------------------------------------
/// Базовые функции для работы с PDA /// Базовые функции для работы с PDA
///---------------------------------------------------------------------------------------------------------- ///----------------------------------------------------------------------------------------------------------
@ -137,7 +106,7 @@ pub fn create_and_write_pda<'info>(
msg!("Создаём PDA с размером {} байт", space); msg!("Создаём PDA с размером {} байт", space);
let space = space; //+ 128; // Добавляется запас под метаданные let space = space; //+ 128; // Добавляется запас под метаданные
// Вычисляем необходимую арендную плату // Вычисляем необходимую арендную плату
let lamports = Rent::get()?.minimum_balance(space as usize); let lamports = Rent::get()?.minimum_balance(space as usize);
// Формируем инструкцию // Формируем инструкцию
@ -152,11 +121,7 @@ pub fn create_and_write_pda<'info>(
// Выполняем инструкцию с подписью от PDA // Выполняем инструкцию с подписью от PDA
invoke_signed( invoke_signed(
&create_instr, &create_instr,
&[ &[signer.clone(), pda_account.clone(), system_program.clone()],
signer.clone(),
pda_account.clone(),
system_program.clone(),
],
&[&seeds], &[&seeds],
)?; )?;
} }
@ -177,9 +142,6 @@ pub fn create_and_write_pda<'info>(
Ok(()) Ok(())
} }
/// Создаёт PDA аккаунт (если его ещё нет). /// Создаёт PDA аккаунт (если его ещё нет).
/// ///
/// ⚠️ Если аккаунт уже существует, выбрасывается ошибка. /// ⚠️ Если аккаунт уже существует, выбрасывается ошибка.
@ -221,22 +183,18 @@ pub fn create_pda<'info>(
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// 3. Создаём инструкцию system_program для создания аккаунта // 3. Создаём инструкцию system_program для создания аккаунта
let create_instr = system_instruction::create_account( let create_instr = system_instruction::create_account(
signer.key, // от имени кого signer.key, // от имени кого
pda_account.key, // для какого PDA pda_account.key, // для какого PDA
lamports, // сколько лампортов перевести lamports, // сколько лампортов перевести
full_space, // сколько байт выделить full_space, // сколько байт выделить
program_id, // кто будет владельцем PDA program_id, // кто будет владельцем PDA
); );
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// 4. Выполняем инструкцию с подписью PDA (через сиды) // 4. Выполняем инструкцию с подписью PDA (через сиды)
invoke_signed( invoke_signed(
&create_instr, &create_instr,
&[ &[signer.clone(), pda_account.clone(), system_program.clone()],
signer.clone(),
pda_account.clone(),
system_program.clone(),
],
&[&seeds], // PDA сиды → для подписи &[&seeds], // PDA сиды → для подписи
)?; )?;
@ -251,10 +209,7 @@ pub fn create_pda<'info>(
/// Аргументы: /// Аргументы:
/// - `pda_account`: аккаунт, в который пишем (должен быть mut) /// - `pda_account`: аккаунт, в который пишем (должен быть mut)
/// - `data`: бинарный массив, который нужно записать /// - `data`: бинарный массив, который нужно записать
pub fn write_to_pda<'info>( pub fn write_to_pda<'info>(pda_account: &AccountInfo<'info>, data: &[u8]) -> Result<()> {
pda_account: &AccountInfo<'info>,
data: &[u8],
) -> Result<()> {
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// 1. Получаем доступ к данным PDA (на запись) // 1. Получаем доступ к данным PDA (на запись)
let mut account_data = pda_account.try_borrow_mut_data()?; let mut account_data = pda_account.try_borrow_mut_data()?;
@ -274,15 +229,6 @@ pub fn write_to_pda<'info>(
Ok(()) Ok(())
} }
/// ------------------------------------------------------------------------ /// ------------------------------------------------------------------------
/// safe_read_pda «безопасное чтение PDA» /// safe_read_pda «безопасное чтение PDA»
/// ------------------------------------------------------------------------ /// ------------------------------------------------------------------------
@ -300,7 +246,7 @@ pub fn write_to_pda<'info>(
/// - Успех → копируем их в Vec и возвращаем. /// - Успех → копируем их в Vec и возвращаем.
/// - Ошибка (например, конфликт borrow) → логируем и возвращаем пустой Vec. /// - Ошибка (например, конфликт borrow) → логируем и возвращаем пустой Vec.
/// ///
/// пример использования /// пример использования
/// let raw_bytes = safe_read_pda(&ctx.accounts.readonly_pda); /// let raw_bytes = safe_read_pda(&ctx.accounts.readonly_pda);
/// require!(!raw_bytes.is_empty(), ErrCode::EmptyPdaData); /// require!(!raw_bytes.is_empty(), ErrCode::EmptyPdaData);
/// msg!("Размер считанных данных: {}", raw_bytes.len()); /// msg!("Размер считанных данных: {}", raw_bytes.len());
@ -333,17 +279,15 @@ pub fn safe_read_pda<'info>(pda_account: &AccountInfo<'info>) -> Vec<u8> {
} }
Err(e) => { Err(e) => {
// Ошибка при borrow (например, уже есть активное мутабельное заимствование) // Ошибка при borrow (например, уже есть активное мутабельное заимствование)
msg!("safe_read_pda: ошибка borrow_data ({:?}) возвращаем пустой массив", e); msg!(
"safe_read_pda: ошибка borrow_data ({:?}) возвращаем пустой массив",
e
);
Vec::new() Vec::new()
} }
} }
} }
/// ------------------------------------------------------------------------ /// ------------------------------------------------------------------------
/// delete_pda_with_assign — закрыть PDA, вернуть ренту и освободить адрес /// delete_pda_with_assign — закрыть PDA, вернуть ренту и освободить адрес
/// ------------------------------------------------------------------------ /// ------------------------------------------------------------------------
@ -373,7 +317,10 @@ pub fn delete_pda_return_rent<'info>(
program_id: &Pubkey, program_id: &Pubkey,
) -> Result<()> { ) -> Result<()> {
// 0) проверки // 0) проверки
require!(pda_account.owner != &Pubkey::default(), ErrCode::EmptyPdaData); require!(
pda_account.owner != &Pubkey::default(),
ErrCode::EmptyPdaData
);
require!(pda_account.owner == program_id, ErrCode::InvalidPdaAddress); require!(pda_account.owner == program_id, ErrCode::InvalidPdaAddress);
// 1) Переложить все лампорты с PDA на получателя (мы владелец, это разрешено) // 1) Переложить все лампорты с PDA на получателя (мы владелец, это разрешено)
@ -389,7 +336,9 @@ pub fn delete_pda_return_rent<'info>(
// 2) Нулим данные (если были) // 2) Нулим данные (если были)
if !pda_account.data_is_empty() { if !pda_account.data_is_empty() {
let mut data = pda_account.try_borrow_mut_data()?; let mut data = pda_account.try_borrow_mut_data()?;
for b in data.iter_mut() { *b = 0; } for b in data.iter_mut() {
*b = 0;
}
} }
// 3) Сжать до 0 байт // 3) Сжать до 0 байт
@ -399,4 +348,3 @@ pub fn delete_pda_return_rent<'info>(
msg!("PDA закрыт: рента отправлена на {}", recipient.key); msg!("PDA закрыт: рента отправлена на {}", recipient.key);
Ok(()) Ok(())
} }

View File

@ -14,6 +14,7 @@ bench = false
[dependencies] [dependencies]
anchor-lang = "0.31.1" anchor-lang = "0.31.1"
common = { path = "../common" } common = { path = "../common" }
pyth-solana-receiver-sdk = { path = "../../.vendor/pyth-crosschain/target_chains/solana/pyth_solana_receiver_sdk" }
[features] [features]
default = [] default = []

View File

@ -1,11 +1,13 @@
use anchor_lang::prelude::*; use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke, system_instruction}; use anchor_lang::solana_program::{program::invoke, system_instruction};
use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode}; use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use pyth_solana_receiver_sdk::error::GetPriceError;
use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, Price, PriceUpdateV2};
use std::str::FromStr; use std::str::FromStr;
pub mod settings; pub mod settings;
declare_id!("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"); declare_id!("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
#[program] #[program]
pub mod shine_payments { pub mod shine_payments {
@ -32,17 +34,12 @@ pub mod shine_payments {
let dao_wallet = Pubkey::from_str(settings::DAO_WALLET) let dao_wallet = Pubkey::from_str(settings::DAO_WALLET)
.map_err(|_| error!(PaymentsError::InvalidSettingsWallet))?; .map_err(|_| error!(PaymentsError::InvalidSettingsWallet))?;
let manager_wallet = Pubkey::from_str(settings::MANAGER_WALLET)
.map_err(|_| error!(PaymentsError::InvalidSettingsWallet))?;
let system_program_ai = ctx.accounts.system_program.to_account_info(); let system_program_ai = ctx.accounts.system_program.to_account_info();
let config = ConfigState { let config = ConfigState {
version: 1, version: 1,
dao_wallet, dao_wallet,
manager_wallet,
inflow_vault: ctx.accounts.inflow_vault_pda.key(), inflow_vault: ctx.accounts.inflow_vault_pda.key(),
call_reward_lamports: settings::START_CALL_REWARD_LAMPORTS,
}; };
create_and_store_state( create_and_store_state(
ctx.program_id, ctx.program_id,
@ -57,7 +54,8 @@ pub mod shine_payments {
let coef_limit = CoefLimitState { let coef_limit = CoefLimitState {
version: 1, version: 1,
coef_ppm: settings::START_COEF_PPM, coef_ppm: settings::START_COEF_PPM,
limit_lamports: settings::START_LIMIT_LAMPORTS, limit_usd_cents: settings::START_LIMIT_USD_CENTS,
call_reward_lamports: settings::START_CALL_REWARD_LAMPORTS,
}; };
create_and_store_state( create_and_store_state(
ctx.program_id, ctx.program_id,
@ -73,12 +71,12 @@ pub mod shine_payments {
version: 1, version: 1,
q1_tickets_total: 0, q1_tickets_total: 0,
q1_tickets_paid: 0, q1_tickets_paid: 0,
q1_sum_total: 0, q1_sum_total_usd_cents: 0,
q1_sum_paid: 0, q1_sum_paid_usd_cents: 0,
q2_tickets_total: 0, q2_tickets_total: 0,
q2_tickets_paid: 0, q2_tickets_paid: 0,
q2_sum_total: 0, q2_sum_total_usd_cents: 0,
q2_sum_paid: 0, q2_sum_paid_usd_cents: 0,
}; };
create_and_store_state( create_and_store_state(
ctx.program_id, ctx.program_id,
@ -104,19 +102,27 @@ pub mod shine_payments {
Ok(()) Ok(())
} }
pub fn update_coef_limit(ctx: Context<UpdateCoefLimit>, args: UpdateCoefLimitArgs) -> Result<()> { pub fn update_coef_limit(
ctx: Context<UpdateCoefLimit>,
args: UpdateCoefLimitArgs,
) -> Result<()> {
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?; let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
require_keys_eq!( require_keys_eq!(
config.manager_wallet, config.dao_wallet,
ctx.accounts.signer.key(), ctx.accounts.signer.key(),
PaymentsError::UnauthorizedManager PaymentsError::UnauthorizedDao
); );
require!(args.coef_ppm > 0, PaymentsError::InvalidCoefficient); require!(args.coef_ppm > 0, PaymentsError::InvalidCoefficient);
require!(args.limit_lamports > 0, PaymentsError::InvalidLimit); require!(args.limit_usd_cents > 0, PaymentsError::InvalidLimit);
require!(
args.call_reward_lamports <= settings::MAX_CALL_REWARD_LAMPORTS,
PaymentsError::InvalidCallReward
);
let mut coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?; let mut coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
coef_limit.coef_ppm = args.coef_ppm; coef_limit.coef_ppm = args.coef_ppm;
coef_limit.limit_lamports = args.limit_lamports; coef_limit.limit_usd_cents = args.limit_usd_cents;
coef_limit.call_reward_lamports = args.call_reward_lamports;
write_state(&ctx.accounts.coef_limit_pda, &coef_limit)?; write_state(&ctx.accounts.coef_limit_pda, &coef_limit)?;
Ok(()) Ok(())
} }
@ -132,7 +138,7 @@ pub mod shine_payments {
PaymentsError::UnauthorizedDao PaymentsError::UnauthorizedDao
); );
require!( require!(
args.add_q1_lamports > 0 || args.add_q2_lamports > 0, args.add_q1_usd_cents > 0 || args.add_q2_usd_cents > 0,
PaymentsError::InvalidAmount PaymentsError::InvalidAmount
); );
@ -148,8 +154,8 @@ pub mod shine_payments {
let initial = ManagerAllowanceState { let initial = ManagerAllowanceState {
version: 1, version: 1,
manager_wallet: args.manager_wallet, manager_wallet: args.manager_wallet,
q1_available_lamports: 0, q1_available_usd_cents: 0,
q2_available_lamports: 0, q2_available_usd_cents: 0,
}; };
create_state_with_seeds( create_state_with_seeds(
ctx.program_id, ctx.program_id,
@ -174,113 +180,82 @@ pub mod shine_payments {
PaymentsError::InvalidManagerWallet PaymentsError::InvalidManagerWallet
); );
state.q1_available_lamports = state state.q1_available_usd_cents = state
.q1_available_lamports .q1_available_usd_cents
.checked_add(args.add_q1_lamports) .checked_add(args.add_q1_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
state.q2_available_lamports = state state.q2_available_usd_cents = state
.q2_available_lamports .q2_available_usd_cents
.checked_add(args.add_q2_lamports) .checked_add(args.add_q2_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
write_state(&ctx.accounts.manager_allowance_pda, &state)?; write_state(&ctx.accounts.manager_allowance_pda, &state)?;
Ok(()) Ok(())
} }
pub fn buy_ticket(ctx: Context<BuyTicket>, args: BuyTicketArgs) -> Result<()> { pub fn buy_ticket(ctx: Context<BuyTicket>, args: BuyTicketArgs) -> Result<()> {
require!(args.amount_lamports > 0, PaymentsError::InvalidAmount); let sol_usd = read_sol_usd_price(
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?; &ctx.accounts.sol_usd_price_update,
let coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?; &ctx.accounts.sol_usd_price_update.key(),
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?; )?;
let purchase_usd_cents = lamports_to_usd_cents_floor(args.amount_lamports, &sol_usd)?;
require_keys_eq!( require!(purchase_usd_cents > 0, PaymentsError::InvalidAmount);
ctx.accounts.dao_wallet.key(), buy_ticket_by_purchase_usd(
config.dao_wallet, &ctx,
PaymentsError::InvalidDaoWallet purchase_usd_cents,
);
let current_debt = queues
.q1_sum_total
.checked_sub(queues.q1_sum_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
require!(
current_debt < coef_limit.limit_lamports,
PaymentsError::QueueTemporarilyPaused
);
let ticket_index = queues
.q1_tickets_total
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?;
let (expected_ticket_pda, ticket_bump) = find_ticket_pda(ctx.program_id, 1, ticket_index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.ticket_pda.key(),
ErrCode::InvalidPdaAddress
);
require!(
ctx.accounts.ticket_pda.owner == &Pubkey::default(),
ErrCode::PdaAlreadyExists
);
let payout_lamports = args
.amount_lamports
.checked_mul(coef_limit.coef_ppm)
.ok_or(error!(ErrCode::MathOverflow))?
/ settings::COEF_SCALE_PPM;
require!(payout_lamports > 0, PaymentsError::InvalidPayoutAmount);
let ix = system_instruction::transfer(
ctx.accounts.signer.key,
ctx.accounts.dao_wallet.key,
args.amount_lamports, args.amount_lamports,
args.recipient_wallet,
)
}
pub fn buy_ticket_usd(ctx: Context<BuyTicket>, args: BuyTicketUsdArgs) -> Result<()> {
require!(args.amount_usd_cents > 0, PaymentsError::InvalidAmount);
require!(args.max_pay_lamports > 0, PaymentsError::InvalidAmount);
let sol_usd = read_sol_usd_price(
&ctx.accounts.sol_usd_price_update,
&ctx.accounts.sol_usd_price_update.key(),
)?;
let pay_lamports = usd_cents_to_lamports_ceil(args.amount_usd_cents, &sol_usd)?;
require!(pay_lamports > 0, PaymentsError::InvalidAmount);
require!(
pay_lamports <= args.max_pay_lamports,
PaymentsError::SlippageExceeded
); );
invoke(
&ix,
&[
ctx.accounts.signer.clone(),
ctx.accounts.dao_wallet.clone(),
ctx.accounts.system_program.to_account_info(),
],
)?;
let ticket = TicketState { buy_ticket_by_purchase_usd(&ctx, args.amount_usd_cents, pay_lamports, args.recipient_wallet)
version: 1, }
queue_id: 1,
index: ticket_index,
is_paid: false,
recipient_wallet: args.recipient_wallet,
payout_lamports,
debt_before_lamports: current_debt,
};
create_state_with_seeds(
ctx.program_id,
&ctx.accounts.signer,
&ctx.accounts.system_program.to_account_info(),
&ctx.accounts.ticket_pda,
&[
settings::Q1_TICKET_SEED,
&ticket_index.to_le_bytes(),
&[ticket_bump],
],
settings::TICKET_SPACE,
&ticket,
)?;
queues.q1_tickets_total = ticket_index; pub fn buy_ticket_sol(ctx: Context<BuyTicket>, args: BuyTicketSolArgs) -> Result<()> {
queues.q1_sum_total = queues require!(args.amount_lamports > 0, PaymentsError::InvalidAmount);
.q1_sum_total
.checked_add(payout_lamports) let sol_usd = read_sol_usd_price(
.ok_or(error!(ErrCode::MathOverflow))?; &ctx.accounts.sol_usd_price_update,
write_state(&ctx.accounts.queues_pda, &queues)?; &ctx.accounts.sol_usd_price_update.key(),
Ok(()) )?;
let purchase_usd_cents = lamports_to_usd_cents_floor(args.amount_lamports, &sol_usd)?;
require!(purchase_usd_cents > 0, PaymentsError::InvalidAmount);
require!(
purchase_usd_cents >= args.min_expected_usd_cents,
PaymentsError::SlippageExceeded
);
buy_ticket_by_purchase_usd(
&ctx,
purchase_usd_cents,
args.amount_lamports,
args.recipient_wallet,
)
} }
pub fn manager_add_ticket( pub fn manager_add_ticket(
ctx: Context<ManagerAddTicket>, ctx: Context<ManagerAddTicket>,
args: ManagerAddTicketArgs, args: ManagerAddTicketArgs,
) -> Result<()> { ) -> Result<()> {
require!(args.payout_lamports > 0, PaymentsError::InvalidPayoutAmount); require!(args.payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount);
require!(args.queue_id == 1 || args.queue_id == 2, PaymentsError::InvalidTicketQueue); require!(
args.queue_id == 1 || args.queue_id == 2,
PaymentsError::InvalidTicketQueue
);
let (expected_manager_pda, _) = let (expected_manager_pda, _) =
find_manager_allowance_pda(ctx.program_id, ctx.accounts.signer.key); find_manager_allowance_pda(ctx.program_id, ctx.accounts.signer.key);
@ -289,7 +264,8 @@ pub mod shine_payments {
ctx.accounts.manager_allowance_pda.key(), ctx.accounts.manager_allowance_pda.key(),
ErrCode::InvalidPdaAddress ErrCode::InvalidPdaAddress
); );
let mut allowance = read_state::<ManagerAllowanceState>(&ctx.accounts.manager_allowance_pda)?; let mut allowance =
read_state::<ManagerAllowanceState>(&ctx.accounts.manager_allowance_pda)?;
require_keys_eq!( require_keys_eq!(
allowance.manager_wallet, allowance.manager_wallet,
ctx.accounts.signer.key(), ctx.accounts.signer.key(),
@ -297,26 +273,20 @@ pub mod shine_payments {
); );
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?; let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
let current_debt = if args.queue_id == 1 { let debt_before_total = if args.queue_id == 1 {
queues queues.q1_sum_total_usd_cents
.q1_sum_total
.checked_sub(queues.q1_sum_paid)
.ok_or(error!(ErrCode::MathOverflow))?
} else { } else {
queues queues.q2_sum_total_usd_cents
.q2_sum_total
.checked_sub(queues.q2_sum_paid)
.ok_or(error!(ErrCode::MathOverflow))?
}; };
if args.queue_id == 1 { if args.queue_id == 1 {
require!( require!(
allowance.q1_available_lamports >= args.payout_lamports, allowance.q1_available_usd_cents >= args.payout_usd_cents,
PaymentsError::ManagerLimitExceeded PaymentsError::ManagerLimitExceeded
); );
} else { } else {
require!( require!(
allowance.q2_available_lamports >= args.payout_lamports, allowance.q2_available_usd_cents >= args.payout_usd_cents,
PaymentsError::ManagerLimitExceeded PaymentsError::ManagerLimitExceeded
); );
} }
@ -350,8 +320,8 @@ pub mod shine_payments {
index: ticket_index, index: ticket_index,
is_paid: false, is_paid: false,
recipient_wallet: args.recipient_wallet, recipient_wallet: args.recipient_wallet,
payout_lamports: args.payout_lamports, payout_usd_cents: args.payout_usd_cents,
debt_before_lamports: current_debt, debt_before_usd_cents: debt_before_total,
}; };
let seed_prefix = if args.queue_id == 1 { let seed_prefix = if args.queue_id == 1 {
settings::Q1_TICKET_SEED settings::Q1_TICKET_SEED
@ -369,24 +339,24 @@ pub mod shine_payments {
)?; )?;
if args.queue_id == 1 { if args.queue_id == 1 {
allowance.q1_available_lamports = allowance allowance.q1_available_usd_cents = allowance
.q1_available_lamports .q1_available_usd_cents
.checked_sub(args.payout_lamports) .checked_sub(args.payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
queues.q1_tickets_total = ticket_index; queues.q1_tickets_total = ticket_index;
queues.q1_sum_total = queues queues.q1_sum_total_usd_cents = queues
.q1_sum_total .q1_sum_total_usd_cents
.checked_add(args.payout_lamports) .checked_add(args.payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
} else { } else {
allowance.q2_available_lamports = allowance allowance.q2_available_usd_cents = allowance
.q2_available_lamports .q2_available_usd_cents
.checked_sub(args.payout_lamports) .checked_sub(args.payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
queues.q2_tickets_total = ticket_index; queues.q2_tickets_total = ticket_index;
queues.q2_sum_total = queues queues.q2_sum_total_usd_cents = queues
.q2_sum_total .q2_sum_total_usd_cents
.checked_add(args.payout_lamports) .checked_add(args.payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
} }
write_state(&ctx.accounts.manager_allowance_pda, &allowance)?; write_state(&ctx.accounts.manager_allowance_pda, &allowance)?;
@ -396,6 +366,7 @@ pub mod shine_payments {
pub fn step_payout(ctx: Context<StepPayout>) -> Result<()> { pub fn step_payout(ctx: Context<StepPayout>) -> Result<()> {
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?; let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
let coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?; let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
let _vault_state = read_state::<VaultState>(&ctx.accounts.inflow_vault_pda)?; let _vault_state = read_state::<VaultState>(&ctx.accounts.inflow_vault_pda)?;
@ -451,7 +422,10 @@ pub mod shine_payments {
ticket.queue_id == target_queue, ticket.queue_id == target_queue,
PaymentsError::InvalidTicketQueue PaymentsError::InvalidTicketQueue
); );
require!(ticket.index == next_index, PaymentsError::InvalidTicketIndex); require!(
ticket.index == next_index,
PaymentsError::InvalidTicketIndex
);
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid); require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
require_keys_eq!( require_keys_eq!(
ctx.accounts.ticket_recipient_wallet.key(), ctx.accounts.ticket_recipient_wallet.key(),
@ -459,10 +433,21 @@ pub mod shine_payments {
PaymentsError::InvalidTicketRecipient PaymentsError::InvalidTicketRecipient
); );
let needed = ticket let sol_usd = read_sol_usd_price(
.payout_lamports &ctx.accounts.sol_usd_price_update,
.checked_add(ticket.payout_lamports) &ctx.accounts.sol_usd_price_update.key(),
.and_then(|v| v.checked_add(config.call_reward_lamports)) )?;
let ticket_lamports = usd_cents_to_lamports_ceil(ticket.payout_usd_cents, &sol_usd)?;
let dao_multiplier: u64 = if target_queue == 1 { 1 } else { 2 };
let dao_usd_cents = ticket
.payout_usd_cents
.checked_mul(dao_multiplier)
.ok_or(error!(ErrCode::MathOverflow))?;
let dao_lamports = usd_cents_to_lamports_ceil(dao_usd_cents, &sol_usd)?;
let needed = ticket_lamports
.checked_add(dao_lamports)
.and_then(|v| v.checked_add(coef_limit.call_reward_lamports))
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
require!( require!(
available_vault_lamports(&ctx.accounts.inflow_vault_pda)? >= needed, available_vault_lamports(&ctx.accounts.inflow_vault_pda)? >= needed,
@ -472,17 +457,17 @@ pub mod shine_payments {
transfer_from_vault( transfer_from_vault(
&ctx.accounts.inflow_vault_pda, &ctx.accounts.inflow_vault_pda,
&ctx.accounts.ticket_recipient_wallet, &ctx.accounts.ticket_recipient_wallet,
ticket.payout_lamports, ticket_lamports,
)?; )?;
transfer_from_vault( transfer_from_vault(
&ctx.accounts.inflow_vault_pda, &ctx.accounts.inflow_vault_pda,
&ctx.accounts.dao_wallet, &ctx.accounts.dao_wallet,
ticket.payout_lamports, dao_lamports,
)?; )?;
transfer_from_vault( transfer_from_vault(
&ctx.accounts.inflow_vault_pda, &ctx.accounts.inflow_vault_pda,
&ctx.accounts.signer, &ctx.accounts.signer,
config.call_reward_lamports, coef_limit.call_reward_lamports,
)?; )?;
ticket.is_paid = true; ticket.is_paid = true;
@ -493,24 +478,79 @@ pub mod shine_payments {
.q1_tickets_paid .q1_tickets_paid
.checked_add(1) .checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
queues.q1_sum_paid = queues queues.q1_sum_paid_usd_cents = queues
.q1_sum_paid .q1_sum_paid_usd_cents
.checked_add(ticket.payout_lamports) .checked_add(ticket.payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
} else { } else {
queues.q2_tickets_paid = queues queues.q2_tickets_paid = queues
.q2_tickets_paid .q2_tickets_paid
.checked_add(1) .checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
queues.q2_sum_paid = queues queues.q2_sum_paid_usd_cents = queues
.q2_sum_paid .q2_sum_paid_usd_cents
.checked_add(ticket.payout_lamports) .checked_add(ticket.payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
} }
write_state(&ctx.accounts.queues_pda, &queues)?; write_state(&ctx.accounts.queues_pda, &queues)?;
Ok(()) 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)] #[derive(Accounts)]
@ -535,7 +575,7 @@ pub struct Init<'info> {
#[derive(Accounts)] #[derive(Accounts)]
pub struct UpdateCoefLimit<'info> { pub struct UpdateCoefLimit<'info> {
/// CHECK: подписант-менеджер, проверяется атрибутом `signer` и сверкой адреса в коде. /// CHECK: подписант-DAO, проверяется атрибутом `signer` и сверкой адреса в коде.
#[account(mut, signer)] #[account(mut, signer)]
pub signer: AccountInfo<'info>, pub signer: AccountInfo<'info>,
/// CHECK: PDA конфига, читается и валидируется вручную. /// CHECK: PDA конфига, читается и валидируется вручную.
@ -580,6 +620,7 @@ pub struct BuyTicket<'info> {
/// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную. /// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную.
#[account(mut)] #[account(mut)]
pub dao_wallet: AccountInfo<'info>, pub dao_wallet: AccountInfo<'info>,
pub sol_usd_price_update: Account<'info, PriceUpdateV2>,
pub system_program: Program<'info, System>, pub system_program: Program<'info, System>,
} }
@ -611,6 +652,9 @@ pub struct StepPayout<'info> {
/// CHECK: PDA очередей, читается и валидируется вручную. /// CHECK: PDA очередей, читается и валидируется вручную.
#[account(mut)] #[account(mut)]
pub queues_pda: AccountInfo<'info>, pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA коэффициента/лимита/награды, читается и валидируется вручную.
#[account(mut)]
pub coef_limit_pda: AccountInfo<'info>,
/// CHECK: PDA inflow-вольта, адрес сверяется с конфигом вручную. /// CHECK: PDA inflow-вольта, адрес сверяется с конфигом вручную.
#[account(mut)] #[account(mut)]
pub inflow_vault_pda: AccountInfo<'info>, pub inflow_vault_pda: AccountInfo<'info>,
@ -623,19 +667,34 @@ pub struct StepPayout<'info> {
/// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную. /// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную.
#[account(mut)] #[account(mut)]
pub dao_wallet: AccountInfo<'info>, pub dao_wallet: AccountInfo<'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)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct UpdateCoefLimitArgs { pub struct UpdateCoefLimitArgs {
pub coef_ppm: u64, pub coef_ppm: u64,
pub limit_lamports: u64, pub limit_usd_cents: u64,
pub call_reward_lamports: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct GrantManagerLimitsArgs { pub struct GrantManagerLimitsArgs {
pub manager_wallet: Pubkey, pub manager_wallet: Pubkey,
pub add_q1_lamports: u64, pub add_q1_usd_cents: u64,
pub add_q2_lamports: u64, pub add_q2_usd_cents: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
@ -644,27 +703,45 @@ pub struct BuyTicketArgs {
pub recipient_wallet: Pubkey, pub recipient_wallet: Pubkey,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct BuyTicketUsdArgs {
pub amount_usd_cents: u64,
pub max_pay_lamports: u64,
pub recipient_wallet: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct BuyTicketSolArgs {
pub amount_lamports: u64,
pub min_expected_usd_cents: u64,
pub recipient_wallet: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ManagerAddTicketArgs { pub struct ManagerAddTicketArgs {
pub queue_id: u8, pub queue_id: u8,
pub recipient_wallet: Pubkey, pub recipient_wallet: Pubkey,
pub payout_lamports: u64, pub payout_usd_cents: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ChangeTicketRecipientArgs {
pub new_recipient_wallet: Pubkey,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ConfigState { pub struct ConfigState {
pub version: u8, pub version: u8,
pub dao_wallet: Pubkey, pub dao_wallet: Pubkey,
pub manager_wallet: Pubkey,
pub inflow_vault: Pubkey, pub inflow_vault: Pubkey,
pub call_reward_lamports: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct CoefLimitState { pub struct CoefLimitState {
pub version: u8, pub version: u8,
pub coef_ppm: u64, pub coef_ppm: u64,
pub limit_lamports: u64, pub limit_usd_cents: u64,
pub call_reward_lamports: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
@ -672,12 +749,12 @@ pub struct QueuesState {
pub version: u8, pub version: u8,
pub q1_tickets_total: u64, pub q1_tickets_total: u64,
pub q1_tickets_paid: u64, pub q1_tickets_paid: u64,
pub q1_sum_total: u64, pub q1_sum_total_usd_cents: u64,
pub q1_sum_paid: u64, pub q1_sum_paid_usd_cents: u64,
pub q2_tickets_total: u64, pub q2_tickets_total: u64,
pub q2_tickets_paid: u64, pub q2_tickets_paid: u64,
pub q2_sum_total: u64, pub q2_sum_total_usd_cents: u64,
pub q2_sum_paid: u64, pub q2_sum_paid_usd_cents: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
@ -687,16 +764,16 @@ pub struct TicketState {
pub index: u64, pub index: u64,
pub is_paid: bool, pub is_paid: bool,
pub recipient_wallet: Pubkey, pub recipient_wallet: Pubkey,
pub payout_lamports: u64, pub payout_usd_cents: u64,
pub debt_before_lamports: u64, pub debt_before_usd_cents: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ManagerAllowanceState { pub struct ManagerAllowanceState {
pub version: u8, pub version: u8,
pub manager_wallet: Pubkey, pub manager_wallet: Pubkey,
pub q1_available_lamports: u64, pub q1_available_usd_cents: u64,
pub q2_available_lamports: u64, pub q2_available_usd_cents: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
@ -704,6 +781,11 @@ pub struct VaultState {
pub version: u8, pub version: u8,
} }
struct SolUsdPrice {
price_num: u128,
price_den: u128,
}
#[error_code] #[error_code]
pub enum PaymentsError { pub enum PaymentsError {
#[msg("Ошибка в адресах кошельков из настроек программы")] #[msg("Ошибка в адресах кошельков из настроек программы")]
@ -722,6 +804,8 @@ pub enum PaymentsError {
InvalidCoefficient, InvalidCoefficient,
#[msg("Некорректный лимит")] #[msg("Некорректный лимит")]
InvalidLimit, InvalidLimit,
#[msg("Некорректная награда за шаг выплаты")]
InvalidCallReward,
#[msg("Некорректная сумма")] #[msg("Некорректная сумма")]
InvalidAmount, InvalidAmount,
#[msg("Очередь временно приостановлена: достигнут лимит")] #[msg("Очередь временно приостановлена: достигнут лимит")]
@ -742,6 +826,22 @@ pub enum PaymentsError {
InvalidManagerWallet, InvalidManagerWallet,
#[msg("Лимит менеджера по выбранной очереди превышен")] #[msg("Лимит менеджера по выбранной очереди превышен")]
ManagerLimitExceeded, ManagerLimitExceeded,
#[msg("Только текущий получатель тикета может изменить получателя")]
UnauthorizedTicketOwner,
#[msg("Нельзя менять получателя у следующего тикета на выплату")]
CannotChangeRecipientForNextPayoutTicket,
#[msg("Оракул передан неверный")]
InvalidOracleAccount,
#[msg("Некорректный feed id оракула")]
InvalidOracleFeed,
#[msg("Конфигурация оракула в settings некорректна")]
InvalidOracleFeedConfig,
#[msg("Цена оракула устарела")]
OraclePriceTooOld,
#[msg("Цена оракула некорректна")]
InvalidOraclePrice,
#[msg("Защита от проскальзывания: лимит пользователя не проходит")]
SlippageExceeded,
} }
fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> { fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> {
@ -749,7 +849,11 @@ fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> {
let (coef, _) = find_single_pda(program_id, settings::COEF_LIMIT_SEED); let (coef, _) = find_single_pda(program_id, settings::COEF_LIMIT_SEED);
let (queues, _) = find_single_pda(program_id, settings::QUEUES_SEED); let (queues, _) = find_single_pda(program_id, settings::QUEUES_SEED);
let (inflow, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED); let (inflow, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED);
require_keys_eq!(config, accounts.config_pda.key(), ErrCode::InvalidPdaAddress); require_keys_eq!(
config,
accounts.config_pda.key(),
ErrCode::InvalidPdaAddress
);
require_keys_eq!( require_keys_eq!(
coef, coef,
accounts.coef_limit_pda.key(), accounts.coef_limit_pda.key(),
@ -789,6 +893,181 @@ fn find_manager_allowance_pda(program_id: &Pubkey, manager_wallet: &Pubkey) -> (
) )
} }
fn buy_ticket_by_purchase_usd(
ctx: &Context<BuyTicket>,
purchase_usd_cents: u64,
transfer_lamports: u64,
recipient_wallet: Pubkey,
) -> Result<()> {
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
let coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
require_keys_eq!(
ctx.accounts.dao_wallet.key(),
config.dao_wallet,
PaymentsError::InvalidDaoWallet
);
let queue1_sum_total_before = queues.q1_sum_total_usd_cents;
require!(
queue1_sum_total_before < coef_limit.limit_usd_cents,
PaymentsError::QueueTemporarilyPaused
);
let ticket_index = queues
.q1_tickets_total
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?;
let (expected_ticket_pda, ticket_bump) = find_ticket_pda(ctx.program_id, 1, ticket_index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.ticket_pda.key(),
ErrCode::InvalidPdaAddress
);
require!(
ctx.accounts.ticket_pda.owner == &Pubkey::default(),
ErrCode::PdaAlreadyExists
);
let payout_usd_cents = purchase_usd_cents
.checked_mul(coef_limit.coef_ppm)
.ok_or(error!(ErrCode::MathOverflow))?
/ settings::COEF_SCALE_PPM;
require!(payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount);
transfer_from_signer_to_target(
&ctx.accounts.signer,
&ctx.accounts.dao_wallet,
&ctx.accounts.system_program.to_account_info(),
transfer_lamports,
)?;
let ticket = TicketState {
version: 1,
queue_id: 1,
index: ticket_index,
is_paid: false,
recipient_wallet,
payout_usd_cents,
debt_before_usd_cents: queue1_sum_total_before,
};
create_state_with_seeds(
ctx.program_id,
&ctx.accounts.signer,
&ctx.accounts.system_program.to_account_info(),
&ctx.accounts.ticket_pda,
&[
settings::Q1_TICKET_SEED,
&ticket_index.to_le_bytes(),
&[ticket_bump],
],
settings::TICKET_SPACE,
&ticket,
)?;
queues.q1_tickets_total = ticket_index;
queues.q1_sum_total_usd_cents = queues
.q1_sum_total_usd_cents
.checked_add(payout_usd_cents)
.ok_or(error!(ErrCode::MathOverflow))?;
write_state(&ctx.accounts.queues_pda, &queues)?;
Ok(())
}
fn transfer_from_signer_to_target<'info>(
signer: &AccountInfo<'info>,
target: &AccountInfo<'info>,
system_program: &AccountInfo<'info>,
amount: u64,
) -> Result<()> {
let ix = system_instruction::transfer(signer.key, target.key, amount);
invoke(
&ix,
&[signer.clone(), target.clone(), system_program.clone()],
)?;
Ok(())
}
fn read_sol_usd_price(price_update: &Account<PriceUpdateV2>, key: &Pubkey) -> Result<SolUsdPrice> {
let expected_oracle = Pubkey::from_str(settings::PYTH_SOL_USD_ACCOUNT)
.map_err(|_| error!(PaymentsError::InvalidOracleFeedConfig))?;
require_keys_eq!(expected_oracle, *key, PaymentsError::InvalidOracleAccount);
let feed_id = get_feed_id_from_hex(settings::PYTH_SOL_USD_FEED_ID)
.map_err(|_| error!(PaymentsError::InvalidOracleFeedConfig))?;
let clock = Clock::get()?;
let price = price_update
.get_price_no_older_than(&clock, settings::ORACLE_MAX_AGE_SECS, &feed_id)
.map_err(map_oracle_error)?;
price_to_ratio(price)
}
fn map_oracle_error(err: GetPriceError) -> anchor_lang::error::Error {
match err {
GetPriceError::PriceTooOld => error!(PaymentsError::OraclePriceTooOld),
GetPriceError::MismatchedFeedId => error!(PaymentsError::InvalidOracleFeed),
GetPriceError::InsufficientVerificationLevel => error!(PaymentsError::InvalidOraclePrice),
GetPriceError::FeedIdMustBe32Bytes | GetPriceError::FeedIdNonHexCharacter => {
error!(PaymentsError::InvalidOracleFeedConfig)
}
_ => error!(PaymentsError::InvalidOraclePrice),
}
}
fn price_to_ratio(price: Price) -> Result<SolUsdPrice> {
require!(price.price > 0, PaymentsError::InvalidOraclePrice);
let mut num = (price.price as u128)
.checked_mul(settings::USD_CENTS_SCALE as u128)
.ok_or(error!(ErrCode::MathOverflow))?;
let mut den: u128 = 1;
if price.exponent >= 0 {
let pow = 10u128
.checked_pow(price.exponent as u32)
.ok_or(error!(ErrCode::MathOverflow))?;
num = num.checked_mul(pow).ok_or(error!(ErrCode::MathOverflow))?;
} else {
let pow = 10u128
.checked_pow((-price.exponent) as u32)
.ok_or(error!(ErrCode::MathOverflow))?;
den = den.checked_mul(pow).ok_or(error!(ErrCode::MathOverflow))?;
}
require!(num > 0 && den > 0, PaymentsError::InvalidOraclePrice);
Ok(SolUsdPrice {
price_num: num,
price_den: den,
})
}
fn lamports_to_usd_cents_floor(lamports: u64, price: &SolUsdPrice) -> Result<u64> {
let numerator = (lamports as u128)
.checked_mul(price.price_num)
.ok_or(error!(ErrCode::MathOverflow))?;
let denominator = (settings::LAMPORTS_PER_SOL as u128)
.checked_mul(price.price_den)
.ok_or(error!(ErrCode::MathOverflow))?;
require!(denominator > 0, PaymentsError::InvalidOraclePrice);
let value = numerator / denominator;
u64::try_from(value).map_err(|_| error!(ErrCode::MathOverflow))
}
fn usd_cents_to_lamports_ceil(usd_cents: u64, price: &SolUsdPrice) -> Result<u64> {
require!(usd_cents > 0, PaymentsError::InvalidAmount);
require!(price.price_num > 0, PaymentsError::InvalidOraclePrice);
let numerator = (usd_cents as u128)
.checked_mul(settings::LAMPORTS_PER_SOL as u128)
.and_then(|v| v.checked_mul(price.price_den))
.ok_or(error!(ErrCode::MathOverflow))?;
let adjusted = numerator
.checked_add(price.price_num - 1)
.ok_or(error!(ErrCode::MathOverflow))?;
let value = adjusted / price.price_num;
u64::try_from(value).map_err(|_| error!(ErrCode::MathOverflow))
}
fn create_and_store_state<'info, T: AnchorSerialize>( fn create_and_store_state<'info, T: AnchorSerialize>(
program_id: &Pubkey, program_id: &Pubkey,
payer: &AccountInfo<'info>, payer: &AccountInfo<'info>,
@ -819,14 +1098,7 @@ fn create_state_with_seeds<'info, T: AnchorSerialize>(
space: usize, space: usize,
state: &T, state: &T,
) -> Result<()> { ) -> Result<()> {
create_pda( create_pda(pda, payer, system_program, program_id, seeds, space as u64)?;
pda,
payer,
system_program,
program_id,
seeds,
space as u64,
)?;
write_state(pda, state) write_state(pda, state)
} }
@ -856,7 +1128,10 @@ fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64
} }
let mut vault_lamports = vault.try_borrow_mut_lamports()?; let mut vault_lamports = vault.try_borrow_mut_lamports()?;
let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; let mut recipient_lamports = recipient.try_borrow_mut_lamports()?;
require!(**vault_lamports >= amount, PaymentsError::NotEnoughInflowForStep); require!(
**vault_lamports >= amount,
PaymentsError::NotEnoughInflowForStep
);
**vault_lamports = vault_lamports **vault_lamports = vault_lamports
.checked_sub(amount) .checked_sub(amount)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;

View File

@ -1,22 +1,54 @@
pub const CONFIG_SEED: &[u8] = b"shine_payments_v2_config"; use common::deploy_config;
pub const COEF_LIMIT_SEED: &[u8] = b"shine_payments_v2_coef_limit";
pub const QUEUES_SEED: &[u8] = b"shine_payments_v2_queues";
pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_v2_inflow_vault";
pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_v2_q1_ticket";
pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_v2_q2_ticket";
pub const MANAGER_ALLOWANCE_SEED: &[u8] = b"shine_payments_v2_manager_allowance";
/// `CONFIG_SEED` — seed PDA основного конфига `shine_payments`.
pub const CONFIG_SEED: &[u8] = b"shine_payments_config";
/// `COEF_LIMIT_SEED` — seed PDA коэффициента, лимита и награды шага выплат.
pub const COEF_LIMIT_SEED: &[u8] = b"shine_payments_coef_limit";
/// `QUEUES_SEED` — seed PDA агрегатов очередей выплат.
pub const QUEUES_SEED: &[u8] = b"shine_payments_queues";
/// `INFLOW_VAULT_SEED` — seed PDA inflow-вольта, откуда исполняются выплаты.
pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_inflow_vault";
/// `Q1_TICKET_SEED` — seed PDA тикетов очереди 1.
pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_q1_ticket";
/// `Q2_TICKET_SEED` — seed PDA тикетов очереди 2.
pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_q2_ticket";
/// `MANAGER_ALLOWANCE_SEED` — seed PDA лимитов менеджера.
pub const MANAGER_ALLOWANCE_SEED: &[u8] = b"shine_p_manager_allow";
/// `CONFIG_SPACE` — размер (в байтах) PDA `ConfigState`.
pub const CONFIG_SPACE: usize = 8 + 160; pub const CONFIG_SPACE: usize = 8 + 160;
/// `COEF_LIMIT_SPACE` — размер (в байтах) PDA `CoefLimitState`.
pub const COEF_LIMIT_SPACE: usize = 8 + 96; pub const COEF_LIMIT_SPACE: usize = 8 + 96;
/// `QUEUES_SPACE` — размер (в байтах) PDA `QueuesState`.
pub const QUEUES_SPACE: usize = 8 + 192; pub const QUEUES_SPACE: usize = 8 + 192;
/// `INFLOW_VAULT_SPACE` — размер (в байтах) PDA `VaultState`.
pub const INFLOW_VAULT_SPACE: usize = 8 + 32; pub const INFLOW_VAULT_SPACE: usize = 8 + 32;
/// `TICKET_SPACE` — размер (в байтах) PDA `TicketState`.
pub const TICKET_SPACE: usize = 8 + 160; pub const TICKET_SPACE: usize = 8 + 160;
/// `MANAGER_ALLOWANCE_SPACE` — размер (в байтах) PDA `ManagerAllowanceState`.
pub const MANAGER_ALLOWANCE_SPACE: usize = 8 + 128; pub const MANAGER_ALLOWANCE_SPACE: usize = 8 + 128;
/// `COEF_SCALE_PPM` — масштаб fixed-point для коэффициента (ppm = parts per million).
pub const COEF_SCALE_PPM: u64 = 1_000_000; pub const COEF_SCALE_PPM: u64 = 1_000_000;
pub const START_COEF_PPM: u64 = 5_000_000; // 5.0 /// `START_COEF_PPM` — стартовый коэффициент выплаты при инициализации (`5_000_000` = 5.0x).
pub const START_LIMIT_LAMPORTS: u64 = 100 * 1_000_000_000; // 100 SOL pub const START_COEF_PPM: u64 = 5_000_000;
pub const START_CALL_REWARD_LAMPORTS: u64 = 8_000_000; // 0.008 SOL /// `START_LIMIT_USD_CENTS` — стартовый лимит Q1 в USD-центах (10_000 USD).
pub const START_LIMIT_USD_CENTS: u64 = 10_000 * 100;
/// `START_CALL_REWARD_LAMPORTS` — стартовая награда за вызов `step_payout` (0.008 SOL).
pub const START_CALL_REWARD_LAMPORTS: u64 = 8_000_000;
/// `MAX_CALL_REWARD_LAMPORTS` — верхняя граница награды за шаг выплат (0.01 SOL).
pub const MAX_CALL_REWARD_LAMPORTS: u64 = 10_000_000;
/// `USD_CENTS_SCALE` — масштаб USD-центов (1 USD = 100 центов).
pub const USD_CENTS_SCALE: u64 = 100;
/// `LAMPORTS_PER_SOL` — количество лампортов в 1 SOL.
pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
pub const DAO_WALLET: &str = "6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY"; /// `ORACLE_MAX_AGE_SECS` — максимальный возраст oracle-цены (в секундах), допустимый для расчетов.
pub const MANAGER_WALLET: &str = "4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv"; pub const ORACLE_MAX_AGE_SECS: u64 = 120;
/// `PYTH_SOL_USD_FEED_ID` — feed id Pyth для пары SOL/USD (берется из общего deploy-конфига).
pub const PYTH_SOL_USD_FEED_ID: &str = deploy_config::PYTH_SOL_USD_FEED_ID;
/// `PYTH_SOL_USD_ACCOUNT` — адрес аккаунта Pyth price update для SOL/USD (берется из общего deploy-конфига).
pub const PYTH_SOL_USD_ACCOUNT: &str = deploy_config::PYTH_SOL_USD_ACCOUNT;
/// `DAO_WALLET` — адрес кошелька DAO-казны для `shine_payments` (берется из общего deploy-конфига).
pub const DAO_WALLET: &str = deploy_config::DAO_TREASURY_WALLET;

View File

@ -5,76 +5,121 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Тех. инструменты — Shine Payments Devnet</title> <title>Тех. инструменты — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } 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); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.wrap { width: 100%; max-width: 1850px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 180px; } input { padding: 9px 10px; min-width: 170px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 8px 12px; cursor: pointer; } button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
.muted { color: #666; } button:hover { background: var(--btn-hover); }
.ok { color: #0a7a3c; } .muted { color: var(--muted); }
.warn { color: #9f5f00; } .ok { color: var(--ok); }
.err { color: #b30000; white-space: pre-wrap; } .warn { color: var(--warn); }
.paid { color: #0a7a3c; font-weight: 700; } .err { color: var(--err); white-space: pre-wrap; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; } .paid { color: var(--ok); font-weight: 700; }
.formula { font-family: monospace; color: #c9d7f0; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
table { border-collapse: collapse; width: 100%; } table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 6px; text-align: left; font-size: 14px; } th, td { border: 1px solid var(--line); padding: 6px; text-align: left; font-size: 14px; vertical-align: top; }
</style> </style>
</head> </head>
<body> <body>
<h1>Техническая страница (Devnet)</h1> <div class="wrap">
<div class="muted">Программа: <code id="programId"></code></div> <div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Техническая страница (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel"> <div class="panel">
<div class="row"> <div class="row">
<button id="connectBtn">Подключить кошелек</button> <button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить всё</button> <button id="refreshBtn">Обновить всё</button>
<button id="initBtn">Init (один раз)</button> <button id="initBtn">Init (один раз)</button>
</div>
<div id="walletInfo" class="muted"></div>
<div id="initResult" class="muted"></div>
</div> </div>
<div id="walletInfo" class="muted"></div>
<div id="initResult" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Коэффициент и лимит</h3> <h3>Коэффициент, лимит и награда шага выплат</h3>
<div class="muted">Право изменения: <code id="managerAllowed">загрузка...</code></div> <div class="muted">Право изменения: <code id="daoAllowed">загрузка...</code></div>
<div class="row"> <div class="row">
<label>Коэффициент (например 5): <input id="coefInput" value="5" /></label> <label>Коэффициент (например 5): <input id="coefInput" value="5" /></label>
<label>Лимит (SOL): <input id="limitInput" value="100" /></label> <label>Лимит (USD): <input id="limitInput" value="10000" /></label>
<button id="updateCoefBtn">Обновить</button> <label>Награда шага (SOL, max 0.01): <input id="rewardInput" value="0.008" /></label>
<button id="updateCoefBtn">Обновить</button>
</div>
<div class="formula">Лимит покупки Q1 = max(limit_usd_cents - q1_sum_total_usd_cents, 0)</div>
<div class="formula">Шаг выплаты Q1 = ticket + dao(1x) + reward; Q2 = ticket + dao(2x) + reward</div>
<div id="updateResult" class="muted"></div>
</div> </div>
<div id="updateResult" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Адреса и агрегаты</h3> <h3>Shine Users: экономические параметры</h3>
<div id="balances" class="muted">Загрузка...</div> <div class="muted">Право изменения: <code id="usersDaoAllowed">загрузка...</code></div>
</div> <div id="usersEconomyState" class="muted">Загрузка...</div>
<div class="row">
<label>Комиссия регистрации (SOL): <input id="usersRegFeeInput" value="0.01" /></label>
<label>Цена шага лимита (SOL): <input id="usersLimitStepFeeInput" value="0.0001" /></label>
<label>Стартовый бонус лимита: <input id="usersBonusInput" value="100000" /></label>
<button id="usersUpdateBtn">Обновить</button>
<button id="usersInitBtn">Init Users Economy</button>
</div>
<div id="usersUpdateResult" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Очередь 1 (все билеты)</h3> <h3>Адреса и агрегаты</h3>
<div class="row"><button id="loadQ1Btn">Показать очередь 1</button></div> <div id="balances" class="muted">Загрузка...</div>
<div id="queue1Table" class="muted"></div> </div>
</div>
<div class="panel"> <div class="panel">
<h3>Очередь 2 (все билеты)</h3> <h3>Очередь 1 (все билеты)</h3>
<div class="row"><button id="loadQ2Btn">Показать очередь 2</button></div> <div class="row"><button id="loadQ1Btn">Показать очередь 1</button></div>
<div id="queue2Table" class="muted"></div> <div id="queue1Table" class="muted"></div>
</div>
<div class="panel">
<h3>Очередь 2 (все билеты)</h3>
<div class="row"><button id="loadQ2Btn">Показать очередь 2</button></div>
<div id="queue2Table" class="muted"></div>
</div>
</div> </div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script> <script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script> <script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"); const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
const USERS_PROGRAM_ID = new solanaWeb3.PublicKey("FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm");
const RPC_URL = "https://api.devnet.solana.com"; const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed"); const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = { const SEEDS = {
config: "shine_payments_v2_config", config: "shine_payments_v3_config",
coef: "shine_payments_v2_coef_limit", coef: "shine_payments_v3_coef_limit",
queues: "shine_payments_v2_queues", queues: "shine_payments_v3_queues",
inflow: "shine_payments_v2_inflow_vault", inflow: "shine_payments_v3_inflow_vault",
ticketQ1: "shine_payments_v2_q1_ticket", ticketQ1: "shine_payments_v3_q1_ticket",
ticketQ2: "shine_payments_v2_q2_ticket", ticketQ2: "shine_payments_v3_q2_ticket",
}; };
const USERS_SEEDS = {
economyConfig: "shine_users_v1_economy_config",
};
const MAX_REWARD_LAMPORTS = 10_000_000n;
let walletPubkey = null; let walletPubkey = null;
let cache = null; let cache = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58(); document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
@ -104,36 +149,47 @@
function lamportsToSolStr(l) { function lamportsToSolStr(l) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9)); return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
} }
function centsToUsdStr(c) {
return trimZeros((Number(c) / 100).toFixed(2));
}
function solToLamports(solStr) { function solToLamports(solStr) {
const v = Number(solStr); const v = Number(solStr.replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL"); if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000)); return BigInt(Math.round(v * 1_000_000_000));
} }
function usdToCents(usdStr) {
const v = Number(usdStr.replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
return BigInt(Math.round(v * 100));
}
async function ixDiscriminator(name) { async function ixDiscriminator(name) {
const msg = utf8("global:" + name); const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg); const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8); return new Uint8Array(hash).slice(0, 8);
} }
function isUnauthorizedManager(msg) { function isUnauthorizedDao(msg) {
const s = String(msg || "").toLowerCase(); const s = String(msg || "").toLowerCase();
return s.includes("unauthorizedmanager") || s.includes("0x1774"); return s.includes("unauthorizeddao") || s.includes("0x1775");
}
function isUsersDaoUnauthorized(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("invalidsigner") || s.includes("0x3ed");
} }
function parseConfig(data) { function parseConfig(data) {
let o = 0; let o = 0;
const version = data[o++]; const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const reward = readU64(data, o); o += 8; return { version, dao, inflow };
return { version, dao, manager, inflow, reward };
} }
function parseCoef(data) { function parseCoef(data) {
let o = 0; let o = 0;
const version = data[o++]; const version = data[o++];
const coefPpm = readU64(data, o); o += 8; const coefPpm = readU64(data, o); o += 8;
const limit = readU64(data, o); o += 8; const limitUsdCents = readU64(data, o); o += 8;
return { version, coefPpm, limit }; const reward = readU64(data, o); o += 8;
return { version, coefPpm, limitUsdCents, reward };
} }
function parseQueues(data) { function parseQueues(data) {
let o = 0; let o = 0;
@ -159,6 +215,14 @@
const debtBefore = readU64(data, o); o += 8; const debtBefore = readU64(data, o); o += 8;
return { version, queueId, index, isPaid, recipient, payout, debtBefore }; return { version, queueId, index, isPaid, recipient, payout, debtBefore };
} }
function parseUsersEconomyConfig(data) {
let o = 0;
const version = data[o++];
const registrationFeeLamports = readU64(data, o); o += 8;
const lamportsPerLimitStep = readU64(data, o); o += 8;
const startBonusLimit = readU64(data, o); o += 8;
return { version, registrationFeeLamports, lamportsPerLimitStep, startBonusLimit };
}
function getProvider() { function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден"); if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
@ -191,6 +255,13 @@
const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID); const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID);
return { configPda, coefPda, queuesPda, inflowPda }; return { configPda, coefPda, queuesPda, inflowPda };
} }
function deriveUsersPdas() {
const [usersEconomyConfigPda] = solanaWeb3.PublicKey.findProgramAddressSync(
[utf8(USERS_SEEDS.economyConfig)],
USERS_PROGRAM_ID
);
return { usersEconomyConfigPda };
}
function ticketPda(queueId, index) { function ticketPda(queueId, index) {
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2; const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID); const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
@ -234,23 +305,51 @@
return; return;
} }
const coefText = trimZeros((Number(core.coef.coefPpm) / 1_000_000).toFixed(6)); const coefText = trimZeros((Number(core.coef.coefPpm) / 1_000_000).toFixed(6));
document.getElementById("managerAllowed").textContent = core.config.manager.toBase58(); const limitRemain = core.coef.limitUsdCents > core.queues.q1SumTotal ? (core.coef.limitUsdCents - core.queues.q1SumTotal) : 0n;
document.getElementById("daoAllowed").textContent = core.config.dao.toBase58();
el.innerHTML = ` el.innerHTML = `
<div>DAO: <code>${core.config.dao.toBase58()}</code></div> <div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
<div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></div> <div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></div>
<div class="muted">Inflow vault — это входящий PDA-кошелек выплат программы.</div> <div class="muted">Inflow vault — входящий PDA-кошелек выплат программы.</div>
<div>Manager (для coef/limit): <code>${core.config.manager.toBase58()}</code></div> <div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
<div>Награда за шаг: <b>${lamportsToSolStr(core.config.reward)} SOL</b></div> <div>Коэффициент: <b>${coefText}</b>, лимит: <b>${centsToUsdStr(core.coef.limitUsdCents)} USD</b></div>
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${lamportsToSolStr(core.coef.limit)} SOL</b></div> <div>Осталось лимита для покупки Q1: <b>${centsToUsdStr(limitRemain)} USD</b></div>
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div> <div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
<div>Баланс inflow vault: <b>${lamportsToSolStr(core.inflowLamports)} SOL</b></div> <div>Баланс inflow vault: <b>${lamportsToSolStr(core.inflowLamports)} SOL</b></div>
<div>Доступно в inflow (сверх ренты): <b>${lamportsToSolStr(core.inflowAvailable)} SOL</b></div> <div>Доступно в inflow (сверх ренты): <b>${lamportsToSolStr(core.inflowAvailable)} SOL</b></div>
<div>Q1: total=${core.queues.q1Total}, paid=${core.queues.q1Paid}, sum_total=${lamportsToSolStr(core.queues.q1SumTotal)} SOL, sum_paid=${lamportsToSolStr(core.queues.q1SumPaid)} SOL</div> <div>Q1: total=${core.queues.q1Total}, paid=${core.queues.q1Paid}, sum_total=${centsToUsdStr(core.queues.q1SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q1SumPaid)} USD</div>
<div>Q2: total=${core.queues.q2Total}, paid=${core.queues.q2Paid}, sum_total=${lamportsToSolStr(core.queues.q2SumTotal)} SOL, sum_paid=${lamportsToSolStr(core.queues.q2SumPaid)} SOL</div> <div>Q2: total=${core.queues.q2Total}, paid=${core.queues.q2Paid}, sum_total=${centsToUsdStr(core.queues.q2SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q2SumPaid)} USD</div>
`; `;
} catch (e) { } catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`; el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
document.getElementById("managerAllowed").textContent = "не определен"; document.getElementById("daoAllowed").textContent = "не определен";
}
}
async function refreshUsersEconomy() {
const out = document.getElementById("usersEconomyState");
try {
const usersPdas = deriveUsersPdas();
const ai = await connection.getAccountInfo(usersPdas.usersEconomyConfigPda, "confirmed");
document.getElementById("usersDaoAllowed").textContent = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
if (!ai) {
out.innerHTML = `<span class="warn">PDA Users Economy еще не инициализирован.</span><div>PDA: <code>${usersPdas.usersEconomyConfigPda.toBase58()}</code></div>`;
return;
}
const c = parseUsersEconomyConfig(ai.data);
document.getElementById("usersRegFeeInput").value = lamportsToSolStr(c.registrationFeeLamports);
document.getElementById("usersLimitStepFeeInput").value = lamportsToSolStr(c.lamportsPerLimitStep);
document.getElementById("usersBonusInput").value = c.startBonusLimit.toString();
out.innerHTML = `
<div>Users program: <code>${USERS_PROGRAM_ID.toBase58()}</code></div>
<div>Economy config PDA: <code>${usersPdas.usersEconomyConfigPda.toBase58()}</code></div>
<div>registration_fee_lamports: <b>${c.registrationFeeLamports.toString()}</b> (~${lamportsToSolStr(c.registrationFeeLamports)} SOL)</div>
<div>lamports_per_limit_step: <b>${c.lamportsPerLimitStep.toString()}</b> (~${lamportsToSolStr(c.lamportsPerLimitStep)} SOL)</div>
<div>start_bonus_limit: <b>${c.startBonusLimit.toString()}</b></div>
`;
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
} }
} }
@ -294,10 +393,12 @@
const coef = Number(document.getElementById("coefInput").value.trim()); const coef = Number(document.getElementById("coefInput").value.trim());
if (!Number.isFinite(coef) || coef <= 0) throw new Error("Некорректный коэффициент"); if (!Number.isFinite(coef) || coef <= 0) throw new Error("Некорректный коэффициент");
const coefPpm = BigInt(Math.round(coef * 1_000_000)); const coefPpm = BigInt(Math.round(coef * 1_000_000));
const limitLamports = solToLamports(document.getElementById("limitInput").value.trim()); const limitUsdCents = usdToCents(document.getElementById("limitInput").value.trim());
const rewardLamports = solToLamports(document.getElementById("rewardInput").value.trim());
if (rewardLamports > MAX_REWARD_LAMPORTS) throw new Error("Награда не должна быть больше 0.01 SOL");
const disc = await ixDiscriminator("update_coef_limit"); const disc = await ixDiscriminator("update_coef_limit");
const data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitLamports)); const data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitUsdCents), u64ToBytes(rewardLamports));
const keys = [ const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, { pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true }, { pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
@ -309,15 +410,83 @@
await refreshAll(); await refreshAll();
} catch (e) { } catch (e) {
const raw = String(e.message || e); const raw = String(e.message || e);
if (isUnauthorizedManager(raw)) { if (isUnauthorizedDao(raw)) {
const mgr = document.getElementById("managerAllowed").textContent; const dao = document.getElementById("daoAllowed").textContent;
out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только управляющему кошельку: <code>${mgr}</code>.</span>`; out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только DAO-кошельку: <code>${dao}</code>.</span>`;
return; return;
} }
out.innerHTML = `<span class="err">${raw}</span>`; out.innerHTML = `<span class="err">${raw}</span>`;
} }
} }
async function initUsersEconomy() {
const out = document.getElementById("usersUpdateResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect();
const usersPdas = deriveUsersPdas();
const disc = await ixDiscriminator("init_users_economy_config");
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: usersPdas.usersEconomyConfigPda, isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: USERS_PROGRAM_ID, keys, data: disc });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Users Economy init выполнен. Tx: <code>${sig}</code></span>`;
await refreshUsersEconomy();
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function updateUsersEconomy() {
const out = document.getElementById("usersUpdateResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect();
const usersPdas = deriveUsersPdas();
const registrationFeeLamports = solToLamports(document.getElementById("usersRegFeeInput").value.trim());
const lamportsPerLimitStep = solToLamports(document.getElementById("usersLimitStepFeeInput").value.trim());
const startBonusLimit = BigInt(document.getElementById("usersBonusInput").value.trim());
if (startBonusLimit < 0n) throw new Error("Стартовый бонус не может быть отрицательным");
const disc = await ixDiscriminator("update_users_economy_config");
const data = concat(
disc,
u64ToBytes(registrationFeeLamports),
u64ToBytes(lamportsPerLimitStep),
u64ToBytes(startBonusLimit)
);
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: usersPdas.usersEconomyConfigPda, isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: USERS_PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Users Economy обновлен. Tx: <code>${sig}</code></span>`;
await refreshUsersEconomy();
} catch (e) {
const raw = String(e.message || e);
if (isUsersDaoUnauthorized(raw)) {
const dao = document.getElementById("usersDaoAllowed").textContent;
out.innerHTML = `<span class="warn">Изменение доступно только DAO-кошельку: <code>${dao}</code>.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
}
}
function currentDebtBeforeTicket(ticket, queues) {
if (ticket.isPaid) return 0n;
const paidSum = ticket.queueId === 1 ? queues.q1SumPaid : queues.q2SumPaid;
return ticket.debtBefore > paidSum ? (ticket.debtBefore - paidSum) : 0n;
}
async function showQueue(queueId) { async function showQueue(queueId) {
const out = document.getElementById(queueId === 1 ? "queue1Table" : "queue2Table"); const out = document.getElementById(queueId === 1 ? "queue1Table" : "queue2Table");
out.textContent = "Загрузка..."; out.textContent = "Загрузка...";
@ -334,19 +503,20 @@
const pda = ticketPda(queueId, i); const pda = ticketPda(queueId, i);
const ai = await connection.getAccountInfo(pda, "confirmed"); const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) { if (!ai) {
rows.push(`<tr><td>${i.toString()}</td><td colspan="6" class="err">PDA не найден</td></tr>`); rows.push(`<tr><td>${i.toString()}</td><td>${queueId}</td><td colspan="6" class="err">PDA не найден</td></tr>`);
continue; continue;
} }
const t = parseTicket(ai.data); const t = parseTicket(ai.data);
rows.push(` rows.push(`
<tr> <tr>
<td>${t.index.toString()}</td> <td>${t.index.toString()}</td>
<td>${t.isPaid ? '<span class="paid">да</span>' : "нет"}</td>
<td><code>${pda.toBase58()}</code></td>
<td><code>${t.recipient.toBase58()}</code></td>
<td>${lamportsToSolStr(t.payout)} SOL</td>
<td>${lamportsToSolStr(t.debtBefore)} SOL</td>
<td>${t.queueId}</td> <td>${t.queueId}</td>
<td>${t.isPaid ? '<span class="paid">выплачен</span>' : "ожидание"}</td>
<td><code>${t.recipient.toBase58()}</code></td>
<td>${centsToUsdStr(t.payout)} USD</td>
<td>${centsToUsdStr(t.debtBefore)} USD</td>
<td>${centsToUsdStr(currentDebtBeforeTicket(t, core.queues))} USD</td>
<td><code>${pda.toBase58()}</code></td>
</tr> </tr>
`); `);
} }
@ -354,7 +524,14 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>#</th><th>Выплачен</th><th>PDA</th><th>Получатель</th><th>Сумма</th><th>Debt Before</th><th>Очередь</th> <th>#</th>
<th>Очередь</th>
<th>Статус</th>
<th>Получатель</th>
<th>Сумма выплаты (USD)</th>
<th>Очередь до него (от старта)</th>
<th>Очередь до него (актуально)</th>
<th>PDA</th>
</tr> </tr>
</thead> </thead>
<tbody>${rows.join("")}</tbody> <tbody>${rows.join("")}</tbody>
@ -367,12 +544,15 @@
async function refreshAll() { async function refreshAll() {
await refreshBalances(); await refreshBalances();
await refreshUsersEconomy();
} }
document.getElementById("connectBtn").addEventListener("click", connectWallet); document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshAll); document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("initBtn").addEventListener("click", runInit); document.getElementById("initBtn").addEventListener("click", runInit);
document.getElementById("updateCoefBtn").addEventListener("click", updateCoefLimit); document.getElementById("updateCoefBtn").addEventListener("click", updateCoefLimit);
document.getElementById("usersInitBtn").addEventListener("click", initUsersEconomy);
document.getElementById("usersUpdateBtn").addEventListener("click", updateUsersEconomy);
document.getElementById("loadQ1Btn").addEventListener("click", () => showQueue(1)); document.getElementById("loadQ1Btn").addEventListener("click", () => showQueue(1));
document.getElementById("loadQ2Btn").addEventListener("click", () => showQueue(2)); document.getElementById("loadQ2Btn").addEventListener("click", () => showQueue(2));
refreshAll(); refreshAll();

View File

@ -5,64 +5,97 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Покупка билета — Shine Payments Devnet</title> <title>Покупка билета — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } :root {
h1 { margin-bottom: 8px; } color-scheme: dark;
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } --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); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.wrap { width: 100%; max-width: 1700px; }
h1 { margin: 8px 0; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 220px; } input { padding: 9px 10px; min-width: 240px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 8px 12px; cursor: pointer; } button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
.muted { color: #666; } button:hover { background: var(--btn-hover); }
.ok { color: #0a7a3c; } .muted { color: var(--muted); }
.warn { color: #9f5f00; } .ok { color: var(--ok); }
.err { color: #b30000; white-space: pre-wrap; } .warn { color: var(--warn); }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; } .err { color: var(--err); white-space: pre-wrap; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style> </style>
</head> </head>
<body> <body>
<h1>Покупка билета (Devnet)</h1> <div class="wrap">
<div class="muted">Программа: <code id="programId"></code></div> <div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Покупка билета (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel"> <div class="panel">
<div class="row"> <div class="row">
<button id="connectBtn">Подключить кошелек</button> <button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить состояние</button> <button id="refreshBtn">Обновить состояние</button>
</div>
<div id="walletInfo" class="muted"></div>
</div> </div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Текущее состояние</h3> <h3>Текущее состояние (очередь 1)</h3>
<div id="stateInfo" class="muted">Загрузка...</div> <div id="stateInfo" class="muted">Загрузка...</div>
</div> </div>
<div class="panel"> <div class="panel">
<h3>Покупка билета в 1-й очереди</h3> <h3>Покупка билета в 1-й очереди</h3>
<div class="muted">Покупка создает билет только в 1-й очереди. Билеты 2-й очереди добавляются менеджерами с правами.</div> <div class="muted">Можно купить по USD или по SOL. В очередь и лимиты записываются USD-центы. Выплаты по тикетам считаются в USD, а переводятся в SOL по актуальному курсу Pyth в момент шага выплаты.</div>
<div class="row"> <div class="row">
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label> <label>Сумма (USD): <input id="amountUsd" value="20" /></label>
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label> <label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
<label>Допуск (%): <input id="slippagePct" value="3" /></label>
</div>
<div class="row">
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
</div>
<div id="quoteInfo" class="muted"></div>
<div class="row">
<button id="buyUsdBtn">Купить по USD</button>
<button id="buySolBtn">Купить по SOL</button>
</div>
<div class="warn">Дополнительно к сумме покупки кошелек платит сеть за создание записи тикета (обычно около 0.002 SOL).</div>
<div id="buyResult" class="muted"></div>
</div> </div>
<div class="row">
<button id="buyBtn">Купить билет</button>
</div>
<div id="buyResult" class="muted"></div>
</div> </div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script> <script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script> <script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"); const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
const RPC_URL = "https://api.devnet.solana.com"; const RPC_URL = "https://api.devnet.solana.com";
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed"); const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = { const SEEDS = {
config: "shine_payments_v2_config", config: "shine_payments_v3_config",
coef: "shine_payments_v2_coef_limit", coef: "shine_payments_v3_coef_limit",
queues: "shine_payments_v2_queues", queues: "shine_payments_v3_queues",
ticketQ1: "shine_payments_v2_q1_ticket", ticketQ1: "shine_payments_v3_q1_ticket",
}; };
const COEF_SCALE = 1_000_000n; const COEF_SCALE = 1_000_000n;
const LAMPORTS_PER_SOL = 1_000_000_000n;
let walletPubkey = null; let walletPubkey = null;
let lastState = null;
let activeEdit = "usd";
document.getElementById("programId").textContent = PROGRAM_ID.toBase58(); document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
@ -78,6 +111,16 @@
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i)); for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x; return x;
} }
function readI32(data, offset) {
let x = Number(readU64(data, offset) & 0xffffffffn);
if (x > 0x7fffffff) x -= 0x100000000;
return x;
}
function readI64(data, offset) {
let x = readU64(data, offset);
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
return x;
}
function concat(...parts) { function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0); const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len); const out = new Uint8Array(len);
@ -89,14 +132,50 @@
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, ""); return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
} }
function lamportsToSolStr(l) { function lamportsToSolStr(l) {
const sol = Number(l) / 1_000_000_000; return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
return trimZeros(sol.toFixed(9));
} }
function solToLamports(solStr) { function centsToUsdStr(cents) {
const v = Number(solStr); return trimZeros((Number(cents) / 100).toFixed(2));
}
function usdTextToCents(text) {
const v = Number(text.trim().replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
return BigInt(Math.round(v * 100));
}
function solTextToLamports(text) {
const v = Number(text.trim().replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL"); if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000)); return BigInt(Math.round(v * 1_000_000_000));
} }
function parsePythPriceUpdateV2(data) {
const price = readI64(data, 73);
const exponent = readI32(data, 89);
const publishTime = readI64(data, 93);
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
let num = price * 100n;
let den = 1n;
if (exponent >= 0) {
num *= 10n ** BigInt(exponent);
} else {
den *= 10n ** BigInt(-exponent);
}
return { num, den, publishTime };
}
function lamportsToUsdCentsFloor(lamports, px) {
return (lamports * px.num) / (LAMPORTS_PER_SOL * px.den);
}
function usdCentsToLamportsCeil(usdCents, px) {
const n = usdCents * LAMPORTS_PER_SOL * px.den;
return (n + px.num - 1n) / px.num;
}
function applySlippageUp(lamports, pct) {
const bp = BigInt(Math.round(pct * 100));
return (lamports * (10_000n + bp) + 9_999n) / 10_000n;
}
function applySlippageDown(cents, pct) {
const bp = BigInt(Math.round(pct * 100));
return (cents * (10_000n - bp)) / 10_000n;
}
async function ixDiscriminator(name) { async function ixDiscriminator(name) {
const msg = utf8("global:" + name); const msg = utf8("global:" + name);
@ -106,19 +185,18 @@
function parseConfig(data) { function parseConfig(data) {
let o = 0; let o = 0;
const version = data[o++]; const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32; const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32; const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const reward = readU64(data, o); o += 8; return { version, dao, inflow };
return { version, dao, manager, inflow, reward };
} }
function parseCoef(data) { function parseCoef(data) {
let o = 0; let o = 0;
const version = data[o++]; const version = data[o++];
const coefPpm = readU64(data, o); o += 8; const coefPpm = readU64(data, o); o += 8;
const limit = readU64(data, o); o += 8; const limitUsdCents = readU64(data, o); o += 8;
return { version, coefPpm, limit }; const reward = readU64(data, o); o += 8;
return { version, coefPpm, limitUsdCents, reward };
} }
function parseQueues(data) { function parseQueues(data) {
let o = 0; let o = 0;
@ -160,7 +238,7 @@
return sig; return sig;
} }
async function derivePdas() { function derivePdas() {
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID); const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
const [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID); const [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID); const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
@ -168,72 +246,116 @@
} }
async function loadCoreState() { async function loadCoreState() {
const pdas = await derivePdas(); const pdas = derivePdas();
const [cfgAi, coefAi, queuesAi] = await Promise.all([ const [cfgAi, coefAi, queuesAi, oracleAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"), connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.coefPda, "confirmed"), connection.getAccountInfo(pdas.coefPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"), connection.getAccountInfo(pdas.queuesPda, "confirmed"),
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
]); ]);
if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки."); if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки.");
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
const config = parseConfig(cfgAi.data); const config = parseConfig(cfgAi.data);
const coef = parseCoef(coefAi.data); const coef = parseCoef(coefAi.data);
const queues = parseQueues(queuesAi.data); const queues = parseQueues(queuesAi.data);
return { pdas, config, coef, queues }; const pyth = parsePythPriceUpdateV2(oracleAi.data);
return { pdas, config, coef, queues, pyth };
}
function renderQuote() {
const el = document.getElementById("quoteInfo");
if (!lastState) { el.textContent = ""; return; }
try {
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
const lamports = solTextToLamports(document.getElementById("amountSol").value);
const payForUsd = usdCentsToLamportsCeil(usdCents, lastState.pyth);
const usdForSol = lamportsToUsdCentsFloor(lamports, lastState.pyth);
const maxLamports = applySlippageUp(payForUsd, slippage);
const minUsd = applySlippageDown(usdForSol, slippage);
el.innerHTML = `
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(lastState.pyth.num) / Number(lastState.pyth.den) / 100).toFixed(6))}</b></div>
<div>Возраст цены: <b>${Math.max(0, Math.floor(Date.now()/1000 - Number(lastState.pyth.publishTime)))} сек</b></div>
<div>Если покупка по USD: к списанию примерно <b>${lamportsToSolStr(payForUsd)} SOL</b>, с допуском максимум <b>${lamportsToSolStr(maxLamports)} SOL</b>.</div>
<div>Если покупка по SOL: это примерно <b>${centsToUsdStr(usdForSol)} USD</b>, с допуском минимум <b>${centsToUsdStr(minUsd)} USD</b>.</div>
`;
} catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
function syncFromUsd() {
if (!lastState) return;
try {
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
const lamports = usdCentsToLamportsCeil(usdCents, lastState.pyth);
document.getElementById("amountSol").value = lamportsToSolStr(lamports);
} catch (_) {}
renderQuote();
}
function syncFromSol() {
if (!lastState) return;
try {
const lamports = solTextToLamports(document.getElementById("amountSol").value);
const usdCents = lamportsToUsdCentsFloor(lamports, lastState.pyth);
document.getElementById("amountUsd").value = centsToUsdStr(usdCents);
} catch (_) {}
renderQuote();
} }
async function refreshState() { async function refreshState() {
const el = document.getElementById("stateInfo"); const el = document.getElementById("stateInfo");
try { try {
const { config, coef, queues } = await loadCoreState(); lastState = await loadCoreState();
const currentDebt = queues.q1SumTotal - queues.q1SumPaid; const { config, coef, queues, pyth } = lastState;
const pendingTickets = queues.q1Total - queues.q1Paid;
const remaining = coef.limit > currentDebt ? coef.limit - currentDebt : 0n;
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6)); const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
const paused = currentDebt >= coef.limit; const pendingBeforeCount = queues.q1Total - queues.q1Paid;
const pendingBeforeSum = queues.q1SumTotal - queues.q1SumPaid;
const nextTicketIndex = queues.q1Total + 1n;
const remainingByTotal = coef.limitUsdCents > queues.q1SumTotal ? (coef.limitUsdCents - queues.q1SumTotal) : 0n;
const paused = queues.q1SumTotal >= coef.limitUsdCents;
el.innerHTML = ` el.innerHTML = `
<div>DAO: <code>${config.dao}</code></div> <div>DAO: <code>${config.dao}</code></div>
<div>Inflow vault: <code>${config.inflow}</code></div> <div>Inflow vault: <code>${config.inflow}</code></div>
<div class="muted">Inflow vault — это входящий PDA-кошелек выплат программы.</div> <div class="muted">Тестовый DAO-кошелек. В production будет реальный адрес DAO.</div>
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(pyth.num) / Number(pyth.den) / 100).toFixed(6))}</b></div>
<div>Коэффициент: <b>${coefText}</b></div> <div>Коэффициент: <b>${coefText}</b></div>
<div>Лимит очереди (1): <b>${lamportsToSolStr(coef.limit)} SOL</b></div> <div>Лимит очереди 1: <b>${centsToUsdStr(coef.limitUsdCents)} USD</b></div>
<div>Текущий долг очереди (1): <b>${lamportsToSolStr(currentDebt)} SOL</b></div> <div>Награда за шаг выплат: <b>${lamportsToSolStr(coef.reward)} SOL</b></div>
<div>Билетов в ожидании до вас сейчас: <b>${pendingTickets.toString()}</b></div> <div>Из них сейчас не выплачено билетов: <b>${pendingBeforeCount.toString()}</b></div>
<div>Осталось до изменения коэффициента/лимита: <b>${lamportsToSolStr(remaining)} SOL</b></div> <div>Из них сейчас не выплачено по сумме: <b>${centsToUsdStr(pendingBeforeSum)} USD</b></div>
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: очередь заполнена." : "Покупка доступна."}</div> <div>Ваш билет будет номером: <b>${nextTicketIndex.toString()}</b></div>
<div>Осталось лимита до паузы: <b>${centsToUsdStr(remainingByTotal)} USD</b></div>
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: лимит заполнен." : "Покупка доступна."}</div>
`; `;
if (activeEdit === "usd") syncFromUsd();
else syncFromSol();
} catch (e) { } catch (e) {
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`; el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
} }
} }
async function buyTicket() { async function buyByUsd() {
const out = document.getElementById("buyResult"); const out = document.getElementById("buyResult");
out.textContent = ""; out.textContent = "";
try { try {
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) { if (!walletPubkey) await connectWallet();
await connectWallet(); else if (!provider.isConnected) await provider.connect();
} else if (!provider.isConnected) { const { pdas, config, queues, coef, pyth } = lastState || await loadCoreState();
await provider.connect(); if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
}
const { pdas, config, coef, queues } = await loadCoreState(); const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
const currentDebt = queues.q1SumTotal - queues.q1SumPaid; const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
if (currentDebt >= coef.limit) { const payLamports = usdCentsToLamportsCeil(usdCents, pyth);
out.innerHTML = `<span class="warn">Пока временно приостановлено: очередь заполнена. После изменения коэффициента/лимита покупка снова заработает.</span>`; const maxPayLamports = applySlippageUp(payLamports, slippage);
return;
}
const amountLamports = solToLamports(document.getElementById("amountSol").value.trim());
const recipientRaw = document.getElementById("recipient").value.trim(); const recipientRaw = document.getElementById("recipient").value.trim();
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey; const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
const nextIndex = queues.q1Total + 1n; const nextIndex = queues.q1Total + 1n;
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync( const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], PROGRAM_ID);
[utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], const disc = await ixDiscriminator("buy_ticket_usd");
PROGRAM_ID const data = concat(disc, u64ToBytes(usdCents), u64ToBytes(maxPayLamports), recipient.toBytes());
);
const disc = await ixDiscriminator("buy_ticket");
const data = concat(disc, u64ToBytes(amountLamports), recipient.toBytes());
const keys = [ const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, { pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: pdas.configPda, isSigner: false, isWritable: true }, { pubkey: pdas.configPda, isSigner: false, isWritable: true },
@ -241,6 +363,47 @@
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true }, { pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: ticketPda, isSigner: false, isWritable: true }, { pubkey: ticketPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true }, { pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Готово. Tx: <code>${sig}</code></span>`;
await refreshState();
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function buyBySol() {
const out = document.getElementById("buyResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect();
const { pdas, config, queues, coef, pyth } = lastState || await loadCoreState();
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
const lamports = solTextToLamports(document.getElementById("amountSol").value);
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
const usdCents = lamportsToUsdCentsFloor(lamports, pyth);
const minUsdCents = applySlippageDown(usdCents, slippage);
const recipientRaw = document.getElementById("recipient").value.trim();
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
const nextIndex = queues.q1Total + 1n;
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], PROGRAM_ID);
const disc = await ixDiscriminator("buy_ticket_sol");
const data = concat(disc, u64ToBytes(lamports), u64ToBytes(minUsdCents), recipient.toBytes());
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: ticketPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
]; ];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data }); const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
@ -254,7 +417,11 @@
document.getElementById("connectBtn").addEventListener("click", connectWallet); document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshState); document.getElementById("refreshBtn").addEventListener("click", refreshState);
document.getElementById("buyBtn").addEventListener("click", buyTicket); document.getElementById("buyUsdBtn").addEventListener("click", buyByUsd);
document.getElementById("buySolBtn").addEventListener("click", buyBySol);
document.getElementById("amountUsd").addEventListener("input", () => { activeEdit = "usd"; syncFromUsd(); });
document.getElementById("amountSol").addEventListener("input", () => { activeEdit = "sol"; syncFromSol(); });
document.getElementById("slippagePct").addEventListener("input", renderQuote);
refreshState(); refreshState();
</script> </script>
</body> </body>

View File

@ -0,0 +1,342 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DAO revoke vote — Shine Payments Devnet</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: 1200px; }
.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; }
label { display: inline-flex; flex-direction: column; gap: 6px; color: var(--muted); min-width: 280px; }
input { padding: 9px 10px; 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; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
</style>
</head>
<body>
<div class="wrap">
<div style="margin-bottom: 12px;"><a class="back" href="./index.html">На главную</a></div>
<h1>DAO: голосование на revoke/burn membership token (Devnet)</h1>
<div class="muted">Governance program: <code id="govPid"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить Phantom</button>
</div>
<div id="walletInfo" class="muted">Кошелек: не подключен</div>
</div>
<div class="panel">
<div class="row">
<label>Realm
<input id="realm" placeholder="Realm pubkey" />
</label>
<label>Governance
<input id="governance" placeholder="Governance pubkey" />
</label>
<label>Community mint
<input id="mint" placeholder="Mint pubkey" />
</label>
</div>
<div class="row">
<label>Target owner
<input id="targetOwner" placeholder="Кого лишаем governance token" />
</label>
<label>Amount
<input id="amount" value="1" />
</label>
</div>
<div class="row">
<button id="createVoteBtn">Create + SignOff + Vote</button>
</div>
<div id="proposalResult" class="muted"></div>
</div>
<div class="panel">
<div class="row">
<label>Proposal
<input id="proposal" placeholder="Proposal pubkey" />
</label>
<label>Proposal transaction
<input id="proposalTx" placeholder="ProposalTransaction pubkey" />
</label>
</div>
<div class="row">
<button id="executeBtn">Execute revoke</button>
</div>
<div class="muted">Если получите hold-up (`0x20d`) — дождитесь конца voting window/hold-up и повторите execute.</div>
<div id="executeResult" class="muted"></div>
</div>
</div>
<script type="module">
import BN from "https://esm.sh/bn.js@5.2.1";
import {
Connection,
PublicKey,
Transaction,
clusterApiUrl
} from "https://esm.sh/@solana/web3.js@1.95.3";
import {
PROGRAM_VERSION_V3,
Vote,
YesNoVote,
VoteType,
InstructionData,
AccountMetaData,
withRevokeGoverningTokens,
withCreateProposal,
withInsertTransaction,
withSignOffProposal,
withCastVote,
withExecuteTransaction,
getTokenOwnerRecordAddress
} from "https://esm.sh/@solana/spl-governance@0.3.28";
const GOVERNANCE_PROGRAM_ID = new PublicKey("GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw");
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
document.getElementById("govPid").textContent = GOVERNANCE_PROGRAM_ID.toBase58();
let wallet = null;
let walletPubkey = null;
function out(id, html, cls = "muted") {
const el = document.getElementById(id);
el.className = cls;
el.innerHTML = html;
}
function mustPubkey(id) {
const raw = document.getElementById(id).value.trim();
if (!raw) throw new Error(`Пустое поле: ${id}`);
return new PublicKey(raw);
}
function toGovernanceInstructionData(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),
});
}
async function connect() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
wallet = window.solana;
const res = await wallet.connect();
walletPubkey = new PublicKey(res.publicKey.toString());
out("walletInfo", `Кошелек: <code>${walletPubkey.toBase58()}</code>`, "muted");
}
async function sendIxs(ixs) {
if (!walletPubkey) await connect();
const tx = new Transaction().add(...ixs);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await wallet.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
async function createSignVote() {
out("proposalResult", "Выполняю...", "muted");
try {
const realm = mustPubkey("realm");
const governance = mustPubkey("governance");
const mint = mustPubkey("mint");
const targetOwner = mustPubkey("targetOwner");
const amount = new BN(document.getElementById("amount").value.trim() || "1");
if (amount.lten(0)) throw new Error("Amount должен быть > 0");
const proposerRecord = await getTokenOwnerRecordAddress(
GOVERNANCE_PROGRAM_ID,
realm,
mint,
walletPubkey
);
const proposalName = `Revoke ${amount.toString()} from ${targetOwner.toBase58().slice(0, 8)}...`;
const proposalDescription = "https://arweave.net/";
const ixCreateProposal = [];
const proposalPk = await withCreateProposal(
ixCreateProposal,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
governance,
proposerRecord,
proposalName,
proposalDescription,
mint,
walletPubkey,
undefined,
VoteType.SINGLE_CHOICE,
["Approve"],
true,
walletPubkey
);
const txCreateProposal = await sendIxs(ixCreateProposal);
const ixRawRevoke = [];
await withRevokeGoverningTokens(
ixRawRevoke,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
targetOwner,
mint,
governance,
amount
);
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
const ixInsert = [];
const proposalTxPk = await withInsertTransaction(
ixInsert,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
governance,
proposalPk,
proposerRecord,
walletPubkey,
0,
0,
0,
[revokeInstructionData],
walletPubkey
);
const txInsert = await sendIxs(ixInsert);
const ixSignOff = [];
withSignOffProposal(
ixSignOff,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
governance,
proposalPk,
walletPubkey,
undefined,
proposerRecord
);
const txSignOff = await sendIxs(ixSignOff);
const ixVote = [];
const vote = Vote.fromYesNoVote(YesNoVote.Yes);
const voteRecordPk = await withCastVote(
ixVote,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
governance,
proposalPk,
proposerRecord,
proposerRecord,
walletPubkey,
mint,
vote,
walletPubkey
);
const txVote = await sendIxs(ixVote);
document.getElementById("proposal").value = proposalPk.toBase58();
document.getElementById("proposalTx").value = proposalTxPk.toBase58();
out(
"proposalResult",
`Proposal: <code>${proposalPk.toBase58()}</code><br/>` +
`ProposalTx: <code>${proposalTxPk.toBase58()}</code><br/>` +
`VoteRecord: <code>${voteRecordPk.toBase58()}</code><br/>` +
`Tx create: <code>${txCreateProposal}</code><br/>` +
`Tx insert: <code>${txInsert}</code><br/>` +
`Tx signOff: <code>${txSignOff}</code><br/>` +
`Tx vote: <code>${txVote}</code>`,
"ok"
);
} catch (e) {
out("proposalResult", String(e?.message || e), "err");
}
}
async function executeRevoke() {
out("executeResult", "Выполняю execute...", "muted");
try {
const governance = mustPubkey("governance");
const proposal = mustPubkey("proposal");
const proposalTx = mustPubkey("proposalTx");
const realm = mustPubkey("realm");
const mint = mustPubkey("mint");
const targetOwner = mustPubkey("targetOwner");
const amount = new BN(document.getElementById("amount").value.trim() || "1");
const ixRawRevoke = [];
await withRevokeGoverningTokens(
ixRawRevoke,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
targetOwner,
mint,
governance,
amount
);
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
const ixExecute = [];
await withExecuteTransaction(
ixExecute,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
governance,
proposal,
proposalTx,
[revokeInstructionData]
);
const sig = await sendIxs(ixExecute);
out("executeResult", `Execute success. Tx: <code>${sig}</code>`, "ok");
} catch (e) {
const msg = String(e?.message || e);
out("executeResult", msg, "err");
}
}
document.getElementById("connectBtn").addEventListener("click", async () => {
try { await connect(); } catch (e) { out("walletInfo", String(e?.message || e), "err"); }
});
document.getElementById("createVoteBtn").addEventListener("click", createSignVote);
document.getElementById("executeBtn").addEventListener("click", executeRevoke);
</script>
</body>
</html>

View File

@ -5,67 +5,90 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DAO-права менеджеров — Shine Payments Devnet</title> <title>DAO-права менеджеров — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } 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); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.wrap { width: 100%; max-width: 1800px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 240px; } input { padding: 9px 10px; min-width: 220px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 8px 12px; cursor: pointer; } button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
.muted { color: #666; } button:hover { background: var(--btn-hover); }
.ok { color: #0a7a3c; } .muted { color: var(--muted); }
.warn { color: #9f5f00; } .ok { color: var(--ok); }
.err { color: #b30000; white-space: pre-wrap; } .warn { color: var(--warn); }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; } .err { color: var(--err); white-space: pre-wrap; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style> </style>
</head> </head>
<body> <body>
<h1>DAO: права менеджеров (Devnet)</h1> <div class="wrap">
<div class="muted">Программа: <code id="programId"></code></div> <div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>DAO: права менеджеров (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel"> <div class="panel">
<div class="warn"> <div class="warn">
Пока реального DAO-голосования нет: роль DAO выполняет обычный кошелек.<br /> Пока реального DAO-голосования нет: роль DAO выполняет тестовый кошелек
Позже это заменяется на вызов из DAO-казначейства/голосования. <code>FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P</code>.<br />
Позже это заменяется на вызов из настоящего DAO-казначейства/голосования.
</div>
</div> </div>
</div>
<div class="panel"> <div class="panel">
<div class="row"> <div class="row">
<button id="connectBtn">Подключить кошелек</button> <button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить</button> <button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
<div id="daoInfo" class="muted"></div>
</div> </div>
<div id="walletInfo" class="muted"></div>
<div id="daoInfo" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Выдать/добавить лимиты менеджеру</h3> <h3>Выдать/добавить лимиты менеджеру</h3>
<div class="row"> <div class="row">
<label>Кошелек менеджера: <input id="managerWallet" placeholder="Base58" /></label> <label>Кошелек менеджера: <input id="managerWallet" placeholder="Base58" /></label>
<label>Добавить лимит Q1 (SOL): <input id="addQ1" value="1" /></label> <label>Добавить лимит Q1 (USD): <input id="addQ1" value="100" /></label>
<label>Добавить лимит Q2 (SOL): <input id="addQ2" value="0.5" /></label> <label>Добавить лимит Q2 (USD): <input id="addQ2" value="50" /></label>
</div>
<div class="row">
<button id="grantBtn">Выдать лимиты</button>
</div>
<div id="grantResult" class="muted"></div>
</div> </div>
<div class="row">
<button id="grantBtn">Выдать лимиты</button>
</div>
<div id="grantResult" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Текущие лимиты менеджера</h3> <h3>Текущие лимиты менеджера</h3>
<div class="row"> <div class="row">
<button id="loadManagerBtn">Показать лимиты</button> <button id="loadManagerBtn">Показать лимиты</button>
</div>
<div id="managerState" class="muted"></div>
</div> </div>
<div id="managerState" class="muted"></div>
</div> </div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script> <script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script> <script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"); const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
const RPC_URL = "https://api.devnet.solana.com"; const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed"); const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = { const SEEDS = {
config: "shine_payments_v2_config", config: "shine_payments_v3_config",
managerAllowance: "shine_payments_v2_manager_allowance", managerAllowance: "shine_p_v3_manager_allow",
}; };
let walletPubkey = null; let walletPubkey = null;
let configCache = null; let configCache = null;
@ -93,13 +116,13 @@
function trimZeros(v) { function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, ""); return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
} }
function lamportsToSolStr(l) { function centsToUsdStr(c) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9)); return trimZeros((Number(c) / 100).toFixed(2));
} }
function solToLamports(solStr) { function usdToCents(usdStr) {
const v = Number(solStr); const v = Number(usdStr.replace(",", "."));
if (!Number.isFinite(v) || v < 0) throw new Error("Некорректная сумма SOL"); if (!Number.isFinite(v) || v < 0) throw new Error("Некорректная сумма USD");
return BigInt(Math.round(v * 1_000_000_000)); return BigInt(Math.round(v * 100));
} }
async function ixDiscriminator(name) { async function ixDiscriminator(name) {
const msg = utf8("global:" + name); const msg = utf8("global:" + name);
@ -115,10 +138,8 @@
let o = 0; let o = 0;
const version = data[o++]; const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const reward = readU64(data, o); o += 8; return { version, dao, inflow };
return { version, dao, manager, inflow, reward };
} }
function parseManagerAllowance(data) { function parseManagerAllowance(data) {
let o = 0; let o = 0;
@ -190,11 +211,11 @@
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) await connectWallet(); if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect(); else if (!provider.isConnected) await provider.connect();
const { configPda, config } = configCache || await loadConfig();
const { configPda } = configCache || await loadConfig();
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim()); const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
const addQ1 = solToLamports(document.getElementById("addQ1").value.trim()); const addQ1 = usdToCents(document.getElementById("addQ1").value.trim());
const addQ2 = solToLamports(document.getElementById("addQ2").value.trim()); const addQ2 = usdToCents(document.getElementById("addQ2").value.trim());
if (addQ1 === 0n && addQ2 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди."); if (addQ1 === 0n && addQ2 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди.");
const allowancePda = deriveManagerAllowancePda(manager); const allowancePda = deriveManagerAllowancePda(manager);
@ -235,8 +256,8 @@
out.innerHTML = ` out.innerHTML = `
<div>Manager: <code>${st.manager.toBase58()}</code></div> <div>Manager: <code>${st.manager.toBase58()}</code></div>
<div>PDA: <code>${allowancePda.toBase58()}</code></div> <div>PDA: <code>${allowancePda.toBase58()}</code></div>
<div>Доступно Q1: <b>${lamportsToSolStr(st.q1)} SOL</b></div> <div>Доступно Q1: <b>${centsToUsdStr(st.q1)} USD</b></div>
<div>Доступно Q2: <b>${lamportsToSolStr(st.q2)} SOL</b></div> <div>Доступно Q2: <b>${centsToUsdStr(st.q2)} USD</b></div>
`; `;
} catch (e) { } catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`; out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;

View File

@ -5,65 +5,86 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Главная — Shine Payments Devnet</title> <title>Главная — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
--hover: #1f2634;
--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); }
a.card { a.card {
display: block; display: block;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
border: 1px solid #ddd; border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 14px; padding: 14px;
margin-bottom: 12px; margin-bottom: 12px;
background: var(--panel);
} }
a.card:hover { background: #fafafa; } a.card:hover { background: var(--hover); }
.muted { color: #666; } .muted { color: var(--muted); }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style> </style>
</head> </head>
<body> <body>
<h1>Shine Payments Devnet</h1> <div class="wrap">
<div class="panel"> <h1>Shine Payments Devnet</h1>
<div>Выберите страницу:</div> <div class="panel">
<div>Выберите страницу:</div>
</div>
<a class="card" href="./buy_ticket.html">
<h3>Покупка билета</h3>
<div class="muted">Создание нового билета в 1-й очереди: ввод в USD или SOL, хранение в USD-центах.</div>
</a>
<a class="card" href="./track_ticket.html">
<h3>Отслеживание билета</h3>
<div class="muted">Проверка позиции, статуса и шага выплат с SOL/USD курсом Pyth.</div>
</a>
<a class="card" href="./admin_tools.html">
<h3>Тех. инструменты</h3>
<div class="muted">Init, просмотр всех билетов, коэффициент/лимит в USD, награда шага в SOL.</div>
</a>
<a class="card" href="./dao_tools.html">
<h3>DAO-права менеджеров</h3>
<div class="muted">Выдача лимитов менеджерам в USD для добавления билетов в очередь 1/2.</div>
</a>
<a class="card" href="./dao_revoke_vote.html">
<h3>DAO revoke governance token</h3>
<div class="muted">UI для proposal/vote/execute на отзыв (burn/revoke) membership governance токенов.</div>
</a>
<a class="card" href="./manager_tools.html">
<h3>Инструменты менеджера</h3>
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2 в USD.</div>
</a>
<a class="card" href="./logic_overview.html">
<h3>Логика работы</h3>
<div class="muted">Кратко: как работают очереди, выплаты, лимиты и тестовый режим.</div>
</a>
<a class="card" href="./roadmap_dao.html">
<h3>Что ещё нужно до реального DAO</h3>
<div class="muted">Ограничения тестовой версии и шаги к production.</div>
</a>
<a class="card" href="./test_plan.html">
<h3>Сценарий тестирования</h3>
<div class="muted">Пошаговая методика тестов и возврата средств после теста.</div>
</a>
</div> </div>
<a class="card" href="./buy_ticket.html">
<h3>Покупка билета</h3>
<div class="muted">Создание нового билета в 1-й очереди.</div>
</a>
<a class="card" href="./track_ticket.html">
<h3>Отслеживание билета</h3>
<div class="muted">Проверка позиции, статуса и шаг выплат по 1-й/2-й очереди.</div>
</a>
<a class="card" href="./admin_tools.html">
<h3>Тех. инструменты</h3>
<div class="muted">Init, просмотр всех билетов в обеих очередях, коэффициент/лимит, агрегаты.</div>
</a>
<a class="card" href="./dao_tools.html">
<h3>DAO-права менеджеров</h3>
<div class="muted">Выдача лимитов менеджерам на добавление билетов в очередь 1/2.</div>
</a>
<a class="card" href="./manager_tools.html">
<h3>Инструменты менеджера</h3>
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2.</div>
</a>
<a class="card" href="./logic_overview.html">
<h3>Логика работы</h3>
<div class="muted">Кратко: как работают очереди, выплаты, лимиты и тестовый режим.</div>
</a>
<a class="card" href="./roadmap_dao.html">
<h3>Что ещё нужно до реального DAO</h3>
<div class="muted">Ограничения тестовой версии и шаги к production.</div>
</a>
<a class="card" href="./test_plan.html">
<h3>Сценарий тестирования</h3>
<div class="muted">Пошаговая методика тестов и возврата средств после теста.</div>
</a>
</body> </body>
</html> </html>

View File

@ -5,16 +5,30 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Логика работы — Shine Payments Devnet</title> <title>Логика работы — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; line-height: 1.45; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } color-scheme: dark;
.muted { color: #666; } --bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
--code: #1e2633;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; max-width: 1800px; line-height: 1.45; background: var(--bg); color: var(--text); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
.muted { color: var(--muted); }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style> </style>
</head> </head>
<body> <body>
<div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Логика работы Shine Payments (тестовый этап)</h1> <h1>Логика работы Shine Payments (тестовый этап)</h1>
<div class="panel"> <div class="panel">
<p>Сейчас система работает в <b>Devnet</b>. Все суммы на этом этапе в SOL/lamports.</p> <p>Система работает в <b>Devnet</b>. Экономика хранится в <b>USD-центах</b>, а реальные переводы происходят в SOL.</p>
<p>В следующей версии расчёт будет считаться в <b>USDT по курсу</b> (переход на курсовую модель).</p> <p>Курс SOL/USD берётся из Pyth прямо в контракте при покупке и при шаге выплаты. Цена проверяется на актуальность (не старше 120 секунд).</p>
</div> </div>
<div class="panel"> <div class="panel">
@ -24,7 +38,7 @@
<div class="panel"> <div class="panel">
<h3>2. Покупка билета</h3> <h3>2. Покупка билета</h3>
<p>Обычная покупка создаёт билет только в очереди 1. Сумма покупки идёт в DAO, а сумма билета рассчитывается как <code>input * coef</code>.</p> <p>Обычная покупка создаёт билет только в очереди 1. Пользователь может ввести сумму в USD или SOL на UI. В контракте сумма переводится по курсу в USD-центы, а выплата билета рассчитывается как <code>purchase_usd_cents * coef</code>.</p>
</div> </div>
<div class="panel"> <div class="panel">
@ -35,6 +49,7 @@
<div class="panel"> <div class="panel">
<h3>4. Порядок выплат</h3> <h3>4. Порядок выплат</h3>
<p>Приоритет всегда у очереди 1. Если в очереди 1 нет невыплаченных билетов, идут выплаты очереди 2. Если в процессе выплат очереди 2 снова появляется билет в очереди 1, приоритет возвращается к очереди 1.</p> <p>Приоритет всегда у очереди 1. Если в очереди 1 нет невыплаченных билетов, идут выплаты очереди 2. Если в процессе выплат очереди 2 снова появляется билет в очереди 1, приоритет возвращается к очереди 1.</p>
<p>Шаг выплаты: для очереди 1 в DAO уходит 1x от выплаты тикета, для очереди 2 в DAO уходит 2x от выплаты тикета. Дополнительно вызывающий получает награду в SOL.</p>
<p>Если обе очереди пустые/выплачены — доступный остаток inflow-вольта переводится в DAO (без награды вызывающему).</p> <p>Если обе очереди пустые/выплачены — доступный остаток inflow-вольта переводится в DAO (без награды вызывающему).</p>
</div> </div>

View File

@ -5,63 +5,85 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Менеджерские билеты — Shine Payments Devnet</title> <title>Менеджерские билеты — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } 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); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.wrap { width: 100%; max-width: 1800px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input, select { padding: 8px; min-width: 200px; } input, select { padding: 9px 10px; min-width: 190px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 8px 12px; cursor: pointer; } button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
.muted { color: #666; } button:hover { background: var(--btn-hover); }
.ok { color: #0a7a3c; } .muted { color: var(--muted); }
.warn { color: #9f5f00; } .ok { color: var(--ok); }
.err { color: #b30000; white-space: pre-wrap; } .warn { color: var(--warn); }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; } .err { color: var(--err); white-space: pre-wrap; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style> </style>
</head> </head>
<body> <body>
<h1>Менеджер: создание билетов (Devnet)</h1> <div class="wrap">
<div class="muted">Программа: <code id="programId"></code></div> <div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Менеджер: создание билетов (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel"> <div class="panel">
<div class="row"> <div class="row">
<button id="connectBtn">Подключить кошелек менеджера</button> <button id="connectBtn">Подключить кошелек менеджера</button>
<button id="refreshBtn">Обновить</button> <button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
</div> </div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Лимиты менеджера</h3> <h3>Лимиты менеджера</h3>
<div id="limitsInfo" class="muted">Загрузка...</div> <div id="limitsInfo" class="muted">Загрузка...</div>
</div> </div>
<div class="panel"> <div class="panel">
<h3>Создать билет менеджером</h3> <h3>Создать билет менеджером</h3>
<div class="row"> <div class="row">
<label>Очередь: <label>Очередь:
<select id="queueId"> <select id="queueId">
<option value="1">Очередь 1</option> <option value="1">Очередь 1</option>
<option value="2">Очередь 2</option> <option value="2">Очередь 2</option>
</select> </select>
</label> </label>
<label>Получатель: <input id="recipient" placeholder="Base58 адрес" /></label> <label>Получатель: <input id="recipient" placeholder="Base58 адрес" /></label>
<label>Сумма выплаты (SOL): <input id="payoutSol" value="0.5" /></label> <label>Сумма выплаты (USD): <input id="payoutUsd" value="50" /></label>
</div>
<div class="row">
<button id="createBtn">Создать билет</button>
</div>
<div id="createResult" class="muted"></div>
</div> </div>
<div class="row">
<button id="createBtn">Создать билет</button>
</div>
<div id="createResult" class="muted"></div>
</div> </div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script> <script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script> <script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"); const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
const RPC_URL = "https://api.devnet.solana.com"; const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed"); const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = { const SEEDS = {
managerAllowance: "shine_payments_v2_manager_allowance", managerAllowance: "shine_p_v3_manager_allow",
queues: "shine_payments_v2_queues", queues: "shine_payments_v3_queues",
ticketQ1: "shine_payments_v2_q1_ticket", ticketQ1: "shine_payments_v3_q1_ticket",
ticketQ2: "shine_payments_v2_q2_ticket", ticketQ2: "shine_payments_v3_q2_ticket",
}; };
let walletPubkey = null; let walletPubkey = null;
let queuesCache = null; let queuesCache = null;
@ -89,13 +111,13 @@
function trimZeros(v) { function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, ""); return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
} }
function lamportsToSolStr(l) { function centsToUsdStr(c) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9)); return trimZeros((Number(c) / 100).toFixed(2));
} }
function solToLamports(solStr) { function usdToCents(usdStr) {
const v = Number(solStr); const v = Number(usdStr.replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL"); if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
return BigInt(Math.round(v * 1_000_000_000)); return BigInt(Math.round(v * 100));
} }
async function ixDiscriminator(name) { async function ixDiscriminator(name) {
const msg = utf8("global:" + name); const msg = utf8("global:" + name);
@ -199,8 +221,8 @@
el.innerHTML = ` el.innerHTML = `
<div>Manager: <code>${core.allowance.manager.toBase58()}</code></div> <div>Manager: <code>${core.allowance.manager.toBase58()}</code></div>
<div>PDA лимитов: <code>${core.allowancePda.toBase58()}</code></div> <div>PDA лимитов: <code>${core.allowancePda.toBase58()}</code></div>
<div>Доступно Q1: <b>${lamportsToSolStr(core.allowance.q1)} SOL</b></div> <div>Доступно Q1: <b>${centsToUsdStr(core.allowance.q1)} USD</b></div>
<div>Доступно Q2: <b>${lamportsToSolStr(core.allowance.q2)} SOL</b></div> <div>Доступно Q2: <b>${centsToUsdStr(core.allowance.q2)} USD</b></div>
`; `;
} catch (e) { } catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`; el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
@ -221,7 +243,7 @@
const queueId = Number(document.getElementById("queueId").value); const queueId = Number(document.getElementById("queueId").value);
if (queueId !== 1 && queueId !== 2) throw new Error("Очередь должна быть 1 или 2"); if (queueId !== 1 && queueId !== 2) throw new Error("Очередь должна быть 1 или 2");
const recipient = new solanaWeb3.PublicKey(document.getElementById("recipient").value.trim()); const recipient = new solanaWeb3.PublicKey(document.getElementById("recipient").value.trim());
const payout = solToLamports(document.getElementById("payoutSol").value.trim()); const payout = usdToCents(document.getElementById("payoutUsd").value.trim());
const nextIndex = queueId === 1 ? (core.queues.q1Total + 1n) : (core.queues.q2Total + 1n); const nextIndex = queueId === 1 ? (core.queues.q1Total + 1n) : (core.queues.q2Total + 1n);
const ticketPda = deriveTicketPda(queueId, nextIndex); const ticketPda = deriveTicketPda(queueId, nextIndex);

View File

@ -5,11 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Что ещё нужно до DAO — Shine Payments Devnet</title> <title>Что ещё нужно до DAO — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; line-height: 1.45; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; max-width: 1800px; line-height: 1.45; background: var(--bg); color: var(--text); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
</style> </style>
</head> </head>
<body> <body>
<div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Что ещё нужно до реального DAO</h1> <h1>Что ещё нужно до реального DAO</h1>
<div class="panel"> <div class="panel">
@ -21,7 +33,7 @@
<ol> <ol>
<li>Заменить обычный DAO-кошелёк на DAO-казначейство/голосование.</li> <li>Заменить обычный DAO-кошелёк на DAO-казначейство/голосование.</li>
<li>DAO-решения (выдача лимитов менеджерам, изменение параметров) проводить через голосование.</li> <li>DAO-решения (выдача лимитов менеджерам, изменение параметров) проводить через голосование.</li>
<li>Переход с SOL-сумм на модель расчёта в USDT по курсу.</li> <li>Зафиксировать production-источник цены (oracle governance, fallback-политика, мониторинг stale-данных).</li>
<li>Ограничить тестовые ключи и закрыть доступ к приватным данным.</li> <li>Ограничить тестовые ключи и закрыть доступ к приватным данным.</li>
</ol> </ol>
</div> </div>

View File

@ -5,23 +5,35 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Сценарий тестирования — Shine Payments Devnet</title> <title>Сценарий тестирования — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; line-height: 1.45; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; max-width: 1800px; line-height: 1.45; background: var(--bg); color: var(--text); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
</style> </style>
</head> </head>
<body> <body>
<div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Сценарий тестирования Shine Payments (Devnet)</h1> <h1>Сценарий тестирования Shine Payments (Devnet)</h1>
<div class="panel"> <div class="panel">
<h3>Вариант А: один кошелёк</h3> <h3>Вариант А: один кошелёк</h3>
<ol> <ol>
<li>Открыть <code>admin_tools</code>, выполнить <code>init</code>.</li> <li>Открыть <code>admin_tools</code>, выполнить <code>init</code>.</li>
<li>Открыть <code>buy_ticket</code>, купить несколько билетов.</li> <li>Открыть <code>buy_ticket</code>, купить несколько билетов (часть через USD, часть через SOL).</li>
<li>Открыть <code>dao_tools</code>, выдать лимиты менеджеру (тем же кошельком).</li> <li>Открыть <code>dao_tools</code>, выдать лимиты менеджеру (тем же кошельком).</li>
<li>Открыть <code>manager_tools</code>, создать билеты в очередь 1 и очередь 2.</li> <li>Открыть <code>manager_tools</code>, создать билеты в очередь 1 и очередь 2.</li>
<li>Пополнить inflow-вольт вручную.</li> <li>Пополнить inflow-вольт вручную.</li>
<li>Открыть <code>track_ticket</code>, выполнять шаги выплат до погашения очередей.</li> <li>Открыть <code>track_ticket</code>, выполнять шаги выплат до погашения очередей.</li>
<li>Проверить, что средства уходят получателям/DAO в ожидаемой пропорции.</li> <li>Проверить, что в шагах: Q1 = ticket + DAO(1x) + reward, Q2 = ticket + DAO(2x) + reward.</li>
</ol> </ol>
</div> </div>
@ -30,7 +42,7 @@
<ol> <ol>
<li>Кошелёк 1: DAO (выдаёт лимиты менеджерам).</li> <li>Кошелёк 1: DAO (выдаёт лимиты менеджерам).</li>
<li>Кошелёк 2: менеджер (создаёт билеты в очередь 1/2).</li> <li>Кошелёк 2: менеджер (создаёт билеты в очередь 1/2).</li>
<li>Кошелёк 3+: покупатели (создают обычные билеты через покупку).</li> <li>Кошельки 3+: покупатели (создают обычные билеты через покупку).</li>
<li>Любой кошелёк может запускать шаг выплат.</li> <li>Любой кошелёк может запускать шаг выплат.</li>
</ol> </ol>
</div> </div>
@ -45,7 +57,7 @@
</div> </div>
<div class="panel"> <div class="panel">
<p>Пока DAO-гovernance не подключена, ключевые действия DAO выполняются обычным тестовым кошельком. В production это заменяется голосованием DAO.</p> <p>Пока DAO-governance не подключена, ключевые действия DAO выполняются обычным тестовым кошельком. В production это заменяется голосованием DAO.</p>
</div> </div>
</body> </body>
</html> </html>

View File

@ -5,63 +5,92 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Отслеживание билета — Shine Payments Devnet</title> <title>Отслеживание билета — Shine Payments Devnet</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } :root {
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } 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); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.wrap { width: 100%; max-width: 1850px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 240px; } input { padding: 9px 10px; min-width: 240px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 8px 12px; cursor: pointer; } button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
.muted { color: #666; } button:hover { background: var(--btn-hover); }
.ok { color: #0a7a3c; } .muted { color: var(--muted); }
.warn { color: #9f5f00; } .ok { color: var(--ok); }
.err { color: #b30000; white-space: pre-wrap; } .warn { color: var(--warn); }
.paid { color: #0a7a3c; font-weight: 700; } .err { color: var(--err); white-space: pre-wrap; }
.waiting { color: #666; } .paid { color: var(--ok); font-weight: 700; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; } .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> </style>
</head> </head>
<body> <body>
<h1>Отслеживание билета (Devnet)</h1> <div class="wrap">
<div class="muted">Программа: <code id="programId"></code></div> <div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<h1>Отслеживание билета (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel"> <div class="panel">
<div class="row"> <div class="row">
<button id="connectBtn">Подключить кошелек</button> <button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить</button> <button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
</div> </div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Поиск билетов</h3> <h3>Поиск билетов</h3>
<div class="row"> <div class="row">
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label> <label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label> <label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
<button id="findBtn">Найти</button> <button id="findBtn">Найти</button>
</div>
<div id="ticketResult" class="muted"></div>
</div> </div>
<div id="ticketResult" class="muted"></div>
</div>
<div class="panel"> <div class="panel">
<h3>Состояние шага выплат</h3> <h3>Состояние шага выплат</h3>
<div id="payoutInfo" class="muted">Загрузка...</div> <div id="payoutInfo" class="muted">Загрузка...</div>
<div class="row"> <div class="row">
<button id="stepBtn">Сделать шаг выплат</button> <button id="stepBtn">Сделать шаг выплат</button>
</div>
<div id="stepResult" class="muted"></div>
<div class="warn">Вызывающий шаг выплат платит сетевую комиссию транзакции и получает on-chain награду. Идея в том, что награда делает вызов экономически выгодным, поэтому всегда есть мотивация нажимать кнопку шага выплат.</div>
<div class="muted">Автоматического таймера в контракте нет: в Solana любая инструкция должна быть инициирована внешним вызовом.</div>
</div> </div>
<div id="stepResult" class="muted"></div>
</div> </div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script> <script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script> <script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"); const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
const RPC_URL = "https://api.devnet.solana.com"; const RPC_URL = "https://api.devnet.solana.com";
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed"); const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = { const SEEDS = {
config: "shine_payments_v2_config", config: "shine_payments_v3_config",
queues: "shine_payments_v2_queues", coef: "shine_payments_v3_coef_limit",
ticketQ1: "shine_payments_v2_q1_ticket", queues: "shine_payments_v3_queues",
ticketQ2: "shine_payments_v2_q2_ticket", ticketQ1: "shine_payments_v3_q1_ticket",
ticketQ2: "shine_payments_v3_q2_ticket",
}; };
const LAMPORTS_PER_SOL = 1_000_000_000n;
let walletPubkey = null; let walletPubkey = null;
let cachedCore = null; let cachedCore = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58(); document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
@ -78,6 +107,16 @@
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i)); for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x; return x;
} }
function readI32(data, offset) {
let x = Number(readU64(data, offset) & 0xffffffffn);
if (x > 0x7fffffff) x -= 0x100000000;
return x;
}
function readI64(data, offset) {
let x = readU64(data, offset);
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
return x;
}
function concat(...parts) { function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0); const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len); const out = new Uint8Array(len);
@ -91,6 +130,9 @@
function lamportsToSolStr(l) { function lamportsToSolStr(l) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9)); return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
} }
function centsToUsdStr(c) {
return trimZeros((Number(c) / 100).toFixed(2));
}
async function ixDiscriminator(name) { async function ixDiscriminator(name) {
const msg = utf8("global:" + name); const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg); const hash = await crypto.subtle.digest("SHA-256", msg);
@ -100,15 +142,39 @@
const s = String(msg || "").toLowerCase(); const s = String(msg || "").toLowerCase();
return s.includes("notenoughinflowforstep") || s.includes("0x177a"); return s.includes("notenoughinflowforstep") || s.includes("0x177a");
} }
function parsePythPriceUpdateV2(data) {
const price = readI64(data, 73);
const exponent = readI32(data, 89);
const publishTime = readI64(data, 93);
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
let num = price * 100n;
let den = 1n;
if (exponent >= 0) num *= 10n ** BigInt(exponent);
else den *= 10n ** BigInt(-exponent);
return { num, den, publishTime };
}
function usdCentsToLamportsCeil(usdCents, px) {
const n = usdCents * LAMPORTS_PER_SOL * px.den;
return (n + px.num - 1n) / px.num;
}
function usdCentsToSolStr(usdCents, px) {
return lamportsToSolStr(usdCentsToLamportsCeil(usdCents, px));
}
function parseConfig(data) { function parseConfig(data) {
let o = 0; let o = 0;
const version = data[o++]; const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
return { version, dao, inflow };
}
function parseCoef(data) {
let o = 0;
const version = data[o++];
const coefPpm = readU64(data, o); o += 8;
const limitUsdCents = readU64(data, o); o += 8;
const reward = readU64(data, o); o += 8; const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward }; return { version, coefPpm, limitUsdCents, reward };
} }
function parseQueues(data) { function parseQueues(data) {
let o = 0; let o = 0;
@ -130,9 +196,9 @@
const index = readU64(data, o); o += 8; const index = readU64(data, o); o += 8;
const isPaid = data[o++] === 1; const isPaid = data[o++] === 1;
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32; const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const payout = readU64(data, o); o += 8; const payoutUsdCents = readU64(data, o); o += 8;
const debtBefore = readU64(data, o); o += 8; const debtBeforeUsdCents = readU64(data, o); o += 8;
return { version, queueId, index, isPaid, recipient, payout, debtBefore }; return { version, queueId, index, isPaid, recipient, payoutUsdCents, debtBeforeUsdCents };
} }
function getProvider() { function getProvider() {
@ -161,8 +227,9 @@
function deriveCorePdas() { function deriveCorePdas() {
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID); const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
const [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID); const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return { configPda, queuesPda }; return { configPda, coefPda, queuesPda };
} }
function deriveTicketPda(queueId, index) { function deriveTicketPda(queueId, index) {
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2; const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
@ -172,18 +239,23 @@
async function loadCoreState() { async function loadCoreState() {
const pdas = deriveCorePdas(); const pdas = deriveCorePdas();
const [cfgAi, qAi] = await Promise.all([ const [cfgAi, coefAi, qAi, oracleAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"), connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.coefPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"), connection.getAccountInfo(pdas.queuesPda, "confirmed"),
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
]); ]);
if (!cfgAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки."); if (!cfgAi || !coefAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки.");
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
const config = parseConfig(cfgAi.data); const config = parseConfig(cfgAi.data);
const coef = parseCoef(coefAi.data);
const queues = parseQueues(qAi.data); const queues = parseQueues(qAi.data);
const pyth = parsePythPriceUpdateV2(oracleAi.data);
const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed"); const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed");
if (!inflowAi) throw new Error("Inflow vault отсутствует"); if (!inflowAi) throw new Error("Inflow vault отсутствует");
const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"); const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed");
const available = BigInt(Math.max(0, inflowAi.lamports - rentMin)); const available = BigInt(Math.max(0, inflowAi.lamports - rentMin));
cachedCore = { pdas, config, queues, inflowAi, available }; cachedCore = { pdas, config, coef, queues, pyth, available };
return cachedCore; return cachedCore;
} }
@ -194,17 +266,27 @@
if (q2Pending > 0n) return 2; if (q2Pending > 0n) return 2;
return 0; 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() { async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo"); const el = document.getElementById("payoutInfo");
try { try {
const core = await loadCoreState(); const core = await loadCoreState();
const queue = nextStepQueue(core.queues); const queue = nextStepQueue(core.queues);
const pythAge = Math.max(0, Math.floor(Date.now() / 1000 - Number(core.pyth.publishTime)));
if (queue === 0) { if (queue === 0) {
el.innerHTML = ` el.innerHTML = `
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
<div>Обе очереди пусты/полностью выплачены.</div> <div>Обе очереди пусты/полностью выплачены.</div>
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div> <div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div class="warn">При шаге выплат эта сумма будет переведена в DAO, награда вызывающему не начисляется.</div> <div class="warn">При шаге эта сумма уйдет в DAO, награда не начисляется.</div>
`; `;
return; return;
} }
@ -216,13 +298,22 @@
return; return;
} }
const next = parseTicket(nextAi.data); const next = parseTicket(nextAi.data);
const need = next.payout * 2n + core.config.reward; const ticketLamports = usdCentsToLamportsCeil(next.payoutUsdCents, core.pyth);
const daoUsd = queue === 1 ? next.payoutUsdCents : (next.payoutUsdCents * 2n);
const daoLamports = usdCentsToLamportsCeil(daoUsd, core.pyth);
const need = ticketLamports + daoLamports + core.coef.reward;
const missing = core.available >= need ? 0n : (need - core.available); const missing = core.available >= need ? 0n : (need - core.available);
el.innerHTML = ` el.innerHTML = `
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div> <div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
<div>Следующий тикет: <b>#${next.index.toString()}</b></div> <div>Следующий тикет: <b>#${next.index.toString()}</b></div>
<div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} SOL</b></div> <div>Тикет: <b>${centsToUsdStr(next.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(next.payoutUsdCents, core.pyth)} SOL)</div>
<div>Нужно для шага (выплата + DAO + награда): <b>${lamportsToSolStr(need)} SOL</b></div> <div>DAO на этом шаге: <b>${centsToUsdStr(daoUsd)} USD</b> (~${lamportsToSolStr(daoLamports)} SOL)</div>
<div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
<div>Нужно для шага: <b>${lamportsToSolStr(need)} SOL</b></div>
<div>Формула: <b>${queue === 1 ? "ticket + dao(1x) + reward" : "ticket + dao(2x) + reward"}</b></div>
<div>Доступно в inflow vault: <b>${lamportsToSolStr(core.available)} SOL</b></div> <div>Доступно в inflow vault: <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div>${missing === 0n <div>${missing === 0n
? '<span class="ok">Хватает для шага выплаты.</span>' ? '<span class="ok">Хватает для шага выплаты.</span>'
@ -235,33 +326,73 @@
} }
function renderTicketCard(core, pda, t) { function renderTicketCard(core, pda, t) {
const nextQ1 = core.queues.q1Paid + 1n; const next = nextPayoutTicket(core.queues);
const nextQ2 = core.queues.q2Paid + 1n; const isNext = !!next && next.queue === t.queueId && next.index === t.index;
const isCurrentQ1 = !t.isPaid && t.queueId === 1 && t.index === nextQ1; const isOwner = walletPubkey && walletPubkey.toBase58() === t.recipient.toBase58();
const isCurrentQ2 = !t.isPaid && t.queueId === 2 && (core.queues.q1Total - core.queues.q1Paid) === 0n && t.index === nextQ2; const canTransfer = !t.isPaid && isOwner && !isNext;
const inFront = t.queueId === 1 const whyBlocked = t.isPaid
? (t.index > nextQ1 ? (t.index - nextQ1) : 0n) ? "Тикет уже выплачен"
: (t.index > nextQ2 ? (t.index - nextQ2) : 0n); : !isOwner
const sumPaid = t.queueId === 1 ? core.queues.q1SumPaid : core.queues.q2SumPaid; ? "Передача доступна только текущему получателю тикета"
const remainingToThis = t.debtBefore > sumPaid ? (t.debtBefore - sumPaid) : 0n; : isNext
const missingInsideCurrent = (isCurrentQ1 || isCurrentQ2) && core.available < t.payout ? (t.payout - core.available) : 0n; ? "Это следующий тикет на выплату, передача заблокирована"
: "";
return ` return `
<div class="panel"> <div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div> <div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
<div>PDA: <code>${pda.toBase58()}</code></div> <div>PDA: <code>${pda.toBase58()}</code></div>
<div>Получатель: <code>${t.recipient.toBase58()}</code></div> <div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${lamportsToSolStr(t.payout)} SOL</b></div> <div>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
<div>Билетов перед ним сейчас: <b>${inFront.toString()}</b></div> <div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
<div>До его выплаты по сумме в предыдущих билетах осталось: <b>${lamportsToSolStr(remainingToThis)} SOL</b></div> <div class="xfer">
${t.queueId === 2 && !t.isPaid ? `<div class="warn">Для 2-й очереди оценка не окончательная: 1-я очередь может увеличиваться.</div>` : ``} <div><b>Передача билета</b></div>
${(isCurrentQ1 || isCurrentQ2) ? `<div class="warn">Это текущий билет к выплате.</div>` : ``} <div class="row">
${((isCurrentQ1 || isCurrentQ2) && missingInsideCurrent > 0n) <input id="newRecipient_${t.queueId}_${t.index.toString()}" placeholder="Новый получатель (Base58)" />
? `<div class="warn">Для выплаты именно этого билета внутри его суммы не хватает: <b>${lamportsToSolStr(missingInsideCurrent)} SOL</b>.</div>` <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> </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() { async function findTickets() {
const out = document.getElementById("ticketResult"); const out = document.getElementById("ticketResult");
out.textContent = ""; out.textContent = "";
@ -289,16 +420,13 @@
const ai = await connection.getAccountInfo(pda, "confirmed"); const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) continue; if (!ai) continue;
const t = parseTicket(ai.data); const t = parseTicket(ai.data);
if (t.recipient.toBase58() === recipient.toBase58()) { if (t.recipient.toBase58() === recipient.toBase58()) results.push({ pda, t });
results.push({ pda, t });
}
} }
} }
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены"); if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
} else { } else {
throw new Error("Введите номер билета или кошелек получателя"); throw new Error("Введите номер билета или кошелек получателя");
} }
out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join(""); out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
} catch (e) { } catch (e) {
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`; out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
@ -310,11 +438,9 @@
out.textContent = ""; out.textContent = "";
try { try {
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) { if (!walletPubkey) await connectWallet();
await connectWallet(); else if (!provider.isConnected) await provider.connect();
} else if (!provider.isConnected) {
await provider.connect();
}
const core = cachedCore || await loadCoreState(); const core = cachedCore || await loadCoreState();
const queue = nextStepQueue(core.queues); const queue = nextStepQueue(core.queues);
@ -337,10 +463,12 @@
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, { pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true }, { pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true }, { pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: core.pdas.coefPda, isSigner: false, isWritable: true },
{ pubkey: core.config.inflow, isSigner: false, isWritable: true }, { pubkey: core.config.inflow, isSigner: false, isWritable: true },
{ pubkey: nextTicketPda, isSigner: false, isWritable: true }, { pubkey: nextTicketPda, isSigner: false, isWritable: true },
{ pubkey: recipient, isSigner: false, isWritable: true }, { pubkey: recipient, isSigner: false, isWritable: true },
{ pubkey: core.config.dao, isSigner: false, isWritable: true }, { pubkey: core.config.dao, isSigner: false, isWritable: true },
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
]; ];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data }); const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix); const sig = await sendInstruction(ix);
@ -364,6 +492,14 @@
document.getElementById("refreshBtn").addEventListener("click", refreshAll); document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("findBtn").addEventListener("click", findTickets); document.getElementById("findBtn").addEventListener("click", findTickets);
document.getElementById("stepBtn").addEventListener("click", stepPayout); 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(); refreshAll();
</script> </script>
</body> </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

@ -1,18 +1,27 @@
use anchor_lang::prelude::*; use anchor_lang::prelude::*;
pub mod users;
pub mod settings; pub mod settings;
pub mod users;
use users::*; use users::*;
declare_id!("FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm");
declare_id!("8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ");
#[program] #[program]
pub mod shine { pub mod shine {
use super::*; use super::*;
pub fn init_users_economy_config(ctx: Context<InitUsersEconomyConfig>) -> Result<()> {
users::init_users_economy_config(ctx)
}
pub fn update_users_economy_config(
ctx: Context<UpdateUsersEconomyConfig>,
args: UpdateUsersEconomyConfigArgs,
) -> Result<()> {
users::update_users_economy_config(ctx, args)
}
pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) -> Result<()> { pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) -> Result<()> {
users::create_user_pda(ctx, args) users::create_user_pda(ctx, args)
} }

View File

@ -1,12 +1,28 @@
use common::deploy_config;
/// `USER_PDA_SEED_PREFIX` — префикс seed для пользовательского PDA (`login=<...>`).
pub const USER_PDA_SEED_PREFIX: &str = "login="; pub const USER_PDA_SEED_PREFIX: &str = "login=";
// Увеличили размер PDA, чтобы оставить запас под будущие расширения формата /// `USERS_ECONOMY_CONFIG_SEED` — seed PDA с экономическими параметрами программы `shine_users`.
// (в частности, сценарии ротации root key с дополнительной подписью старого ключа). pub const USERS_ECONOMY_CONFIG_SEED: &[u8] = b"shine_users_economy_config";
/// `USER_PDA_SPACE` — фиксированный размер PDA пользователя, включая запас под расширения формата.
pub const USER_PDA_SPACE: usize = 768; pub const USER_PDA_SPACE: usize = 768;
/// `USERS_ECONOMY_CONFIG_SPACE` — размер PDA с экономическими параметрами `shine_users`.
pub const USERS_ECONOMY_CONFIG_SPACE: usize = 8 + 96;
pub const REGISTRATION_FEE_RECEIVER: &str = "6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY"; /// `DAO_AUTHORITY` — адрес DAO-авторити, который имеет право обновлять economy-конфиг.
pub const REGISTRATION_FEE_LAMPORTS: u64 = 10_000_000; // 0.01 SOL pub const DAO_AUTHORITY: &str = deploy_config::DAO_AUTHORITY;
/// `SHINE_PAYMENTS_PROGRAM_ID` — адрес программы `shine_payments`, от которой вычисляется PDA inflow-вольта.
pub const SHINE_PAYMENTS_PROGRAM_ID: &str = deploy_config::SHINE_PAYMENTS_PROGRAM_ID;
/// `SHINE_PAYMENTS_INFLOW_VAULT_SEED` — seed inflow-вольта в программе `shine_payments` (должен совпадать с payments settings).
pub const SHINE_PAYMENTS_INFLOW_VAULT_SEED: &[u8] = b"shine_payments_inflow_vault";
/// `START_REGISTRATION_FEE_LAMPORTS` — стартовая комиссия регистрации (0.01 SOL) для initial economy-конфига.
pub const START_REGISTRATION_FEE_LAMPORTS: u64 = 10_000_000;
/// `LIMIT_STEP` — шаг пополнения лимита; `additional_limit` должен быть кратен этому значению.
pub const LIMIT_STEP: u64 = 10_000; pub const LIMIT_STEP: u64 = 10_000;
pub const LAMPORTS_PER_LIMIT_STEP: u64 = 100_000; // 0.0001 SOL за 10_000 лимита /// `START_LAMPORTS_PER_LIMIT_STEP` — стартовая цена одного шага лимита (0.0001 SOL за 10_000 лимита).
pub const START_LAMPORTS_PER_LIMIT_STEP: u64 = 100_000;
/// `START_BONUS_LIMIT` — стартовый бонус лимита, выдаваемый пользователю при создании записи.
pub const START_BONUS_LIMIT: u64 = 100_000; pub const START_BONUS_LIMIT: u64 = 100_000;

View File

@ -5,8 +5,8 @@ use anchor_lang::solana_program::{
hash::hashv, hash::hashv,
instruction::Instruction, instruction::Instruction,
program::invoke, program::invoke,
sysvar::instructions::{load_current_index_checked, load_instruction_at_checked},
system_instruction, system_instruction,
sysvar::instructions::{load_current_index_checked, load_instruction_at_checked},
}; };
use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode}; use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use std::str::FromStr; use std::str::FromStr;
@ -14,6 +14,7 @@ use std::str::FromStr;
const MAGIC: &[u8; 5] = b"SHiNE"; const MAGIC: &[u8; 5] = b"SHiNE";
const FORMAT_MAJOR: u8 = 1; const FORMAT_MAJOR: u8 = 1;
const FORMAT_MINOR: u8 = 0; const FORMAT_MINOR: u8 = 0;
const KEY_STATUS_CREATED: u8 = 0;
const RESERVED_BYTES: [u8; 5] = [0, 0, 0, 0, 0]; const RESERVED_BYTES: [u8; 5] = [0, 0, 0, 0, 0];
const ZERO_HASH: [u8; 32] = [0; 32]; const ZERO_HASH: [u8; 32] = [0; 32];
@ -58,8 +59,11 @@ pub struct UserRecord {
pub version: u32, pub version: u32,
pub prev_hash: [u8; 32], pub prev_hash: [u8; 32],
pub login: String, pub login: String,
pub root_key_status: u8,
pub root_key: Pubkey, pub root_key: Pubkey,
pub blockchain_key_status: u8,
pub blockchain_key: Pubkey, pub blockchain_key: Pubkey,
pub device_key_status: u8,
pub device_key: Pubkey, pub device_key: Pubkey,
pub chain_number: u16, pub chain_number: u16,
pub balance: u64, pub balance: u64,
@ -71,6 +75,14 @@ pub struct UserRecord {
pub signature: [u8; 64], pub signature: [u8; 64],
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct UsersEconomyConfigState {
pub version: u8,
pub registration_fee_lamports: u64,
pub lamports_per_limit_step: u64,
pub start_bonus_limit: u64,
}
#[derive(Accounts)] #[derive(Accounts)]
pub struct CreateUserPda<'info> { pub struct CreateUserPda<'info> {
/// CHECK: подписант транзакции, валидируется Anchor как signer и mut. /// CHECK: подписант транзакции, валидируется Anchor как signer и mut.
@ -80,11 +92,13 @@ pub struct CreateUserPda<'info> {
#[account(mut)] #[account(mut)]
pub user_pda: AccountInfo<'info>, pub user_pda: AccountInfo<'info>,
pub system_program: Program<'info, System>, pub system_program: Program<'info, System>,
/// CHECK: адрес получателя комиссии проверяется вручную с константой settings. /// CHECK: inflow-вольт shine_payments, адрес проверяется вручную как PDA.
#[account(mut)] #[account(mut)]
pub fee_receiver: AccountInfo<'info>, pub inflow_vault: AccountInfo<'info>,
/// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции. /// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции.
pub instructions: AccountInfo<'info>, pub instructions: AccountInfo<'info>,
/// CHECK: PDA с экономическими настройками пользователей, адрес проверяется вручную.
pub users_economy_config_pda: AccountInfo<'info>,
} }
#[derive(Accounts)] #[derive(Accounts)]
@ -96,21 +110,117 @@ pub struct UpdateUserPda<'info> {
#[account(mut)] #[account(mut)]
pub user_pda: AccountInfo<'info>, pub user_pda: AccountInfo<'info>,
pub system_program: Program<'info, System>, pub system_program: Program<'info, System>,
/// CHECK: адрес получателя комиссии проверяется вручную с константой settings. /// CHECK: inflow-вольт shine_payments, адрес проверяется вручную как PDA.
#[account(mut)] #[account(mut)]
pub fee_receiver: AccountInfo<'info>, pub inflow_vault: AccountInfo<'info>,
/// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции. /// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции.
pub instructions: AccountInfo<'info>, pub instructions: AccountInfo<'info>,
/// CHECK: PDA с экономическими настройками пользователей, адрес проверяется вручную.
pub users_economy_config_pda: AccountInfo<'info>,
}
#[derive(Accounts)]
pub struct InitUsersEconomyConfig<'info> {
/// CHECK: подписант и плательщик, валидируется Anchor как signer и mut.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA с экономическими настройками пользователей, адрес проверяется вручную.
#[account(mut)]
pub users_economy_config_pda: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct UpdateUsersEconomyConfig<'info> {
/// CHECK: подписант (должен быть DAO authority из settings).
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA с экономическими настройками пользователей, адрес проверяется вручную.
#[account(mut)]
pub users_economy_config_pda: AccountInfo<'info>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct UpdateUsersEconomyConfigArgs {
pub registration_fee_lamports: u64,
pub lamports_per_limit_step: u64,
pub start_bonus_limit: u64,
}
pub fn init_users_economy_config(ctx: Context<InitUsersEconomyConfig>) -> Result<()> {
let (expected_pda, bump) = find_users_economy_config_pda(ctx.program_id);
require_keys_eq!(
expected_pda,
ctx.accounts.users_economy_config_pda.key(),
ErrCode::InvalidPdaAddress
);
require!(
ctx.accounts.users_economy_config_pda.owner == &Pubkey::default(),
ErrCode::SystemAlreadyInitialized
);
let state = UsersEconomyConfigState {
version: 1,
registration_fee_lamports: settings::START_REGISTRATION_FEE_LAMPORTS,
lamports_per_limit_step: settings::START_LAMPORTS_PER_LIMIT_STEP,
start_bonus_limit: settings::START_BONUS_LIMIT,
};
let bytes = state
.try_to_vec()
.map_err(|_| error!(ErrCode::DeserializationError))?;
let seeds: &[&[u8]] = &[settings::USERS_ECONOMY_CONFIG_SEED, &[bump]];
create_pda(
&ctx.accounts.users_economy_config_pda,
&ctx.accounts.signer,
&ctx.accounts.system_program.to_account_info(),
ctx.program_id,
seeds,
settings::USERS_ECONOMY_CONFIG_SPACE as u64,
)?;
write_to_pda(&ctx.accounts.users_economy_config_pda, &bytes)?;
Ok(())
}
pub fn update_users_economy_config(
ctx: Context<UpdateUsersEconomyConfig>,
args: UpdateUsersEconomyConfigArgs,
) -> Result<()> {
let dao_authority =
Pubkey::from_str(settings::DAO_AUTHORITY).map_err(|_| error!(ErrCode::InvalidSigner))?;
require_keys_eq!(dao_authority, ctx.accounts.signer.key(), ErrCode::InvalidSigner);
let (expected_pda, _) = find_users_economy_config_pda(ctx.program_id);
require_keys_eq!(
expected_pda,
ctx.accounts.users_economy_config_pda.key(),
ErrCode::InvalidPdaAddress
);
require!(
ctx.accounts.users_economy_config_pda.owner == ctx.program_id,
ErrCode::InvalidPdaAddress
);
require!(args.lamports_per_limit_step > 0, ErrCode::InvalidRecordData);
let mut state = read_users_economy_config(&ctx.accounts.users_economy_config_pda)?;
state.registration_fee_lamports = args.registration_fee_lamports;
state.lamports_per_limit_step = args.lamports_per_limit_step;
state.start_bonus_limit = args.start_bonus_limit;
let bytes = state
.try_to_vec()
.map_err(|_| error!(ErrCode::DeserializationError))?;
write_to_pda(&ctx.accounts.users_economy_config_pda, &bytes)?;
Ok(())
} }
pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) -> Result<()> { pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) -> Result<()> {
validate_login(&args.login)?; validate_login(&args.login)?;
validate_fields(&args.fields)?; validate_fields(&args.fields)?;
validate_fee_receiver(&ctx.accounts.fee_receiver)?; validate_inflow_vault(&ctx.accounts.inflow_vault)?;
require!( require!(
args.additional_limit % settings::LIMIT_STEP == 0, args.additional_limit % settings::LIMIT_STEP == 0,
ErrCode::InvalidLimitIncrement ErrCode::InvalidLimitIncrement
); );
let economy = read_users_economy_config(&ctx.accounts.users_economy_config_pda)?;
let (expected_pda, bump) = find_user_pda(ctx.program_id, &args.login); let (expected_pda, bump) = find_user_pda(ctx.program_id, &args.login);
require_keys_eq!( require_keys_eq!(
@ -123,7 +233,8 @@ pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) ->
ErrCode::UserAlreadyExists ErrCode::UserAlreadyExists
); );
let start_balance = settings::START_BONUS_LIMIT let start_balance = economy
.start_bonus_limit
.checked_add(args.additional_limit) .checked_add(args.additional_limit)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
@ -133,8 +244,11 @@ pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) ->
version: 0, version: 0,
prev_hash: ZERO_HASH, prev_hash: ZERO_HASH,
login: args.login.clone(), login: args.login.clone(),
root_key_status: KEY_STATUS_CREATED,
root_key: args.root_key, root_key: args.root_key,
blockchain_key_status: KEY_STATUS_CREATED,
blockchain_key: args.fields.blockchain_key, blockchain_key: args.fields.blockchain_key,
device_key_status: KEY_STATUS_CREATED,
device_key: args.fields.device_key, device_key: args.fields.device_key,
chain_number: args.fields.chain_number, chain_number: args.fields.chain_number,
balance: start_balance, balance: start_balance,
@ -176,12 +290,13 @@ pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) ->
)?; )?;
write_to_pda(&ctx.accounts.user_pda, &padded)?; write_to_pda(&ctx.accounts.user_pda, &padded)?;
let total_fee = settings::REGISTRATION_FEE_LAMPORTS let total_fee = economy
.checked_add(limit_fee_lamports(args.additional_limit)?) .registration_fee_lamports
.checked_add(limit_fee_lamports(args.additional_limit, economy.lamports_per_limit_step)?)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
transfer_lamports( transfer_lamports(
&ctx.accounts.signer, &ctx.accounts.signer,
&ctx.accounts.fee_receiver, &ctx.accounts.inflow_vault,
&ctx.accounts.system_program.to_account_info(), &ctx.accounts.system_program.to_account_info(),
total_fee, total_fee,
)?; )?;
@ -192,11 +307,12 @@ pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) ->
pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) -> Result<()> { pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) -> Result<()> {
validate_login(&args.login)?; validate_login(&args.login)?;
validate_fields(&args.fields)?; validate_fields(&args.fields)?;
validate_fee_receiver(&ctx.accounts.fee_receiver)?; validate_inflow_vault(&ctx.accounts.inflow_vault)?;
require!( require!(
args.additional_limit % settings::LIMIT_STEP == 0, args.additional_limit % settings::LIMIT_STEP == 0,
ErrCode::InvalidLimitIncrement ErrCode::InvalidLimitIncrement
); );
let economy = read_users_economy_config(&ctx.accounts.users_economy_config_pda)?;
let (expected_pda, _) = find_user_pda(ctx.program_id, &args.login); let (expected_pda, _) = find_user_pda(ctx.program_id, &args.login);
require_keys_eq!( require_keys_eq!(
@ -221,7 +337,17 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
old_record.created_at_ms == args.created_at_ms, old_record.created_at_ms == args.created_at_ms,
ErrCode::ImmutableFieldChanged ErrCode::ImmutableFieldChanged
); );
require_keys_eq!(old_record.root_key, args.root_key, ErrCode::ImmutableFieldChanged); require_keys_eq!(
old_record.root_key,
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!( require!(
args.version == old_record.version.saturating_add(1), args.version == old_record.version.saturating_add(1),
ErrCode::InvalidVersion ErrCode::InvalidVersion
@ -246,8 +372,11 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
version: args.version, version: args.version,
prev_hash: provided_prev_hash, prev_hash: provided_prev_hash,
login: old_record.login.clone(), login: old_record.login.clone(),
root_key_status: old_record.root_key_status,
root_key: old_record.root_key, root_key: old_record.root_key,
blockchain_key_status: old_record.blockchain_key_status,
blockchain_key: args.fields.blockchain_key, blockchain_key: args.fields.blockchain_key,
device_key_status: old_record.device_key_status,
device_key: args.fields.device_key, device_key: args.fields.device_key,
chain_number: args.fields.chain_number, chain_number: args.fields.chain_number,
balance: new_balance, balance: new_balance,
@ -275,11 +404,11 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
let padded = pad_to_fixed_size(serialized, settings::USER_PDA_SPACE)?; let padded = pad_to_fixed_size(serialized, settings::USER_PDA_SPACE)?;
write_to_pda(&ctx.accounts.user_pda, &padded)?; write_to_pda(&ctx.accounts.user_pda, &padded)?;
let topup_fee = limit_fee_lamports(args.additional_limit)?; let topup_fee = limit_fee_lamports(args.additional_limit, economy.lamports_per_limit_step)?;
if topup_fee > 0 { if topup_fee > 0 {
transfer_lamports( transfer_lamports(
&ctx.accounts.signer, &ctx.accounts.signer,
&ctx.accounts.fee_receiver, &ctx.accounts.inflow_vault,
&ctx.accounts.system_program.to_account_info(), &ctx.accounts.system_program.to_account_info(),
topup_fee, topup_fee,
)?; )?;
@ -316,8 +445,11 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>> {
out.push(login_bytes.len() as u8); out.push(login_bytes.len() as u8);
out.extend_from_slice(login_bytes); out.extend_from_slice(login_bytes);
out.push(record.root_key_status);
out.extend_from_slice(record.root_key.as_ref()); 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.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.device_key.as_ref());
out.extend_from_slice(&record.chain_number.to_le_bytes()); out.extend_from_slice(&record.chain_number.to_le_bytes());
@ -380,8 +512,11 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord> {
let prev_hash = read_fixed_32(useful, &mut cursor)?; let prev_hash = read_fixed_32(useful, &mut cursor)?;
let login = read_len_prefixed_string(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 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 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 device_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
let chain_number = read_u16(useful, &mut cursor)?; let chain_number = read_u16(useful, &mut cursor)?;
@ -419,8 +554,11 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord> {
version, version,
prev_hash, prev_hash,
login, login,
root_key_status,
root_key, root_key,
blockchain_key_status,
blockchain_key, blockchain_key,
device_key_status,
device_key, device_key,
chain_number, chain_number,
balance, balance,
@ -455,18 +593,18 @@ fn verify_record_signature(
let provided_sig = vec_to_signature(signature)?; let provided_sig = vec_to_signature(signature)?;
let msg_hash = hashv(&[unsigned]); let msg_hash = hashv(&[unsigned]);
let current_ix_index = let current_ix_index = load_current_index_checked(instructions_sysvar)
load_current_index_checked(instructions_sysvar).map_err(|_| error!(ErrCode::InvalidSignature))?; .map_err(|_| error!(ErrCode::InvalidSignature))?;
require!(current_ix_index > 0, ErrCode::InvalidSignature); require!(current_ix_index > 0, ErrCode::InvalidSignature);
let ed_ix = load_instruction_at_checked( let ed_ix = load_instruction_at_checked((current_ix_index - 1) as usize, instructions_sysvar)
(current_ix_index - 1) as usize, .map_err(|_| error!(ErrCode::InvalidSignature))?;
instructions_sysvar,
)
.map_err(|_| error!(ErrCode::InvalidSignature))?;
let parsed = parse_ed25519_ix(&ed_ix)?; let parsed = parse_ed25519_ix(&ed_ix)?;
require_keys_eq!(parsed.pubkey, *root_key, ErrCode::InvalidSignature); require_keys_eq!(parsed.pubkey, *root_key, ErrCode::InvalidSignature);
require!(parsed.message == msg_hash.as_ref(), ErrCode::InvalidSignature); require!(
parsed.message == msg_hash.as_ref(),
ErrCode::InvalidSignature
);
require!(parsed.signature == provided_sig, ErrCode::InvalidSignature); require!(parsed.signature == provided_sig, ErrCode::InvalidSignature);
Ok(parsed.signature) Ok(parsed.signature)
@ -479,7 +617,11 @@ struct ParsedEd25519 {
} }
fn parse_ed25519_ix(ix: &Instruction) -> Result<ParsedEd25519> { fn parse_ed25519_ix(ix: &Instruction) -> Result<ParsedEd25519> {
require_keys_eq!(ix.program_id, ed25519_program::id(), ErrCode::InvalidSignature); require_keys_eq!(
ix.program_id,
ed25519_program::id(),
ErrCode::InvalidSignature
);
let data = &ix.data; let data = &ix.data;
require!(data.len() >= 16, ErrCode::InvalidSignature); require!(data.len() >= 16, ErrCode::InvalidSignature);
@ -543,7 +685,7 @@ fn le_u16(data: &[u8], offset: usize) -> Result<u16> {
fn validate_login(login: &str) -> Result<()> { fn validate_login(login: &str) -> Result<()> {
require!(!login.is_empty(), ErrCode::InvalidLogin); require!(!login.is_empty(), ErrCode::InvalidLogin);
require!(login.len() <= 30, ErrCode::InvalidLogin); require!(login.len() <= 25, ErrCode::InvalidLogin);
for ch in login.chars() { for ch in login.chars() {
if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') { if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') {
return Err(error!(ErrCode::InvalidLogin)); return Err(error!(ErrCode::InvalidLogin));
@ -554,7 +696,10 @@ fn validate_login(login: &str) -> Result<()> {
fn validate_fields(fields: &UserMutableFields) -> Result<()> { fn validate_fields(fields: &UserMutableFields) -> Result<()> {
if fields.is_server { if fields.is_server {
require!(!fields.server_address.is_empty(), ErrCode::InvalidRecordData); require!(
!fields.server_address.is_empty(),
ErrCode::InvalidRecordData
);
require!( require!(
fields.server_address.as_bytes().len() <= u8::MAX as usize, fields.server_address.as_bytes().len() <= u8::MAX as usize,
ErrCode::InvalidRecordData ErrCode::InvalidRecordData
@ -568,15 +713,22 @@ fn validate_fields(fields: &UserMutableFields) -> Result<()> {
); );
for login in &fields.connection_servers { for login in &fields.connection_servers {
require!(!login.is_empty(), ErrCode::InvalidRecordData); require!(!login.is_empty(), ErrCode::InvalidRecordData);
require!(login.as_bytes().len() <= u8::MAX as usize, ErrCode::InvalidRecordData); require!(
login.as_bytes().len() <= u8::MAX as usize,
ErrCode::InvalidRecordData
);
} }
Ok(()) Ok(())
} }
fn validate_fee_receiver(fee_receiver: &AccountInfo) -> Result<()> { fn validate_inflow_vault(inflow_vault: &AccountInfo) -> Result<()> {
let expected = Pubkey::from_str(settings::REGISTRATION_FEE_RECEIVER) let payments_program_id = Pubkey::from_str(settings::SHINE_PAYMENTS_PROGRAM_ID)
.map_err(|_| error!(ErrCode::InvalidFeeReceiver))?; .map_err(|_| error!(ErrCode::InvalidFeeReceiver))?;
require_keys_eq!(expected, *fee_receiver.key, ErrCode::InvalidFeeReceiver); let (expected, _) = Pubkey::find_program_address(
&[settings::SHINE_PAYMENTS_INFLOW_VAULT_SEED],
&payments_program_id,
);
require_keys_eq!(expected, *inflow_vault.key, ErrCode::InvalidFeeReceiver);
Ok(()) Ok(())
} }
@ -590,14 +742,17 @@ fn transfer_lamports<'info>(
return Ok(()); return Ok(());
} }
let ix = system_instruction::transfer(payer.key, recipient.key, lamports); let ix = system_instruction::transfer(payer.key, recipient.key, lamports);
invoke(&ix, &[payer.clone(), recipient.clone(), system_program.clone()])?; invoke(
&ix,
&[payer.clone(), recipient.clone(), system_program.clone()],
)?;
Ok(()) Ok(())
} }
fn limit_fee_lamports(limit_delta: u64) -> Result<u64> { fn limit_fee_lamports(limit_delta: u64, lamports_per_limit_step: u64) -> Result<u64> {
let units = limit_delta / settings::LIMIT_STEP; let units = limit_delta / settings::LIMIT_STEP;
units units
.checked_mul(settings::LAMPORTS_PER_LIMIT_STEP) .checked_mul(lamports_per_limit_step)
.ok_or(error!(ErrCode::MathOverflow)) .ok_or(error!(ErrCode::MathOverflow))
} }
@ -608,6 +763,18 @@ fn find_user_pda(program_id: &Pubkey, login: &str) -> (Pubkey, u8) {
) )
} }
fn find_users_economy_config_pda(program_id: &Pubkey) -> (Pubkey, u8) {
Pubkey::find_program_address(&[settings::USERS_ECONOMY_CONFIG_SEED], program_id)
}
fn read_users_economy_config(pda: &AccountInfo) -> Result<UsersEconomyConfigState> {
let raw = safe_read_pda(pda);
require!(!raw.is_empty(), ErrCode::EmptyPdaData);
let mut slice: &[u8] = &raw;
UsersEconomyConfigState::deserialize(&mut slice)
.map_err(|_| error!(ErrCode::DeserializationError))
}
fn pad_to_fixed_size(mut bytes: Vec<u8>, target_size: usize) -> Result<Vec<u8>> { fn pad_to_fixed_size(mut bytes: Vec<u8>, target_size: usize) -> Result<Vec<u8>> {
require!(bytes.len() <= target_size, ErrCode::RecordTooLarge); require!(bytes.len() <= target_size, ErrCode::RecordTooLarge);
bytes.resize(target_size, 0); bytes.resize(target_size, 0);
@ -629,7 +796,9 @@ fn vec_to_hash32(input: &[u8]) -> Result<[u8; 32]> {
} }
fn read_u8(data: &[u8], cursor: &mut usize) -> Result<u8> { fn read_u8(data: &[u8], cursor: &mut usize) -> Result<u8> {
let v = *data.get(*cursor).ok_or(error!(ErrCode::InvalidRecordData))?; let v = *data
.get(*cursor)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor += 1; *cursor += 1;
Ok(v) Ok(v)
} }
@ -638,7 +807,9 @@ fn read_u16(data: &[u8], cursor: &mut usize) -> Result<u16> {
let end = cursor let end = cursor
.checked_add(2) .checked_add(2)
.ok_or(error!(ErrCode::InvalidRecordData))?; .ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?; let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end; *cursor = end;
Ok(u16::from_le_bytes([slice[0], slice[1]])) Ok(u16::from_le_bytes([slice[0], slice[1]]))
} }
@ -647,7 +818,9 @@ fn read_u32(data: &[u8], cursor: &mut usize) -> Result<u32> {
let end = cursor let end = cursor
.checked_add(4) .checked_add(4)
.ok_or(error!(ErrCode::InvalidRecordData))?; .ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?; let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end; *cursor = end;
Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]])) Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
} }
@ -656,7 +829,9 @@ fn read_u64(data: &[u8], cursor: &mut usize) -> Result<u64> {
let end = cursor let end = cursor
.checked_add(8) .checked_add(8)
.ok_or(error!(ErrCode::InvalidRecordData))?; .ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?; let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end; *cursor = end;
Ok(u64::from_le_bytes([ Ok(u64::from_le_bytes([
slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7], slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
@ -667,7 +842,9 @@ fn read_fixed_32(data: &[u8], cursor: &mut usize) -> Result<[u8; 32]> {
let end = cursor let end = cursor
.checked_add(32) .checked_add(32)
.ok_or(error!(ErrCode::InvalidRecordData))?; .ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?; let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end; *cursor = end;
let mut out = [0u8; 32]; let mut out = [0u8; 32];
out.copy_from_slice(slice); out.copy_from_slice(slice);
@ -678,7 +855,9 @@ fn read_fixed_64(data: &[u8], cursor: &mut usize) -> Result<[u8; 64]> {
let end = cursor let end = cursor
.checked_add(64) .checked_add(64)
.ok_or(error!(ErrCode::InvalidRecordData))?; .ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?; let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end; *cursor = end;
let mut out = [0u8; 64]; let mut out = [0u8; 64];
out.copy_from_slice(slice); out.copy_from_slice(slice);
@ -690,7 +869,9 @@ fn read_len_prefixed_string(data: &[u8], cursor: &mut usize) -> Result<String> {
let end = cursor let end = cursor
.checked_add(len) .checked_add(len)
.ok_or(error!(ErrCode::InvalidRecordData))?; .ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?; let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end; *cursor = end;
let value = std::str::from_utf8(slice).map_err(|_| error!(ErrCode::InvalidRecordData))?; let value = std::str::from_utf8(slice).map_err(|_| error!(ErrCode::InvalidRecordData))?;
Ok(value.to_string()) Ok(value.to_string())

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"

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