Отключить репосты и добавить Solana-модуль
This commit is contained in:
parent
abdce05136
commit
56cd90a197
23
.gitignore
vendored
23
.gitignore
vendored
@ -50,3 +50,26 @@ bin/
|
|||||||
|
|
||||||
# временный debug token
|
# временный debug token
|
||||||
.debug-token
|
.debug-token
|
||||||
|
|
||||||
|
# Локальные артефакты и секреты Solana-модуля
|
||||||
|
shine-solana/.git/
|
||||||
|
shine-solana/.git-local-backup/
|
||||||
|
shine-solana/.idea/
|
||||||
|
shine-solana/shine/.idea/
|
||||||
|
shine-solana/shine/.gradle/
|
||||||
|
shine-solana/shine/.anchor/
|
||||||
|
shine-solana/shine/.yarn/
|
||||||
|
shine-solana/shine/.vendor/
|
||||||
|
shine-solana/shine/node_modules/
|
||||||
|
shine-solana/shine/target/
|
||||||
|
shine-solana/shine/test-ledger/
|
||||||
|
shine-solana/shine/old_vers/
|
||||||
|
shine-solana/shine/program-keypair.json
|
||||||
|
shine-solana/shine/keys/
|
||||||
|
shine-solana/shine/validator.log
|
||||||
|
shine-solana/shine/doc/КОШЕЛЬКИ_DEVNET_ТЕСТ.md
|
||||||
|
shine-solana/shine/scripts/del/
|
||||||
|
shine-solana/shine/scripts/**/keypairs/
|
||||||
|
shine-solana/shine/scripts/**/runs/
|
||||||
|
shine-solana/shine/scripts/**/*.env
|
||||||
|
shine-solana/shine/scripts/**/TEMP_*.md
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@ -13,6 +13,13 @@
|
|||||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||||
- Подробные правила работы сервиса, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
|
- Подробные правила работы сервиса, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
|
||||||
|
|
||||||
|
## Solana-модуль
|
||||||
|
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
|
||||||
|
- Модуль логически связан с SHiNE, но не должен автоматически подключаться к сборке или деплою основного сервера без отдельного решения.
|
||||||
|
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
|
||||||
|
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
|
||||||
|
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля с `deployServer`/`deployUI` основного проекта.
|
||||||
|
|
||||||
## Документация блокчейна
|
## Документация блокчейна
|
||||||
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
|
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
|
||||||
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
||||||
@ -90,6 +97,15 @@
|
|||||||
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
||||||
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
||||||
|
|
||||||
|
## Будущие фичи
|
||||||
|
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
|
||||||
|
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
||||||
|
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
|
||||||
|
- какие файлы и участки отключены;
|
||||||
|
- что осталось в коде как заготовка;
|
||||||
|
- какие документы нужно обновить при возврате;
|
||||||
|
- с какого сценария продолжать разработку.
|
||||||
|
|
||||||
## Коммуникация по новым задачам (обязательно)
|
## Коммуникация по новым задачам (обязательно)
|
||||||
- При получении нового задания сначала кратко пересказать задачу своими словами.
|
- При получении нового задания сначала кратко пересказать задачу своими словами.
|
||||||
- До начала реализации задать недостающие уточняющие вопросы (если они есть).
|
- До начала реализации задать недостающие уточняющие вопросы (если они есть).
|
||||||
|
|||||||
@ -81,11 +81,12 @@
|
|||||||
- `bad_signature`, `signature_verify_failed`
|
- `bad_signature`, `signature_verify_failed`
|
||||||
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
||||||
- `limit_exceeded`
|
- `limit_exceeded`
|
||||||
|
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||||
- `internal_error`
|
- `internal_error`
|
||||||
|
|
||||||
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
||||||
|
|
||||||
Через `AddBlock` можно писать все поддержанные форматы:
|
Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
|
||||||
|
|
||||||
1. **TECH (type=0)**
|
1. **TECH (type=0)**
|
||||||
- `HEADER_COMPAT (subType=0)`
|
- `HEADER_COMPAT (subType=0)`
|
||||||
@ -96,7 +97,7 @@
|
|||||||
- `TEXT_EDIT_POST (11)`
|
- `TEXT_EDIT_POST (11)`
|
||||||
- `TEXT_REPLY (20)`
|
- `TEXT_REPLY (20)`
|
||||||
- `TEXT_EDIT_REPLY (21)`
|
- `TEXT_EDIT_REPLY (21)`
|
||||||
- `TEXT_REPOST (30)`
|
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||||
|
|
||||||
3. **REACTION (type=2)**
|
3. **REACTION (type=2)**
|
||||||
- `REACTION_LIKE (1)`
|
- `REACTION_LIKE (1)`
|
||||||
|
|||||||
@ -24,7 +24,8 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
5. `subType=30` — `TEXT_REPOST`
|
5. `subType=30` — `TEXT_REPOST`
|
||||||
- репост сообщения в линию канала;
|
- репост сообщения в линию канала;
|
||||||
- содержит line-поля + target на оригинальное сообщение + текст комментария;
|
- содержит line-поля + target на оригинальное сообщение + текст комментария;
|
||||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются).
|
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
||||||
|
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 2026-05-24 11:40:00 +0300
|
||||||
|
- Базовый коммит-ориентир: `abdce05`.
|
||||||
|
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
|
||||||
|
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
|
||||||
|
- В `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
|
||||||
|
|
||||||
## 2026-05-21 19:05:00 +0300
|
## 2026-05-21 19:05:00 +0300
|
||||||
- Базовый коммит-ориентир: `5344c42`.
|
- Базовый коммит-ориентир: `5344c42`.
|
||||||
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
|
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
|
||||||
|
|||||||
@ -0,0 +1,93 @@
|
|||||||
|
# Репосты в каналах и тредах
|
||||||
|
|
||||||
|
- Статус:
|
||||||
|
`future`
|
||||||
|
|
||||||
|
- Решение от 2026-05-24:
|
||||||
|
Репосты временно убраны из активной разработки. Фича уже была частично реализована, но не доведена до финальной проверки. Чтобы она не мешала запуску проекта, пользовательский вход в репосты отключён в UI, а сервер больше не принимает новые `TEXT_REPOST` через `AddBlock`.
|
||||||
|
|
||||||
|
## Что должна делать фича
|
||||||
|
|
||||||
|
Репост должен позволять взять сообщение из канала или треда и опубликовать его в один из своих каналов с комментарием.
|
||||||
|
|
||||||
|
Ожидаемый пользовательский сценарий после возврата к задаче:
|
||||||
|
|
||||||
|
1. Пользователь открывает сообщение в канале или треде.
|
||||||
|
2. Нажимает `Репост`.
|
||||||
|
3. Выбирает один из своих каналов.
|
||||||
|
4. Добавляет комментарий.
|
||||||
|
5. Отправляет репост.
|
||||||
|
6. В целевом канале появляется новый пост-репост.
|
||||||
|
7. У репоста есть переход к исходному сообщению через действие `Оригинал`.
|
||||||
|
|
||||||
|
## Что уже есть в коде
|
||||||
|
|
||||||
|
- В блокчейн-формате зарезервирован и описан подтип `TEXT_REPOST (30)`.
|
||||||
|
- Парсер блокчейна умеет распознавать тело репоста как `TextLineBody`.
|
||||||
|
- В UI есть функция сборки тела репоста:
|
||||||
|
`shine-UI/js/services/auth-service.js`, `makeTextRepostBodyBytes`.
|
||||||
|
- В UI есть клиентская операция:
|
||||||
|
`shine-UI/js/services/auth-service.js`, `addBlockRepost`.
|
||||||
|
- В экране канала был обработчик репоста:
|
||||||
|
`shine-UI/js/pages/channel-view.js`, `onRepost`.
|
||||||
|
- В экране треда был обработчик репоста:
|
||||||
|
`shine-UI/js/pages/channel-thread-view.js`, `onRepost`.
|
||||||
|
- Серверная выдача каналов и тредов частично учитывает `TEXT_REPOST` и target-поля:
|
||||||
|
`targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`.
|
||||||
|
- В списке подписок `TEXT_REPOST` считается публикацией канала.
|
||||||
|
|
||||||
|
## Что сейчас отключено
|
||||||
|
|
||||||
|
- В `shine-UI/js/pages/channel-view.js` кнопка `Репост` больше не создаётся и не добавляется в список действий сообщения.
|
||||||
|
- В `shine-UI/js/pages/channel-thread-view.js` кнопка `Репост` больше не создаётся и не добавляется в список действий ответа/сообщения треда.
|
||||||
|
- В `shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java` добавлена явная временная блокировка:
|
||||||
|
если новый блок имеет `type=1` и `subType=TEXT_REPOST (30)`, `AddBlock` возвращает ошибку `repost_disabled`.
|
||||||
|
|
||||||
|
## Что осталось активным намеренно
|
||||||
|
|
||||||
|
- Константа `TEXT_REPOST (30)` остаётся в коде и документации как зарезервированный формат.
|
||||||
|
- Парсер блокчейна продолжает знать формат `TEXT_REPOST`, чтобы не потерять уже написанную основу и не ломать потенциальное чтение старых тестовых данных.
|
||||||
|
- Код формирования репоста в `auth-service.js` не удалён: его можно будет использовать как основу при возвращении к задаче.
|
||||||
|
- Код отображения target-полей и перехода к оригиналу не удалён: он нужен для будущей проверки и возможной совместимости с уже созданными тестовыми блоками.
|
||||||
|
|
||||||
|
## Почему это не лежит в Pending_Features
|
||||||
|
|
||||||
|
`Dev_Docs/Pending_Features/` предназначена для фич, которые уже реализованы и ждут ручной проверки.
|
||||||
|
|
||||||
|
Репосты сейчас не подходят под этот статус: они не должны проверяться как готовая фича, потому что пользовательский сценарий временно закрыт, а серверная запись новых репостов заблокирована. Поэтому старый pending-файл удалён, а задача перенесена сюда как будущая.
|
||||||
|
|
||||||
|
## Что сделать при возврате к реализации
|
||||||
|
|
||||||
|
1. Решить, остаётся ли формат `TEXT_REPOST (30)` финальным.
|
||||||
|
2. Если формат меняется, заранее предупредить пользователя и получить отдельное подтверждение на изменение блокчейн-формата.
|
||||||
|
3. Вернуть UI-кнопки репоста в:
|
||||||
|
- `shine-UI/js/pages/channel-view.js`;
|
||||||
|
- `shine-UI/js/pages/channel-thread-view.js`.
|
||||||
|
4. Снять временную блокировку `repost_disabled` в:
|
||||||
|
`shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java`.
|
||||||
|
5. Проверить `auth-service.js`:
|
||||||
|
- `makeTextRepostBodyBytes`;
|
||||||
|
- `addBlockRepost`;
|
||||||
|
- актуализацию вершины блокчейна перед `AddBlock`;
|
||||||
|
- корректность target-полей исходного сообщения.
|
||||||
|
6. Проверить серверное чтение:
|
||||||
|
- `GetChannelMessages`;
|
||||||
|
- `GetMessageThread`;
|
||||||
|
- отображение `targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`.
|
||||||
|
7. Добавить или обновить тесты на успешный репост и отказ некорректных target-полей.
|
||||||
|
8. Обновить документацию:
|
||||||
|
- `Dev_Docs/Blockchain/11_TEXT_Blocks.md`;
|
||||||
|
- `Dev_Docs/Blockchain/CHANGELOG.md`;
|
||||||
|
- `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`;
|
||||||
|
- документы API чтения каналов/тредов, если изменятся поля ответа.
|
||||||
|
9. После реализации перенести задачу из `Dev_Docs/Future_Features/` в `Dev_Docs/Pending_Features/` как фичу, требующую ручной проверки.
|
||||||
|
|
||||||
|
## Минимальный чек-лист ручной проверки в будущем
|
||||||
|
|
||||||
|
1. Репост из сообщения канала в свой канал.
|
||||||
|
2. Репост из ответа в треде в свой канал.
|
||||||
|
3. Ошибка при попытке репоста в чужой канал.
|
||||||
|
4. Переход `Оригинал` из репоста к исходному сообщению.
|
||||||
|
5. Корректное отображение комментария к репосту.
|
||||||
|
6. Корректная работа после перезагрузки страницы.
|
||||||
|
7. Отсутствие поломки обычных постов, ответов, лайков и отправки ссылки.
|
||||||
14
Dev_Docs/Future_Features/README.md
Normal file
14
Dev_Docs/Future_Features/README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Будущие фичи
|
||||||
|
|
||||||
|
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку.
|
||||||
|
|
||||||
|
## Как использовать
|
||||||
|
|
||||||
|
1. Каждая будущая фича описывается отдельным markdown-файлом.
|
||||||
|
2. В файле нужно фиксировать:
|
||||||
|
- зачем нужна фича;
|
||||||
|
- что уже было сделано в коде;
|
||||||
|
- что временно отключено или закомментировано;
|
||||||
|
- какие документы нужно обновить при возврате к задаче;
|
||||||
|
- с какого места продолжать разработку.
|
||||||
|
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# Репосты в каналах и тредах
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
Добавлен подтип `TEXT_REPOST (30)` и UI-режим репоста с комментарием. Репост можно делать как из сообщения канала, так и из сообщения в треде. Для репоста выбирается один из своих каналов.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
1. В канале открыть любое сообщение и нажать `Репост`.
|
|
||||||
2. Выбрать свой канал, ввести комментарий, отправить.
|
|
||||||
3. Убедиться, что в целевом канале появился новый пост-репост.
|
|
||||||
4. Нажать `Оригинал` у репоста и подтвердить переход.
|
|
||||||
5. Проверить, что переход открывает исходное сообщение.
|
|
||||||
6. Повторить сценарий из треда (для сообщения-ответа).
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Репост успешно записывается в блокчейн как `TEXT_REPOST`.
|
|
||||||
- В выдаче `GetChannelMessages`/`GetMessageThread` возвращаются поля target (`targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`) для репоста.
|
|
||||||
- Кнопка `Оригинал` открывает нужное исходное сообщение.
|
|
||||||
- Для репоста не отображается история редактирования (одна версия).
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`pending`
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Групповой чат агента-кодера
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
- Сервис `SHiNE-agent-bot-coder` теперь сохраняет сообщения участников обычной Telegram-группы и supergroup как контекст.
|
||||||
|
- На сообщения других участников группы сервис отвечает в тот же чат коротким подтверждением `Получил сообщение.`, но не создаёт задачу Codex.
|
||||||
|
- При миграции обычной группы в supergroup сервис запоминает новый `chat_id` и перенаправляет ответы туда.
|
||||||
|
- Команды и задачи по-прежнему выполняются только от Айдара (`@aidarkc` / `@AidarKC`).
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
1. Написать сообщение от другого участника в группе `@shine_writing`.
|
||||||
|
2. Убедиться, что бот ответил на него в группе коротким подтверждением получения.
|
||||||
|
3. Написать задачу от Айдара в этой же группе.
|
||||||
|
4. Убедиться, что ответ приходит в группу, а не в личные сообщения.
|
||||||
|
5. Убедиться, что после миграции group → supergroup ответы не теряются.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- Чужие сообщения попадают в историю как контекст.
|
||||||
|
- Чужие сообщения получают ACK в группе, но не попадают в очередь задач.
|
||||||
|
- Сообщение Айдара создаёт задачу и получает ответ в актуальном supergroup-чате.
|
||||||
|
- Ошибка Telegram `group chat was upgraded to a supergroup chat` больше не ломает отправку ответа.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
pending
|
||||||
@ -10,13 +10,17 @@
|
|||||||
- Сообщение может быть текстом или результатом распознавания голосового.
|
- Сообщение может быть текстом или результатом распознавания голосового.
|
||||||
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
|
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
|
||||||
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
|
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
|
||||||
|
- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды.
|
||||||
|
- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
|
||||||
|
|
||||||
## Авторитет команд и история
|
## Авторитет команд и история
|
||||||
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
||||||
- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
|
- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
|
||||||
- Сообщения других пользователей в разрешённом канале сохраняются в историю диалога как контекстные сообщения.
|
- Сообщения других пользователей в разрешённом канале, группе или supergroup сохраняются в историю диалога как контекстные сообщения.
|
||||||
|
- На сообщения других пользователей в группе или supergroup сервис должен коротко отвечать в тот же чат, что сообщение получено, но не ставить их в очередь как задачи.
|
||||||
- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
|
- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
|
||||||
- В Telegram-канале `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в этот же канал.
|
- В Telegram-канале/группе `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в тот же чат.
|
||||||
|
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
|
||||||
|
|
||||||
## Очередь и состояние
|
## Очередь и состояние
|
||||||
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||||
- при рестарте восстанавливает незавершённые задачи;
|
- при рестарте восстанавливает незавершённые задачи;
|
||||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||||
- принимает сообщения из канала `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст.
|
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст;
|
||||||
|
- на сообщения других участников группы отвечает в тот же чат коротким подтверждением получения, не создавая задачу Codex;
|
||||||
|
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
|
||||||
|
|
||||||
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
||||||
|
|
||||||
@ -27,7 +29,7 @@
|
|||||||
2. Заполнить секреты в `.env`.
|
2. Заполнить секреты в `.env`.
|
||||||
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
||||||
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
||||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`.
|
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
||||||
3. Запуск:
|
3. Запуск:
|
||||||
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import json
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import string
|
import string
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@ -350,6 +351,56 @@ class ShinePyBotService:
|
|||||||
history_path = self._current_history_file()
|
history_path = self._current_history_file()
|
||||||
self._append_history(history_path, "system_event", {"event": event_type, **payload})
|
self._append_history(history_path, "system_event", {"event": event_type, **payload})
|
||||||
|
|
||||||
|
def _resolve_chat_id(self, chat_id: int) -> int:
|
||||||
|
migrations = self.state.get("chat_id_migrations")
|
||||||
|
if not isinstance(migrations, dict):
|
||||||
|
return chat_id
|
||||||
|
current = chat_id
|
||||||
|
visited: set[int] = set()
|
||||||
|
while current not in visited:
|
||||||
|
visited.add(current)
|
||||||
|
next_chat_id = migrations.get(str(current))
|
||||||
|
if not isinstance(next_chat_id, int):
|
||||||
|
break
|
||||||
|
current = next_chat_id
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _remember_chat_migration(self, old_chat_id: int, new_chat_id: int, source: str) -> None:
|
||||||
|
if old_chat_id == new_chat_id:
|
||||||
|
return
|
||||||
|
migrations = self.state.get("chat_id_migrations")
|
||||||
|
if not isinstance(migrations, dict):
|
||||||
|
migrations = {}
|
||||||
|
self.state["chat_id_migrations"] = migrations
|
||||||
|
if migrations.get(str(old_chat_id)) == new_chat_id:
|
||||||
|
return
|
||||||
|
migrations[str(old_chat_id)] = new_chat_id
|
||||||
|
self._persist_state()
|
||||||
|
with self.queue_lock:
|
||||||
|
changed = False
|
||||||
|
for job in self.queue:
|
||||||
|
if job.get("chat_id") == old_chat_id:
|
||||||
|
job["chat_id"] = new_chat_id
|
||||||
|
job["updated_at"] = now_iso()
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._persist_queue()
|
||||||
|
self._append_history_event("chat_migrated_to_supergroup", {
|
||||||
|
"oldChatId": old_chat_id,
|
||||||
|
"newChatId": new_chat_id,
|
||||||
|
"source": source,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_migrate_to_chat_id(error_text: str) -> int | None:
|
||||||
|
match = re.search(r'"migrate_to_chat_id"\s*:\s*(-?\d+)', error_text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
match = re.search(r"'migrate_to_chat_id'\s*:\s*(-?\d+)", error_text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
def _handle_update(self, update: dict[str, Any]) -> None:
|
def _handle_update(self, update: dict[str, Any]) -> None:
|
||||||
message = update.get("message")
|
message = update.get("message")
|
||||||
update_type = "message"
|
update_type = "message"
|
||||||
@ -371,11 +422,17 @@ class ShinePyBotService:
|
|||||||
if not isinstance(chat_id, int) or not isinstance(message_id, int):
|
if not isinstance(chat_id, int) or not isinstance(message_id, int):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
migrate_to_chat_id = message.get("migrate_to_chat_id")
|
||||||
|
if isinstance(migrate_to_chat_id, int):
|
||||||
|
self._remember_chat_migration(chat_id, migrate_to_chat_id, "telegram_message")
|
||||||
|
return
|
||||||
|
|
||||||
update_key = f"{chat_id}:{message_id}"
|
update_key = f"{chat_id}:{message_id}"
|
||||||
if self._mark_processed_update(update_key):
|
if self._mark_processed_update(update_key):
|
||||||
return
|
return
|
||||||
|
|
||||||
is_channel_post = update_type == "channel_post" or chat_type == "channel"
|
is_channel_post = update_type == "channel_post" or chat_type == "channel"
|
||||||
|
is_group_message = update_type == "message" and chat_type in ("group", "supergroup")
|
||||||
is_allowed_channel = (
|
is_allowed_channel = (
|
||||||
not is_channel_post
|
not is_channel_post
|
||||||
or not self.cfg.allowed_channel_username
|
or not self.cfg.allowed_channel_username
|
||||||
@ -387,10 +444,12 @@ class ShinePyBotService:
|
|||||||
text = (message.get("text") or message.get("caption") or "").strip()
|
text = (message.get("text") or message.get("caption") or "").strip()
|
||||||
history_path = self._current_history_file()
|
history_path = self._current_history_file()
|
||||||
if author_username != self.cfg.allowed_username:
|
if author_username != self.cfg.allowed_username:
|
||||||
if is_channel_post:
|
if is_channel_post or is_group_message:
|
||||||
self._append_history(history_path, "channel_context_message", {
|
self._append_history(history_path, "chat_context_message", {
|
||||||
"chatId": chat_id,
|
"chatId": chat_id,
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
|
"updateType": update_type,
|
||||||
|
"chatType": chat_type,
|
||||||
"chatUsername": chat_username,
|
"chatUsername": chat_username,
|
||||||
"chatTitle": chat_title,
|
"chatTitle": chat_title,
|
||||||
"username": username,
|
"username": username,
|
||||||
@ -399,6 +458,8 @@ class ShinePyBotService:
|
|||||||
"hasVoice": bool(message.get("voice")),
|
"hasVoice": bool(message.get("voice")),
|
||||||
"hasAudio": bool(message.get("audio")),
|
"hasAudio": bool(message.get("audio")),
|
||||||
})
|
})
|
||||||
|
if is_group_message:
|
||||||
|
self._safe_send(chat_id, "Получил сообщение.", reply_to=message_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
@ -412,6 +473,7 @@ class ShinePyBotService:
|
|||||||
chat_username=chat_username,
|
chat_username=chat_username,
|
||||||
chat_title=chat_title,
|
chat_title=chat_title,
|
||||||
author_signature=author_signature,
|
author_signature=author_signature,
|
||||||
|
chat_type=chat_type,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if message.get("audio"):
|
if message.get("audio"):
|
||||||
@ -424,6 +486,7 @@ class ShinePyBotService:
|
|||||||
chat_username=chat_username,
|
chat_username=chat_username,
|
||||||
chat_title=chat_title,
|
chat_title=chat_title,
|
||||||
author_signature=author_signature,
|
author_signature=author_signature,
|
||||||
|
chat_type=chat_type,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self._safe_send(chat_id, "Поддерживаются текст, voice и audio.", reply_to=message_id)
|
self._safe_send(chat_id, "Поддерживаются текст, voice и audio.", reply_to=message_id)
|
||||||
@ -437,6 +500,7 @@ class ShinePyBotService:
|
|||||||
"chatId": chat_id,
|
"chatId": chat_id,
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
"updateType": update_type,
|
"updateType": update_type,
|
||||||
|
"chatType": chat_type,
|
||||||
"chatUsername": chat_username,
|
"chatUsername": chat_username,
|
||||||
"chatTitle": chat_title,
|
"chatTitle": chat_title,
|
||||||
"username": author_username,
|
"username": author_username,
|
||||||
@ -447,6 +511,7 @@ class ShinePyBotService:
|
|||||||
job["type"] = "text"
|
job["type"] = "text"
|
||||||
job["text"] = text
|
job["text"] = text
|
||||||
job["update_type"] = update_type
|
job["update_type"] = update_type
|
||||||
|
job["chat_type"] = chat_type
|
||||||
job["chat_username"] = chat_username
|
job["chat_username"] = chat_username
|
||||||
job["chat_title"] = chat_title
|
job["chat_title"] = chat_title
|
||||||
job["author_signature"] = author_signature
|
job["author_signature"] = author_signature
|
||||||
@ -466,6 +531,7 @@ class ShinePyBotService:
|
|||||||
chat_username: str = "",
|
chat_username: str = "",
|
||||||
chat_title: str = "",
|
chat_title: str = "",
|
||||||
author_signature: str = "",
|
author_signature: str = "",
|
||||||
|
chat_type: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
if not file_id:
|
if not file_id:
|
||||||
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
|
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
|
||||||
@ -475,6 +541,7 @@ class ShinePyBotService:
|
|||||||
"chatId": chat_id,
|
"chatId": chat_id,
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
"updateType": update_type,
|
"updateType": update_type,
|
||||||
|
"chatType": chat_type,
|
||||||
"chatUsername": chat_username,
|
"chatUsername": chat_username,
|
||||||
"chatTitle": chat_title,
|
"chatTitle": chat_title,
|
||||||
"username": username,
|
"username": username,
|
||||||
@ -485,6 +552,7 @@ class ShinePyBotService:
|
|||||||
job["type"] = "voice"
|
job["type"] = "voice"
|
||||||
job["telegram_file_id"] = file_id
|
job["telegram_file_id"] = file_id
|
||||||
job["update_type"] = update_type
|
job["update_type"] = update_type
|
||||||
|
job["chat_type"] = chat_type
|
||||||
job["chat_username"] = chat_username
|
job["chat_username"] = chat_username
|
||||||
job["chat_title"] = chat_title
|
job["chat_title"] = chat_title
|
||||||
job["author_signature"] = author_signature
|
job["author_signature"] = author_signature
|
||||||
@ -507,6 +575,7 @@ class ShinePyBotService:
|
|||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
"update_type": "message",
|
"update_type": "message",
|
||||||
|
"chat_type": "",
|
||||||
"chat_username": "",
|
"chat_username": "",
|
||||||
"chat_title": "",
|
"chat_title": "",
|
||||||
"author_signature": "",
|
"author_signature": "",
|
||||||
@ -717,6 +786,7 @@ class ShinePyBotService:
|
|||||||
"Пришло сообщение в Telegram.\n"
|
"Пришло сообщение в Telegram.\n"
|
||||||
f"Тип: {job.get('type')}\n"
|
f"Тип: {job.get('type')}\n"
|
||||||
f"Источник Telegram: {job.get('update_type', 'message')}\n"
|
f"Источник Telegram: {job.get('update_type', 'message')}\n"
|
||||||
|
f"Тип чата: {job.get('chat_type') or ''}\n"
|
||||||
f"Канал/чат: @{job.get('chat_username') or ''} {job.get('chat_title') or ''}\n"
|
f"Канал/чат: @{job.get('chat_username') or ''} {job.get('chat_title') or ''}\n"
|
||||||
f"Username отправителя: @{job.get('username')}\n"
|
f"Username отправителя: @{job.get('username')}\n"
|
||||||
f"Подпись автора в Telegram: {job.get('author_signature') or ''}\n"
|
f"Подпись автора в Telegram: {job.get('author_signature') or ''}\n"
|
||||||
@ -892,9 +962,20 @@ class ShinePyBotService:
|
|||||||
return
|
return
|
||||||
if len(text) > 3900:
|
if len(text) > 3900:
|
||||||
text = text[:3900] + "\n...[обрезано]"
|
text = text[:3900] + "\n...[обрезано]"
|
||||||
|
resolved_chat_id = self._resolve_chat_id(chat_id)
|
||||||
|
resolved_reply_to = reply_to if resolved_chat_id == chat_id else None
|
||||||
try:
|
try:
|
||||||
self.telegram.send_message(chat_id, text, reply_to_message_id=reply_to)
|
self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
|
||||||
|
if migrate_to_chat_id is not None:
|
||||||
|
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
|
||||||
|
try:
|
||||||
|
self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
|
||||||
|
return
|
||||||
|
except Exception as retry_error:
|
||||||
|
print(f"[py-bot] sendMessage retry after migration error: {retry_error}", flush=True)
|
||||||
|
return
|
||||||
print(f"[py-bot] sendMessage error: {e}", flush=True)
|
print(f"[py-bot] sendMessage error: {e}", flush=True)
|
||||||
|
|
||||||
def _schedule_self_restart(self) -> None:
|
def _schedule_self_restart(self) -> None:
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.87
|
client.version=1.2.88
|
||||||
server.version=1.2.81
|
server.version=1.2.82
|
||||||
|
|||||||
@ -709,24 +709,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
await handlers.onShare(target);
|
await handlers.onShare(target);
|
||||||
});
|
});
|
||||||
|
|
||||||
const repostButton = document.createElement('button');
|
// Репосты временно отключены до будущей реализации.
|
||||||
repostButton.type = 'button';
|
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
repostButton.className = 'channel-action-item thread-reply-btn';
|
actions.append(likeButton, replyButton, shareButton);
|
||||||
repostButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">🔁</span>
|
|
||||||
<span class="channel-action-label">Репост</span>
|
|
||||||
`;
|
|
||||||
repostButton.addEventListener('click', async (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
try {
|
|
||||||
await handlers.onRepost(target);
|
|
||||||
} catch (error) {
|
|
||||||
handlers?.onActionError?.(error, 'repost');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.append(likeButton, replyButton, repostButton, shareButton);
|
|
||||||
if (repostTarget) {
|
if (repostTarget) {
|
||||||
const originalButton = document.createElement('button');
|
const originalButton = document.createElement('button');
|
||||||
originalButton.type = 'button';
|
originalButton.type = 'button';
|
||||||
|
|||||||
@ -1004,19 +1004,9 @@ function renderPostCard(post, {
|
|||||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const repostButton = document.createElement('button');
|
// Репосты временно отключены до будущей реализации.
|
||||||
repostButton.type = 'button';
|
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
repostButton.className = 'channel-action-item channel-action-reply';
|
actions.append(likeButton, replyButton);
|
||||||
repostButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">🔁</span>
|
|
||||||
<span class="channel-action-label">Репост</span>
|
|
||||||
`;
|
|
||||||
repostButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
onRepost(post.messageRef);
|
|
||||||
});
|
|
||||||
actions.append(likeButton, replyButton, repostButton);
|
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
|
|||||||
@ -146,6 +146,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
||||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
|
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
@ -246,6 +247,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Репосты временно отключены до будущей реализации.
|
||||||
|
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
|
if ((block.type & 0xFFFF) == 1
|
||||||
|
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
|
log.warn("AddBlock: repost_disabled (login={}, blockchainName={}, blockNumber={})",
|
||||||
|
login, blockchainName, block.blockNumber);
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "repost_disabled", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
ChannelNameStateEntry channelNameStateEntry = null;
|
ChannelNameStateEntry channelNameStateEntry = null;
|
||||||
Chat200CreateSeed chat200CreateSeed = null;
|
Chat200CreateSeed chat200CreateSeed = null;
|
||||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||||
|
|||||||
9
shine-solana/shine/.gitignore
vendored
Normal file
9
shine-solana/shine/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.anchor
|
||||||
|
.DS_Store
|
||||||
|
target
|
||||||
|
**/*.rs.bk
|
||||||
|
node_modules
|
||||||
|
test-ledger
|
||||||
|
.yarn
|
||||||
|
program-keypair.json
|
||||||
|
/old_vers/
|
||||||
7
shine-solana/shine/.prettierignore
Normal file
7
shine-solana/shine/.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.anchor
|
||||||
|
.DS_Store
|
||||||
|
target
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
test-ledger
|
||||||
63
shine-solana/shine/AGENTS.md
Normal file
63
shine-solana/shine/AGENTS.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Documentation Rule
|
||||||
|
|
||||||
|
В проекте есть спецификация пользовательской PDA-записи:
|
||||||
|
|
||||||
|
- актуальные документы в `doc/`.
|
||||||
|
|
||||||
|
Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, соответствующую документацию в `doc/` нужно обновлять в том же изменении.
|
||||||
|
|
||||||
|
## Language Rule
|
||||||
|
|
||||||
|
Во всем проекте использовать русский язык:
|
||||||
|
|
||||||
|
- комментарии в коде;
|
||||||
|
- тексты в файлах настроек и справочных файлах;
|
||||||
|
- сообщения и описания в коммитах;
|
||||||
|
- сопроводительные технические заметки.
|
||||||
|
|
||||||
|
## Rule: Logic and Docs
|
||||||
|
|
||||||
|
Если меняется бизнес-логика смарт-контрактов, сериализация PDA, правила переводов или экономика:
|
||||||
|
|
||||||
|
1. Обновить соответствующий документ в `doc/` в том же изменении.
|
||||||
|
2. Если документ сразу обновить нельзя, обязательно явно согласовать это с пользователем в чате и зафиксировать план обновления.
|
||||||
|
|
||||||
|
## Rule: Git Push
|
||||||
|
|
||||||
|
Для push в удаленный репозиторий использовать токен из переменной окружения:
|
||||||
|
|
||||||
|
- `GITEA_TOKEN`
|
||||||
|
|
||||||
|
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/`).
|
||||||
|
|
||||||
|
## Rule: Dictionary Growth Reporting
|
||||||
|
|
||||||
|
Если пользователь просит увеличить количество слов в словарях `shine_login_guard`:
|
||||||
|
|
||||||
|
1. Увеличивать словарь в первую очередь в явно указанных пользователем категориях/файлах.
|
||||||
|
2. Если в конкретной категории добавлять новые уместные слова уже затруднительно, прямо сообщать об этом и предлагать соседние категории для расширения.
|
||||||
|
3. После каждого такого изменения выводить количество слов по каждому файлу словаря:
|
||||||
|
- `src/dictionaries/premium/*.txt`
|
||||||
|
- `src/dictionaries/trademarks/*.txt`
|
||||||
|
4. В отчете дополнительно кратко оценивать заполненность категорий (где есть смысл расширять, где уже близко к насыщению).
|
||||||
|
5. В конце каждого увеличения словаря обязательно выводить итог:
|
||||||
|
- общее число слов для premium;
|
||||||
|
- общее число слов для trademarks.
|
||||||
29
shine-solana/shine/Anchor.toml
Normal file
29
shine-solana/shine/Anchor.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[toolchain]
|
||||||
|
package_manager = "yarn"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
resolution = true
|
||||||
|
skip-lint = false
|
||||||
|
|
||||||
|
[programs.devnet]
|
||||||
|
shine_payments = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR"
|
||||||
|
shine_users = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"
|
||||||
|
shine_login_guard = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"
|
||||||
|
|
||||||
|
[programs.localnet]
|
||||||
|
shine_payments = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR"
|
||||||
|
shine_users = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"
|
||||||
|
shine_login_guard = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"
|
||||||
|
|
||||||
|
[registry]
|
||||||
|
url = "https://api.apr.dev"
|
||||||
|
|
||||||
|
[provider]
|
||||||
|
cluster = "devnet"
|
||||||
|
wallet = "~/.config/solana/id.json"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["programs/shine_users", "programs/shine_payments", "programs/shine_login_guard"]
|
||||||
|
|
||||||
|
[scripts]
|
||||||
|
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
|
||||||
7464
shine-solana/shine/Cargo.lock
generated
Normal file
7464
shine-solana/shine/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
shine-solana/shine/Cargo.toml
Normal file
20
shine-solana/shine/Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"programs/common",
|
||||||
|
"programs/shine_login_guard",
|
||||||
|
"programs/shine_users",
|
||||||
|
"programs/shine_payments",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
overflow-checks = true
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
[profile.release.build-override]
|
||||||
|
opt-level = 3
|
||||||
|
incremental = false
|
||||||
|
codegen-units = 1
|
||||||
44
shine-solana/shine/build.gradle
Normal file
44
shine-solana/shine/build.gradle
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
47
shine-solana/shine/doc/FUNDS_FLOW.md
Normal file
47
shine-solana/shine/doc/FUNDS_FLOW.md
Normal 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`.
|
||||||
225
shine-solana/shine/doc/SHiNE-user-format-v.1.0.md
Normal file
225
shine-solana/shine/doc/SHiNE-user-format-v.1.0.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# 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` (сейчас 1024 байта).
|
||||||
|
- `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. `sync_servers_count` (только если `is_server = 1`)
|
||||||
|
Размер: 1 байт (`u8`).
|
||||||
|
Назначение: количество серверов, с которыми сервер синхронизирует данные.
|
||||||
|
Ограничение: максимум `32`.
|
||||||
|
|
||||||
|
24. Повтор `sync_servers_count` раз (только если `is_server = 1`):
|
||||||
|
`server_login_len` — 1 байт (`u8`),
|
||||||
|
`server_login` — `server_login_len` байт (UTF-8).
|
||||||
|
Назначение: логины серверов синхронизации.
|
||||||
|
|
||||||
|
25. `access_servers_count`
|
||||||
|
Размер: 1 байт (`u8`).
|
||||||
|
Назначение: количество серверов доступа (relay), через которые можно достучаться до пользователя.
|
||||||
|
|
||||||
|
26. Повтор `access_servers_count` раз:
|
||||||
|
`server_login_len` — 1 байт (`u8`),
|
||||||
|
`server_login` — `server_login_len` байт (UTF-8).
|
||||||
|
Назначение: логины серверов доступа.
|
||||||
|
|
||||||
|
27. `trusted_count`
|
||||||
|
Размер: 1 байт (`u8`).
|
||||||
|
Назначение: текущее число trusted-контактов.
|
||||||
|
Текущее состояние: пока только счетчик, без отдельной trusted-логики.
|
||||||
|
|
||||||
|
28. `reserved`
|
||||||
|
Размер: 5 байт.
|
||||||
|
Текущее значение: `0x00 0x00 0x00 0x00 0x00`.
|
||||||
|
Назначение: резерв под будущие расширения.
|
||||||
|
|
||||||
|
29. `signature`
|
||||||
|
Размер: 64 байта.
|
||||||
|
Назначение: Ed25519-подпись хэша unsigned-части записи.
|
||||||
|
|
||||||
|
30. `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`, `sync_servers`, `access_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. Работа с несколькими серверами на уровне приложения ограничена
|
||||||
|
В записи можно хранить `sync_servers` и `access_servers`, но фактическая клиентская логика выбора/обхода серверов может быть ограничена.
|
||||||
|
|
||||||
|
|
||||||
|
## 7) Константы и фиксированные значения (точки будущего расширения)
|
||||||
|
|
||||||
|
Ниже перечислены места, где сейчас используются константы/фиксированные значения, а в будущем возможна доработка:
|
||||||
|
|
||||||
|
1. Версия формата: `format_major = 1`, `format_minor = 0`.
|
||||||
|
Расширение: переход на следующую минорную/мажорную версию при изменении бинарной схемы.
|
||||||
|
|
||||||
|
2. Размер PDA: `USER_PDA_SPACE = 1024`.
|
||||||
|
Расширение: увеличение размера или переход на иное хранение при росте структуры.
|
||||||
|
|
||||||
|
3. Статусы ключей: все три `*_key_status` пока равны `0`.
|
||||||
|
Расширение: добавить коды состояний для ротации/восстановления ключей.
|
||||||
|
|
||||||
|
4. `chain_number`: текущий рабочий сценарий с одним профилем (обычно `1`).
|
||||||
|
Расширение: поддержка нескольких блокчейн-форков.
|
||||||
|
|
||||||
|
5. `trusted_count`: пока только счетчик, обычно `0`.
|
||||||
|
Расширение: отдельные структуры trusted-списка, очередей и таймеров.
|
||||||
|
|
||||||
|
6. `reserved` (5 байт): сейчас всегда нули.
|
||||||
|
Расширение: использовать как флаги/дополнительные поля без слома общей схемы.
|
||||||
55
shine-solana/shine/doc/devnet_keys_and_deploy.md
Normal file
55
shine-solana/shine/doc/devnet_keys_and_deploy.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# Ключи и деплой (тестовое пояснение)
|
||||||
|
|
||||||
|
## 1) Какие адреса участвуют
|
||||||
|
|
||||||
|
В проекте есть **2 программы**, поэтому у них **2 разных Program ID**:
|
||||||
|
|
||||||
|
1. `shine_users` -> отдельный адрес программы
|
||||||
|
2. `shine_payments` -> отдельный адрес программы
|
||||||
|
|
||||||
|
Это нормальная схема Solana: одна программа = один Program ID.
|
||||||
|
|
||||||
|
Отдельно есть адрес кошелька-деплоера (upgrade authority), сейчас это:
|
||||||
|
|
||||||
|
- keypair: `~/.config/solana/id.json`
|
||||||
|
- адрес: `4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv`
|
||||||
|
|
||||||
|
Именно этот кошелек:
|
||||||
|
|
||||||
|
- платит комиссии/ренту при деплое;
|
||||||
|
- владеет правом апгрейда программ;
|
||||||
|
- получает обратно SOL при `solana program close`.
|
||||||
|
|
||||||
|
## 2) Почему раньше "плавали" адреса программ
|
||||||
|
|
||||||
|
`anchor deploy` берет адрес программы из program keypair файла (`target/deploy/*-keypair.json`).
|
||||||
|
Если keypair другой, Program ID тоже будет другой.
|
||||||
|
|
||||||
|
Чтобы этого не было, нужно держать синхронно:
|
||||||
|
|
||||||
|
1. `declare_id!` в `programs/*/src/lib.rs`
|
||||||
|
2. `[programs.devnet]` и `[programs.localnet]` в `Anchor.toml`
|
||||||
|
3. соответствующие `*-keypair.json` для программ
|
||||||
|
|
||||||
|
Сделано:
|
||||||
|
|
||||||
|
- выполнен `anchor keys sync`;
|
||||||
|
- keypair CLI по умолчанию переключен на `~/.config/solana/id.json`;
|
||||||
|
- сохранены копии program keypair в `shine/keys/`.
|
||||||
|
|
||||||
|
## 3) Сколько SOL занимали программы раньше (до закрытия)
|
||||||
|
|
||||||
|
Перед очисткой были закрыты 4 программы с такими возвратами:
|
||||||
|
|
||||||
|
1. `8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ` -> `3.38059032 SOL`
|
||||||
|
2. `qpgnAKhsXgPPaqQWfXhpme7UnG8GyStssuoSjF6Fzy3` -> `2.11208856 SOL`
|
||||||
|
3. `5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t` -> `1.76425560 SOL`
|
||||||
|
4. `92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW` -> `1.66820760 SOL`
|
||||||
|
|
||||||
|
Итого было занято программами:
|
||||||
|
|
||||||
|
- `8.92514208 SOL`
|
||||||
|
|
||||||
|
Из них "актуальная пара" (2 программы последнего деплоя) занимала:
|
||||||
|
|
||||||
|
- `3.38059032 + 2.11208856 = 5.49267888 SOL`
|
||||||
12
shine-solana/shine/migrations/deploy.ts
Normal file
12
shine-solana/shine/migrations/deploy.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Migrations are an early feature. Currently, they're nothing more than this
|
||||||
|
// single deploy script that's invoked from the CLI, injecting a provider
|
||||||
|
// configured from the workspace's Anchor.toml.
|
||||||
|
|
||||||
|
import * as anchor from "@coral-xyz/anchor";
|
||||||
|
|
||||||
|
module.exports = async function (provider: anchor.AnchorProvider) {
|
||||||
|
// Configure client to use the provider.
|
||||||
|
anchor.setProvider(provider);
|
||||||
|
|
||||||
|
// Add your deploy script here.
|
||||||
|
};
|
||||||
3021
shine-solana/shine/package-lock.json
generated
Normal file
3021
shine-solana/shine/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
shine-solana/shine/package.json
Normal file
27
shine-solana/shine/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"license": "ISC",
|
||||||
|
"scripts": {
|
||||||
|
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
|
||||||
|
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@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": {
|
||||||
|
"@types/bn.js": "^5.1.0",
|
||||||
|
"@types/chai": "^4.3.0",
|
||||||
|
"@types/mocha": "^9.0.0",
|
||||||
|
"chai": "^4.3.4",
|
||||||
|
"mocha": "^9.0.3",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"ts-mocha": "^10.0.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
shine-solana/shine/programs/common/Cargo.toml
Normal file
10
shine-solana/shine/programs/common/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "common"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anchor-lang = "0.31.1"
|
||||||
|
|
||||||
|
|
||||||
|
[features]
|
||||||
43
shine-solana/shine/programs/common/src/deploy_config.rs
Normal file
43
shine-solana/shine/programs/common/src/deploy_config.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
//! Единый деплой-конфиг проекта 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";
|
||||||
|
|
||||||
|
/// `SHINE_LOGIN_GUARD_PROGRAM_ID` — адрес программы проверки платных логинов.
|
||||||
|
pub const SHINE_LOGIN_GUARD_PROGRAM_ID: &str = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 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";
|
||||||
2
shine-solana/shine/programs/common/src/lib.rs
Normal file
2
shine-solana/shine/programs/common/src/lib.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod utils;
|
||||||
|
pub mod deploy_config;
|
||||||
359
shine-solana/shine/programs/common/src/utils.rs
Normal file
359
shine-solana/shine/programs/common/src/utils.rs
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_lang::solana_program::{program::invoke_signed, system_instruction};
|
||||||
|
|
||||||
|
/// сдесь коды всех ошибок
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
|
||||||
|
#[msg("Невалидная цифровая подпись записи")]
|
||||||
|
InvalidSignature = 1013,
|
||||||
|
|
||||||
|
#[msg("Невалидный формат записи")]
|
||||||
|
InvalidRecordFormat = 1014,
|
||||||
|
|
||||||
|
#[msg("Невалидная длина записи")]
|
||||||
|
InvalidRecordLength = 1015,
|
||||||
|
|
||||||
|
#[msg("Невалидные данные записи")]
|
||||||
|
InvalidRecordData = 1016,
|
||||||
|
|
||||||
|
#[msg("Невалидный хэш предыдущей версии")]
|
||||||
|
InvalidPrevHash = 1017,
|
||||||
|
|
||||||
|
#[msg("Попытка изменить неизменяемое поле")]
|
||||||
|
ImmutableFieldChanged = 1018,
|
||||||
|
|
||||||
|
#[msg("Попытка уменьшить лимит/баланс")]
|
||||||
|
BalanceDecrease = 1019,
|
||||||
|
|
||||||
|
#[msg("Невалидная версия записи")]
|
||||||
|
InvalidVersion = 1020,
|
||||||
|
|
||||||
|
#[msg("Размер записи превышает допустимый")]
|
||||||
|
RecordTooLarge = 1021,
|
||||||
|
|
||||||
|
#[msg("Переполнение при вычислении")]
|
||||||
|
MathOverflow = 1022,
|
||||||
|
|
||||||
|
#[msg("Неверный адрес получателя комиссии")]
|
||||||
|
InvalidFeeReceiver = 1023,
|
||||||
|
|
||||||
|
#[msg("Пополнение лимита должно быть кратно шагу")]
|
||||||
|
InvalidLimitIncrement = 1024,
|
||||||
|
|
||||||
|
#[msg("Невалидная magic-сигнатура записи")]
|
||||||
|
InvalidRecordMagic = 1025,
|
||||||
|
|
||||||
|
#[msg("Логин относится к платным и требует отдельной покупки через DAO")]
|
||||||
|
PremiumLogin = 1026,
|
||||||
|
|
||||||
|
#[msg("Некорректный ответ программы проверки логина")]
|
||||||
|
InvalidLoginGuardResponse = 1027,
|
||||||
|
|
||||||
|
#[msg("Логин использует брендовый термин и требует дополнительной верификации")]
|
||||||
|
TrademarkLoginRequiresReview = 1028,
|
||||||
|
}
|
||||||
|
|
||||||
|
///----------------------------------------------------------------------------------------------------------
|
||||||
|
/// Базовые функции для работы с PDA
|
||||||
|
///----------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Создаёт PDA аккаунт (если его ещё нет), и записывает в него массив байт.
|
||||||
|
///
|
||||||
|
/// Аргументы:
|
||||||
|
/// - `pda_account`: аккаунт, куда записываем
|
||||||
|
/// - `signer`: кто платит за создание (обычно пользователь)
|
||||||
|
/// - `program_id`: адрес текущей программы
|
||||||
|
/// - `seeds`: слайс сидов, по которым создавался PDA
|
||||||
|
/// - `data`: байты для записи
|
||||||
|
/// - `space`: желаемый размер аккаунта
|
||||||
|
pub fn create_and_write_pda<'info>(
|
||||||
|
pda_account: &AccountInfo<'info>,
|
||||||
|
signer: &AccountInfo<'info>,
|
||||||
|
system_program: &AccountInfo<'info>,
|
||||||
|
program_id: &Pubkey,
|
||||||
|
seeds: &[&[u8]],
|
||||||
|
data: Vec<u8>,
|
||||||
|
space: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 1. Проверяем, создан ли аккаунт (если нет — owner = default)
|
||||||
|
if pda_account.owner == &Pubkey::default() {
|
||||||
|
msg!("Создаём PDA с размером {} байт", space);
|
||||||
|
|
||||||
|
let space = space; //+ 128; // Добавляется запас под метаданные
|
||||||
|
// Вычисляем необходимую арендную плату
|
||||||
|
let lamports = Rent::get()?.minimum_balance(space as usize);
|
||||||
|
|
||||||
|
// Формируем инструкцию
|
||||||
|
let create_instr = system_instruction::create_account(
|
||||||
|
signer.key,
|
||||||
|
pda_account.key,
|
||||||
|
lamports,
|
||||||
|
space,
|
||||||
|
program_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Выполняем инструкцию с подписью от PDA
|
||||||
|
invoke_signed(
|
||||||
|
&create_instr,
|
||||||
|
&[signer.clone(), pda_account.clone(), system_program.clone()],
|
||||||
|
&[&seeds],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 2. Пишем данные в аккаунт
|
||||||
|
let mut account_data = pda_account.try_borrow_mut_data()?;
|
||||||
|
|
||||||
|
let copy_len = std::cmp::min(account_data.len(), data.len());
|
||||||
|
account_data[..copy_len].copy_from_slice(&data[..copy_len]);
|
||||||
|
|
||||||
|
// Если хочешь дополнить оставшееся нулями — раскомментируй:
|
||||||
|
// for i in copy_len..account_data.len() {
|
||||||
|
// account_data[i] = 0;
|
||||||
|
// }
|
||||||
|
|
||||||
|
msg!("Успешно записано {} байт в PDA", copy_len);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создаёт PDA аккаунт (если его ещё нет).
|
||||||
|
///
|
||||||
|
/// ⚠️ Если аккаунт уже существует, выбрасывается ошибка.
|
||||||
|
/// Используется внутри инструкций смарт-контракта.
|
||||||
|
///
|
||||||
|
/// Аргументы:
|
||||||
|
/// - `pda_account`: аккаунт, который хотим создать (PDA)
|
||||||
|
/// - `signer`: кто оплачивает создание аккаунта (обычно пользователь)
|
||||||
|
/// - `system_program`: системная программа (`111...111`)
|
||||||
|
/// - `program_id`: адрес текущей программы (используется для подписи PDA)
|
||||||
|
/// - `seeds`: массив сидов, по которым вычислялся PDA
|
||||||
|
/// - `space`: желаемый размер аккаунта в байтах (только данных, без метаданных)
|
||||||
|
pub fn create_pda<'info>(
|
||||||
|
pda_account: &AccountInfo<'info>,
|
||||||
|
signer: &AccountInfo<'info>,
|
||||||
|
system_program: &AccountInfo<'info>,
|
||||||
|
program_id: &Pubkey,
|
||||||
|
seeds: &[&[u8]],
|
||||||
|
space: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 1. Проверяем, существует ли аккаунт
|
||||||
|
if pda_account.owner != &Pubkey::default() {
|
||||||
|
// Если владелец не равен Pubkey::default, значит аккаунт уже создан
|
||||||
|
// Возвращаем ошибку с пояснением
|
||||||
|
return Err(error!(ErrCode::PdaAlreadyExists));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 2. Логируем, что будем создавать PDA
|
||||||
|
msg!("Создаём PDA-аккаунт на {} байт", space);
|
||||||
|
|
||||||
|
// Добавляем запас под метаданные Solana (примерно 128 байт)
|
||||||
|
let full_space = space;
|
||||||
|
|
||||||
|
// Получаем минимальный баланс для аренды (чтобы аккаунт не удалили)
|
||||||
|
let lamports = Rent::get()?.minimum_balance(full_space as usize);
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 3. Создаём инструкцию system_program для создания аккаунта
|
||||||
|
let create_instr = system_instruction::create_account(
|
||||||
|
signer.key, // от имени кого
|
||||||
|
pda_account.key, // для какого PDA
|
||||||
|
lamports, // сколько лампортов перевести
|
||||||
|
full_space, // сколько байт выделить
|
||||||
|
program_id, // кто будет владельцем PDA
|
||||||
|
);
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 4. Выполняем инструкцию с подписью PDA (через сиды)
|
||||||
|
invoke_signed(
|
||||||
|
&create_instr,
|
||||||
|
&[signer.clone(), pda_account.clone(), system_program.clone()],
|
||||||
|
&[&seeds], // PDA сиды → для подписи
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Записывает массив байт в PDA аккаунт (в начало data-секции).
|
||||||
|
///
|
||||||
|
/// ⚠️ Убедись, что PDA был передан как `#[account(mut)]`
|
||||||
|
/// ⚠️ Эта функция ничего не создаёт, только пишет.
|
||||||
|
///
|
||||||
|
/// Аргументы:
|
||||||
|
/// - `pda_account`: аккаунт, в который пишем (должен быть mut)
|
||||||
|
/// - `data`: бинарный массив, который нужно записать
|
||||||
|
pub fn write_to_pda<'info>(pda_account: &AccountInfo<'info>, data: &[u8]) -> Result<()> {
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 1. Получаем доступ к данным PDA (на запись)
|
||||||
|
let mut account_data = pda_account.try_borrow_mut_data()?;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 2. Вычисляем сколько байт реально можно записать
|
||||||
|
// (на случай, если data длиннее, чем выделено место)
|
||||||
|
let copy_len = std::cmp::min(account_data.len(), data.len());
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// 3. Копируем данные в аккаунт (с самого начала)
|
||||||
|
account_data[..copy_len].copy_from_slice(&data[..copy_len]);
|
||||||
|
|
||||||
|
// Логируем, сколько байт записано
|
||||||
|
msg!("Успешно записано {} байт в PDA", copy_len);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ------------------------------------------------------------------------
|
||||||
|
/// safe_read_pda ‒ «безопасное чтение PDA»
|
||||||
|
/// ------------------------------------------------------------------------
|
||||||
|
///
|
||||||
|
/// * Принимает: ссылку на `AccountInfo<'info>` PDA-аккаунта.
|
||||||
|
/// * Возвращает: `Vec<u8>` с данными аккаунта.
|
||||||
|
/// Если аккаунта нет или его данные пусты — возвращается `Vec::new()`
|
||||||
|
/// длиной 0 байт.
|
||||||
|
///
|
||||||
|
/// Как работает ───────────────────────────────────────────────────────────
|
||||||
|
/// 1. Проверяем, что аккаунт **инициализирован**: у не-инициализированного
|
||||||
|
/// owner = Pubkey::default(). Если owner нулевой — сразу отдаём пустой вектор.
|
||||||
|
/// 2. Если длина буфера == 0 (Anchor helper `data_is_empty()`), тоже отдаём пустой.
|
||||||
|
/// 3. Пытаемся безопасно (`try_borrow_data`) получить ссылку на данные.
|
||||||
|
/// - Успех → копируем их в Vec и возвращаем.
|
||||||
|
/// - Ошибка (например, конфликт borrow) → логируем и возвращаем пустой Vec.
|
||||||
|
///
|
||||||
|
/// пример использования
|
||||||
|
/// let raw_bytes = safe_read_pda(&ctx.accounts.readonly_pda);
|
||||||
|
/// require!(!raw_bytes.is_empty(), ErrCode::EmptyPdaData);
|
||||||
|
/// msg!("Размер считанных данных: {}", raw_bytes.len());
|
||||||
|
/// ------------------------------------------------------------------------
|
||||||
|
pub fn safe_read_pda<'info>(pda_account: &AccountInfo<'info>) -> Vec<u8> {
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 1) Аккаунт Н*Е* СУЩЕСТВУЕТ или не инициализирован:
|
||||||
|
// owner == Pubkey::default() (в Solana нулевой owner у пустого счёта)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if pda_account.owner == &Pubkey::default() {
|
||||||
|
msg!("safe_read_pda: аккаунт не инициализирован ‒ возвращаем пустой массив");
|
||||||
|
return Vec::new(); // []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 2) У аккаунта нет данных (длина 0) — тоже считаем «пустым»
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if pda_account.data_is_empty() {
|
||||||
|
msg!("safe_read_pda: у аккаунта data_len == 0 ‒ возвращаем пустой массив");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 3) Пытаемся безопасно забрать буфер данных; ошибки перехватываем
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
match pda_account.try_borrow_data() {
|
||||||
|
Ok(data_ref) => {
|
||||||
|
// to_vec() копирует bytes → Vec<u8>, чтобы дальше работать без borrow-лифа
|
||||||
|
data_ref.to_vec()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Ошибка при borrow (например, уже есть активное мутабельное заимствование)
|
||||||
|
msg!(
|
||||||
|
"safe_read_pda: ошибка borrow_data ({:?}) ‒ возвращаем пустой массив",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ------------------------------------------------------------------------
|
||||||
|
/// delete_pda_with_assign — закрыть PDA, вернуть ренту и освободить адрес
|
||||||
|
/// ------------------------------------------------------------------------
|
||||||
|
///
|
||||||
|
/// Параметры:
|
||||||
|
/// - `pda_account` : PDA-аккаунт (mut), который закрываем (owned вашей программой)
|
||||||
|
/// - `recipient` : счёт, на который возвращаем лампорты (обычно пользователь)
|
||||||
|
/// - `system_program`: системная программа (111...111)
|
||||||
|
/// - `program_id` : Pubkey вашей программы (проверка владельца)
|
||||||
|
/// - `seeds` : сиды PDA (в том же порядке, как при создании), чтобы PDA «подписал» assign
|
||||||
|
///
|
||||||
|
/// Делает:
|
||||||
|
/// 1) Проверяет, что PDA принадлежит вашей программе.
|
||||||
|
/// 2) Обнуляет данные и сжимает их до 0 байт (realloc(0)).
|
||||||
|
/// 3) Переводит все лампорты PDA на `recipient`.
|
||||||
|
/// 4) Делает `assign` владельца на System Program (через `invoke_signed`).
|
||||||
|
///
|
||||||
|
/// Результат:
|
||||||
|
/// — В конце транзакции аккаунт с lamports=0 и data_len=0 будет удалён рантаймом,
|
||||||
|
/// владелец = System Program (чисто/ожидаемо).
|
||||||
|
/// — В следующей транзакции можно снова создать PDA с тем же сидом.
|
||||||
|
/// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn delete_pda_return_rent<'info>(
|
||||||
|
pda_account: &AccountInfo<'info>,
|
||||||
|
recipient: &AccountInfo<'info>,
|
||||||
|
program_id: &Pubkey,
|
||||||
|
) -> Result<()> {
|
||||||
|
// 0) проверки
|
||||||
|
require!(
|
||||||
|
pda_account.owner != &Pubkey::default(),
|
||||||
|
ErrCode::EmptyPdaData
|
||||||
|
);
|
||||||
|
require!(pda_account.owner == program_id, ErrCode::InvalidPdaAddress);
|
||||||
|
|
||||||
|
// 1) Переложить все лампорты с PDA на получателя (мы владелец, это разрешено)
|
||||||
|
let amount = **pda_account.lamports.borrow();
|
||||||
|
if amount > 0 {
|
||||||
|
**recipient.lamports.borrow_mut() = recipient
|
||||||
|
.lamports()
|
||||||
|
.checked_add(amount)
|
||||||
|
.ok_or(ProgramError::InsufficientFunds)?;
|
||||||
|
**pda_account.lamports.borrow_mut() = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Нулим данные (если были)
|
||||||
|
if !pda_account.data_is_empty() {
|
||||||
|
let mut data = pda_account.try_borrow_mut_data()?;
|
||||||
|
for b in data.iter_mut() {
|
||||||
|
*b = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Сжать до 0 байт
|
||||||
|
pda_account.realloc(0, false)?;
|
||||||
|
|
||||||
|
// Никаких assign/transfer больше не делаем — это надёжнее.
|
||||||
|
msg!("PDA закрыт: рента отправлена на {}", recipient.key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
26
shine-solana/shine/programs/shine_login_guard/Cargo.toml
Normal file
26
shine-solana/shine/programs/shine_login_guard/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "shine_login_guard"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Premium login classification program"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
name = "shine_login_guard"
|
||||||
|
test = false
|
||||||
|
doctest = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anchor-lang = "0.31.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
no-entrypoint = []
|
||||||
|
no-idl = []
|
||||||
|
no-log-ix-name = []
|
||||||
|
anchor-debug = []
|
||||||
|
custom-heap = []
|
||||||
|
custom-panic = []
|
||||||
|
cpi = []
|
||||||
|
idl-build = ["anchor-lang/idl-build"]
|
||||||
114
shine-solana/shine/programs/shine_login_guard/build.rs
Normal file
114
shine-solana/shine/programs/shine_login_guard/build.rs
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const PREMIUM_DIR: &str = "src/dictionaries/premium";
|
||||||
|
const TRADEMARKS_DIR: &str = "src/dictionaries/trademarks";
|
||||||
|
|
||||||
|
fn normalize_word(word: &str) -> Option<String> {
|
||||||
|
let w = word.trim().to_ascii_lowercase();
|
||||||
|
if w.is_empty() || w.len() > 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !w.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gather_files(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
files.extend(gather_files(&p));
|
||||||
|
} else if p.extension().and_then(|s| s.to_str()) == Some("txt") {
|
||||||
|
files.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_word_set(dir: &Path, label: &str) -> BTreeSet<String> {
|
||||||
|
let mut out = BTreeSet::new();
|
||||||
|
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||||
|
for file in gather_files(dir) {
|
||||||
|
println!("cargo:rerun-if-changed={}", file.display());
|
||||||
|
let raw = fs::read_to_string(&file).unwrap_or_default();
|
||||||
|
for line in raw.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(w) = normalize_word(line) {
|
||||||
|
*seen.entry(w.clone()).or_insert(0) += 1;
|
||||||
|
out.insert(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut duplicate_words = 0usize;
|
||||||
|
let mut duplicate_entries = 0usize;
|
||||||
|
let mut sample: Vec<String> = Vec::new();
|
||||||
|
let mut keys: Vec<_> = seen.keys().cloned().collect();
|
||||||
|
keys.sort();
|
||||||
|
for k in keys {
|
||||||
|
if let Some(cnt) = seen.get(&k) {
|
||||||
|
if *cnt > 1 {
|
||||||
|
duplicate_words += 1;
|
||||||
|
duplicate_entries += cnt - 1;
|
||||||
|
if sample.len() < 40 {
|
||||||
|
sample.push(format!("{k} x{cnt}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if duplicate_words > 0 {
|
||||||
|
println!(
|
||||||
|
"cargo:warning=[{label}] duplicates found: words={}, extra_entries={}",
|
||||||
|
duplicate_words, duplicate_entries
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"cargo:warning=[{label}] duplicate samples: {}",
|
||||||
|
sample.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let premium_dir = Path::new(PREMIUM_DIR);
|
||||||
|
let trademarks_dir = Path::new(TRADEMARKS_DIR);
|
||||||
|
println!("cargo:rerun-if-changed={}", premium_dir.display());
|
||||||
|
println!("cargo:rerun-if-changed={}", trademarks_dir.display());
|
||||||
|
|
||||||
|
let premium = load_word_set(premium_dir, "premium");
|
||||||
|
let trademarks = load_word_set(trademarks_dir, "trademarks");
|
||||||
|
|
||||||
|
let premium_words: Vec<String> = premium.into_iter().collect();
|
||||||
|
let trademark_words: Vec<String> = trademarks.into_iter().collect();
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("// @generated by build.rs\n");
|
||||||
|
out.push_str("pub static PREMIUM_WORDS: &[&str] = &[\n");
|
||||||
|
for w in &premium_words {
|
||||||
|
out.push_str(" \"");
|
||||||
|
out.push_str(w);
|
||||||
|
out.push_str("\",\n");
|
||||||
|
}
|
||||||
|
out.push_str("];\n");
|
||||||
|
out.push_str("pub static TRADEMARK_WORDS: &[&str] = &[\n");
|
||||||
|
for w in &trademark_words {
|
||||||
|
out.push_str(" \"");
|
||||||
|
out.push_str(w);
|
||||||
|
out.push_str("\",\n");
|
||||||
|
}
|
||||||
|
out.push_str("];\n");
|
||||||
|
|
||||||
|
let out_dir = env::var("OUT_DIR").expect("OUT_DIR is not set");
|
||||||
|
let dst = Path::new(&out_dir).join("generated_dictionary.rs");
|
||||||
|
fs::write(dst, out).expect("failed to write generated dictionary");
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
# Premium dictionary: adjectives / style words
|
||||||
|
# Премиум-словарь: прилагательные / слова-стили
|
||||||
|
alpha
|
||||||
|
arcane
|
||||||
|
atomic
|
||||||
|
aurora
|
||||||
|
bold
|
||||||
|
brave
|
||||||
|
bright
|
||||||
|
calm
|
||||||
|
clean
|
||||||
|
cool
|
||||||
|
cosmic
|
||||||
|
crazy
|
||||||
|
crisp
|
||||||
|
crystal
|
||||||
|
cyber
|
||||||
|
dark
|
||||||
|
daring
|
||||||
|
deep
|
||||||
|
divine
|
||||||
|
electric
|
||||||
|
elite
|
||||||
|
epic
|
||||||
|
fast
|
||||||
|
final
|
||||||
|
fluid
|
||||||
|
fresh
|
||||||
|
funny
|
||||||
|
future
|
||||||
|
giant
|
||||||
|
global
|
||||||
|
golden
|
||||||
|
grand
|
||||||
|
great
|
||||||
|
happy
|
||||||
|
hyper
|
||||||
|
iconic
|
||||||
|
infinite
|
||||||
|
iron
|
||||||
|
legend
|
||||||
|
light
|
||||||
|
lucky
|
||||||
|
lunar
|
||||||
|
magic
|
||||||
|
mega
|
||||||
|
metal
|
||||||
|
modern
|
||||||
|
mystic
|
||||||
|
nano
|
||||||
|
neo
|
||||||
|
new
|
||||||
|
night
|
||||||
|
noble
|
||||||
|
official
|
||||||
|
omega
|
||||||
|
prime
|
||||||
|
pro
|
||||||
|
pure
|
||||||
|
quick
|
||||||
|
rapid
|
||||||
|
real
|
||||||
|
royal
|
||||||
|
sexy
|
||||||
|
sharp
|
||||||
|
silent
|
||||||
|
silver
|
||||||
|
smart
|
||||||
|
solid
|
||||||
|
sonic
|
||||||
|
special
|
||||||
|
star
|
||||||
|
steel
|
||||||
|
storm
|
||||||
|
strong
|
||||||
|
super
|
||||||
|
swift
|
||||||
|
top
|
||||||
|
true
|
||||||
|
ultra
|
||||||
|
united
|
||||||
|
urban
|
||||||
|
velvet
|
||||||
|
vivid
|
||||||
|
vip
|
||||||
|
wild
|
||||||
|
young
|
||||||
|
zen
|
||||||
@ -0,0 +1,421 @@
|
|||||||
|
# Premium dictionary: nicknames / persona words
|
||||||
|
# Премиум-словарь: никнеймы / слова-персоны
|
||||||
|
ace
|
||||||
|
agent
|
||||||
|
alpha
|
||||||
|
astro
|
||||||
|
bandit
|
||||||
|
beast
|
||||||
|
blaze
|
||||||
|
bolt
|
||||||
|
boss
|
||||||
|
bravo
|
||||||
|
bro
|
||||||
|
captain
|
||||||
|
champ
|
||||||
|
chief
|
||||||
|
commander
|
||||||
|
cosmo
|
||||||
|
crane
|
||||||
|
crow
|
||||||
|
crusher
|
||||||
|
cypher
|
||||||
|
dash
|
||||||
|
delta
|
||||||
|
diesel
|
||||||
|
drake
|
||||||
|
duke
|
||||||
|
eagle
|
||||||
|
echo
|
||||||
|
falcon
|
||||||
|
flash
|
||||||
|
fury
|
||||||
|
ghost
|
||||||
|
gladiator
|
||||||
|
hawk
|
||||||
|
hero
|
||||||
|
hunter
|
||||||
|
joker
|
||||||
|
judge
|
||||||
|
king
|
||||||
|
knight
|
||||||
|
legend
|
||||||
|
lion
|
||||||
|
lord
|
||||||
|
marshal
|
||||||
|
master
|
||||||
|
matrix
|
||||||
|
maverick
|
||||||
|
ninja
|
||||||
|
nomad
|
||||||
|
onyx
|
||||||
|
phantom
|
||||||
|
pilot
|
||||||
|
pirate
|
||||||
|
predator
|
||||||
|
prince
|
||||||
|
pro
|
||||||
|
queen
|
||||||
|
ranger
|
||||||
|
reaper
|
||||||
|
rex
|
||||||
|
rider
|
||||||
|
rookie
|
||||||
|
samurai
|
||||||
|
savage
|
||||||
|
sensei
|
||||||
|
shadow
|
||||||
|
shark
|
||||||
|
silver
|
||||||
|
skipper
|
||||||
|
sniper
|
||||||
|
soldier
|
||||||
|
sparrow
|
||||||
|
spartan
|
||||||
|
spirit
|
||||||
|
storm
|
||||||
|
striker
|
||||||
|
tiger
|
||||||
|
trailblazer
|
||||||
|
viking
|
||||||
|
warrior
|
||||||
|
whisper
|
||||||
|
wizard
|
||||||
|
wolf
|
||||||
|
wraith
|
||||||
|
zeus
|
||||||
|
admiral
|
||||||
|
afterglow
|
||||||
|
anvil
|
||||||
|
arrow
|
||||||
|
avenger
|
||||||
|
badger
|
||||||
|
banshee
|
||||||
|
baron
|
||||||
|
basilisk
|
||||||
|
bear
|
||||||
|
blackout
|
||||||
|
boomer
|
||||||
|
breaker
|
||||||
|
bronco
|
||||||
|
bullet
|
||||||
|
bulldog
|
||||||
|
butcher
|
||||||
|
caesar
|
||||||
|
cannon
|
||||||
|
cardinal
|
||||||
|
centurion
|
||||||
|
cerberus
|
||||||
|
charger
|
||||||
|
cheetah
|
||||||
|
cobra
|
||||||
|
colossus
|
||||||
|
comet
|
||||||
|
corsair
|
||||||
|
cyclone
|
||||||
|
daemon
|
||||||
|
defender
|
||||||
|
destroyer
|
||||||
|
dominator
|
||||||
|
dragon
|
||||||
|
dragonfly
|
||||||
|
dynamo
|
||||||
|
enigma
|
||||||
|
executor
|
||||||
|
firebrand
|
||||||
|
firefly
|
||||||
|
firestorm
|
||||||
|
firewolf
|
||||||
|
fisher
|
||||||
|
forger
|
||||||
|
freeman
|
||||||
|
frontier
|
||||||
|
frost
|
||||||
|
frostbite
|
||||||
|
gambit
|
||||||
|
general
|
||||||
|
goliath
|
||||||
|
griffin
|
||||||
|
grizzly
|
||||||
|
gunslinger
|
||||||
|
harbinger
|
||||||
|
hercules
|
||||||
|
hex
|
||||||
|
hornet
|
||||||
|
hurricane
|
||||||
|
hyena
|
||||||
|
icarus
|
||||||
|
inferno
|
||||||
|
jackal
|
||||||
|
jaguar
|
||||||
|
javelin
|
||||||
|
jester
|
||||||
|
judgex
|
||||||
|
keeper
|
||||||
|
killer
|
||||||
|
kraken
|
||||||
|
lancer
|
||||||
|
leviathan
|
||||||
|
lightning
|
||||||
|
locksmith
|
||||||
|
lynx
|
||||||
|
magnum
|
||||||
|
mercenary
|
||||||
|
merlin
|
||||||
|
mirage
|
||||||
|
monolith
|
||||||
|
monster
|
||||||
|
mustang
|
||||||
|
nebula
|
||||||
|
neutron
|
||||||
|
nightfall
|
||||||
|
nightfox
|
||||||
|
nightmare
|
||||||
|
nitro
|
||||||
|
obelisk
|
||||||
|
octane
|
||||||
|
odin
|
||||||
|
outlaw
|
||||||
|
overlord
|
||||||
|
panther
|
||||||
|
patriot
|
||||||
|
pegasus
|
||||||
|
phoenix
|
||||||
|
phoenixx
|
||||||
|
poison
|
||||||
|
protector
|
||||||
|
prowler
|
||||||
|
punisher
|
||||||
|
pyro
|
||||||
|
quasar
|
||||||
|
rampage
|
||||||
|
raptor
|
||||||
|
ravager
|
||||||
|
razor
|
||||||
|
renegade
|
||||||
|
revenant
|
||||||
|
riptide
|
||||||
|
roadster
|
||||||
|
ronin
|
||||||
|
saber
|
||||||
|
sabertooth
|
||||||
|
scorpion
|
||||||
|
sentinel
|
||||||
|
seraph
|
||||||
|
serpent
|
||||||
|
shogun
|
||||||
|
sidewinder
|
||||||
|
silencer
|
||||||
|
sirocco
|
||||||
|
sledge
|
||||||
|
specter
|
||||||
|
sphinx
|
||||||
|
stallion
|
||||||
|
starlord
|
||||||
|
stonewall
|
||||||
|
sunfire
|
||||||
|
survivor
|
||||||
|
talon
|
||||||
|
tempest
|
||||||
|
thor
|
||||||
|
thunder
|
||||||
|
thunderbolt
|
||||||
|
titan
|
||||||
|
tracker
|
||||||
|
trident
|
||||||
|
trooper
|
||||||
|
typhoon
|
||||||
|
tyrant
|
||||||
|
undertaker
|
||||||
|
valkyrie
|
||||||
|
vanguard
|
||||||
|
venom
|
||||||
|
vertex
|
||||||
|
vortex
|
||||||
|
warden
|
||||||
|
warlock
|
||||||
|
watcher
|
||||||
|
wildcard
|
||||||
|
windrunner
|
||||||
|
wingman
|
||||||
|
wolfhound
|
||||||
|
abyss
|
||||||
|
airstrike
|
||||||
|
alchemist
|
||||||
|
ambassador
|
||||||
|
apex
|
||||||
|
archer
|
||||||
|
assassin
|
||||||
|
atlas
|
||||||
|
backdraft
|
||||||
|
barrage
|
||||||
|
barricade
|
||||||
|
bastion
|
||||||
|
behemoth
|
||||||
|
berserker
|
||||||
|
bigfoot
|
||||||
|
blackhawk
|
||||||
|
blizzard
|
||||||
|
bloodhound
|
||||||
|
bluefire
|
||||||
|
bodyguard
|
||||||
|
bomber
|
||||||
|
booster
|
||||||
|
brick
|
||||||
|
broadsword
|
||||||
|
buck
|
||||||
|
buffalo
|
||||||
|
bughunter
|
||||||
|
captor
|
||||||
|
caretaker
|
||||||
|
carnage
|
||||||
|
catapult
|
||||||
|
cavalier
|
||||||
|
chargerx
|
||||||
|
chieftain
|
||||||
|
cliffhanger
|
||||||
|
clutch
|
||||||
|
codebreaker
|
||||||
|
colt
|
||||||
|
conqueror
|
||||||
|
contractor
|
||||||
|
cougar
|
||||||
|
crosshair
|
||||||
|
cryptic
|
||||||
|
darkstar
|
||||||
|
daybreak
|
||||||
|
deepstrike
|
||||||
|
demolisher
|
||||||
|
desperado
|
||||||
|
direwolf
|
||||||
|
doombringer
|
||||||
|
dozer
|
||||||
|
drifter
|
||||||
|
eclipse
|
||||||
|
ember
|
||||||
|
endgame
|
||||||
|
evoker
|
||||||
|
falconer
|
||||||
|
fencer
|
||||||
|
fierce
|
||||||
|
firehawk
|
||||||
|
firestarter
|
||||||
|
fist
|
||||||
|
flanker
|
||||||
|
flint
|
||||||
|
floodgate
|
||||||
|
forge
|
||||||
|
fortress
|
||||||
|
freefall
|
||||||
|
fugitive
|
||||||
|
gale
|
||||||
|
gamechanger
|
||||||
|
gatekeeper
|
||||||
|
gauntlet
|
||||||
|
glacier
|
||||||
|
godspeed
|
||||||
|
grave
|
||||||
|
gremlin
|
||||||
|
grim
|
||||||
|
hammer
|
||||||
|
hardline
|
||||||
|
headhunter
|
||||||
|
hellfire
|
||||||
|
helix
|
||||||
|
highlander
|
||||||
|
hitman
|
||||||
|
hotshot
|
||||||
|
iceman
|
||||||
|
icewind
|
||||||
|
immortal
|
||||||
|
incognito
|
||||||
|
invictus
|
||||||
|
ironclad
|
||||||
|
juggernaut
|
||||||
|
juniper
|
||||||
|
kamikaze
|
||||||
|
keymaster
|
||||||
|
kingpin
|
||||||
|
longshot
|
||||||
|
lowlight
|
||||||
|
madmax
|
||||||
|
marksman
|
||||||
|
megatron
|
||||||
|
midnight
|
||||||
|
minotaur
|
||||||
|
moonwalker
|
||||||
|
mutant
|
||||||
|
nightbird
|
||||||
|
nighthawk
|
||||||
|
nightwind
|
||||||
|
northstar
|
||||||
|
obsidian
|
||||||
|
officer
|
||||||
|
onslaught
|
||||||
|
operator
|
||||||
|
overdrive
|
||||||
|
paladin
|
||||||
|
pathfinder
|
||||||
|
patroller
|
||||||
|
peacekeeper
|
||||||
|
pendragon
|
||||||
|
pinpoint
|
||||||
|
plasma
|
||||||
|
poet
|
||||||
|
polar
|
||||||
|
raider
|
||||||
|
rainmaker
|
||||||
|
riptalon
|
||||||
|
risker
|
||||||
|
roadrunner
|
||||||
|
rocketeer
|
||||||
|
runeblade
|
||||||
|
safeguard
|
||||||
|
scalpel
|
||||||
|
scar
|
||||||
|
scout
|
||||||
|
shade
|
||||||
|
shellshock
|
||||||
|
shockwave
|
||||||
|
showstopper
|
||||||
|
skyfall
|
||||||
|
slayer
|
||||||
|
smokescreen
|
||||||
|
snowfall
|
||||||
|
solstice
|
||||||
|
soulforge
|
||||||
|
southpaw
|
||||||
|
spectral
|
||||||
|
speedster
|
||||||
|
spellbinder
|
||||||
|
spike
|
||||||
|
stargazer
|
||||||
|
stinger
|
||||||
|
sunstrike
|
||||||
|
supernova
|
||||||
|
tactician
|
||||||
|
teammate
|
||||||
|
thrasher
|
||||||
|
thunderbird
|
||||||
|
timekeeper
|
||||||
|
torch
|
||||||
|
tracer
|
||||||
|
trailhawk
|
||||||
|
trigger
|
||||||
|
troublemaker
|
||||||
|
twister
|
||||||
|
undertow
|
||||||
|
updraft
|
||||||
|
vanquisher
|
||||||
|
viper
|
||||||
|
void
|
||||||
|
wanderer
|
||||||
|
warpath
|
||||||
|
wavebreaker
|
||||||
|
westwind
|
||||||
|
whitewolf
|
||||||
|
wildfire
|
||||||
|
windstorm
|
||||||
|
wolfpack
|
||||||
|
wrangler
|
||||||
|
zenith
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
# Premium dictionary: nouns / entities
|
||||||
|
# Премиум-словарь: существительные / сущности
|
||||||
|
academy
|
||||||
|
access
|
||||||
|
account
|
||||||
|
agent
|
||||||
|
air
|
||||||
|
angel
|
||||||
|
app
|
||||||
|
arena
|
||||||
|
art
|
||||||
|
atlas
|
||||||
|
bank
|
||||||
|
base
|
||||||
|
beacon
|
||||||
|
beat
|
||||||
|
beta
|
||||||
|
bit
|
||||||
|
blade
|
||||||
|
block
|
||||||
|
board
|
||||||
|
bot
|
||||||
|
brand
|
||||||
|
bridge
|
||||||
|
buddy
|
||||||
|
build
|
||||||
|
buyer
|
||||||
|
byte
|
||||||
|
camp
|
||||||
|
capital
|
||||||
|
card
|
||||||
|
cash
|
||||||
|
center
|
||||||
|
chain
|
||||||
|
chat
|
||||||
|
city
|
||||||
|
class
|
||||||
|
club
|
||||||
|
coin
|
||||||
|
collective
|
||||||
|
company
|
||||||
|
community
|
||||||
|
connect
|
||||||
|
core
|
||||||
|
craft
|
||||||
|
crew
|
||||||
|
crown
|
||||||
|
dao
|
||||||
|
data
|
||||||
|
deal
|
||||||
|
delta
|
||||||
|
desk
|
||||||
|
dev
|
||||||
|
digital
|
||||||
|
direct
|
||||||
|
district
|
||||||
|
dock
|
||||||
|
domain
|
||||||
|
dream
|
||||||
|
drive
|
||||||
|
drop
|
||||||
|
edge
|
||||||
|
engine
|
||||||
|
exchange
|
||||||
|
expert
|
||||||
|
factory
|
||||||
|
family
|
||||||
|
farm
|
||||||
|
field
|
||||||
|
finance
|
||||||
|
flow
|
||||||
|
force
|
||||||
|
fox
|
||||||
|
fund
|
||||||
|
future
|
||||||
|
game
|
||||||
|
gate
|
||||||
|
genesis
|
||||||
|
ghost
|
||||||
|
global
|
||||||
|
gold
|
||||||
|
group
|
||||||
|
guard
|
||||||
|
guild
|
||||||
|
guru
|
||||||
|
hall
|
||||||
|
hero
|
||||||
|
hub
|
||||||
|
idea
|
||||||
|
index
|
||||||
|
info
|
||||||
|
infra
|
||||||
|
jet
|
||||||
|
joy
|
||||||
|
key
|
||||||
|
king
|
||||||
|
kit
|
||||||
|
labs
|
||||||
|
land
|
||||||
|
leader
|
||||||
|
league
|
||||||
|
line
|
||||||
|
link
|
||||||
|
list
|
||||||
|
logic
|
||||||
|
lounge
|
||||||
|
machine
|
||||||
|
maker
|
||||||
|
market
|
||||||
|
matrix
|
||||||
|
media
|
||||||
|
member
|
||||||
|
mint
|
||||||
|
mode
|
||||||
|
money
|
||||||
|
moon
|
||||||
|
network
|
||||||
|
nexus
|
||||||
|
node
|
||||||
|
nova
|
||||||
|
office
|
||||||
|
one
|
||||||
|
open
|
||||||
|
oracle
|
||||||
|
orbit
|
||||||
|
order
|
||||||
|
origin
|
||||||
|
owner
|
||||||
|
party
|
||||||
|
pay
|
||||||
|
pilot
|
||||||
|
planet
|
||||||
|
platform
|
||||||
|
play
|
||||||
|
point
|
||||||
|
pool
|
||||||
|
portal
|
||||||
|
power
|
||||||
|
project
|
||||||
|
protocol
|
||||||
|
pulse
|
||||||
|
queen
|
||||||
|
quest
|
||||||
|
radar
|
||||||
|
realm
|
||||||
|
relay
|
||||||
|
resource
|
||||||
|
rise
|
||||||
|
rocket
|
||||||
|
room
|
||||||
|
route
|
||||||
|
runner
|
||||||
|
safe
|
||||||
|
sale
|
||||||
|
scope
|
||||||
|
service
|
||||||
|
shop
|
||||||
|
signal
|
||||||
|
site
|
||||||
|
skill
|
||||||
|
sky
|
||||||
|
space
|
||||||
|
sphere
|
||||||
|
spot
|
||||||
|
squad
|
||||||
|
stack
|
||||||
|
stage
|
||||||
|
star
|
||||||
|
state
|
||||||
|
station
|
||||||
|
step
|
||||||
|
stock
|
||||||
|
store
|
||||||
|
stream
|
||||||
|
studio
|
||||||
|
suite
|
||||||
|
swap
|
||||||
|
system
|
||||||
|
team
|
||||||
|
tech
|
||||||
|
terminal
|
||||||
|
time
|
||||||
|
token
|
||||||
|
tower
|
||||||
|
trade
|
||||||
|
travel
|
||||||
|
tribe
|
||||||
|
trust
|
||||||
|
union
|
||||||
|
unit
|
||||||
|
universe
|
||||||
|
vault
|
||||||
|
vector
|
||||||
|
venture
|
||||||
|
verse
|
||||||
|
view
|
||||||
|
vision
|
||||||
|
voice
|
||||||
|
wallet
|
||||||
|
wave
|
||||||
|
way
|
||||||
|
web
|
||||||
|
world
|
||||||
|
zone
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@
|
|||||||
|
# Premium dictionary: prefixes and connectors
|
||||||
|
# Премиум-словарь: префиксы и слова-соединители
|
||||||
|
and
|
||||||
|
anti
|
||||||
|
best
|
||||||
|
bio
|
||||||
|
block
|
||||||
|
chain
|
||||||
|
cloud
|
||||||
|
crypto
|
||||||
|
dao
|
||||||
|
de
|
||||||
|
dr
|
||||||
|
eco
|
||||||
|
elite
|
||||||
|
exo
|
||||||
|
for
|
||||||
|
free
|
||||||
|
geo
|
||||||
|
global
|
||||||
|
go
|
||||||
|
hello
|
||||||
|
i
|
||||||
|
io
|
||||||
|
lab
|
||||||
|
meta
|
||||||
|
micro
|
||||||
|
multi
|
||||||
|
my
|
||||||
|
neo
|
||||||
|
new
|
||||||
|
no
|
||||||
|
omni
|
||||||
|
post
|
||||||
|
pre
|
||||||
|
pro
|
||||||
|
re
|
||||||
|
super
|
||||||
|
the
|
||||||
|
to
|
||||||
|
ultra
|
||||||
|
un
|
||||||
|
vip
|
||||||
|
web
|
||||||
|
x
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
# Premium dictionary: web3 and finance terms
|
||||||
|
# Премиум-словарь: термины web3 и финансов
|
||||||
|
airdrop
|
||||||
|
amm
|
||||||
|
arb
|
||||||
|
blockchain
|
||||||
|
bridge
|
||||||
|
burn
|
||||||
|
cex
|
||||||
|
chainlink
|
||||||
|
dao
|
||||||
|
defi
|
||||||
|
dex
|
||||||
|
drop
|
||||||
|
farm
|
||||||
|
farming
|
||||||
|
gas
|
||||||
|
governance
|
||||||
|
holder
|
||||||
|
launchpad
|
||||||
|
liquidity
|
||||||
|
lp
|
||||||
|
marketcap
|
||||||
|
mint
|
||||||
|
nft
|
||||||
|
node
|
||||||
|
oracle
|
||||||
|
pool
|
||||||
|
reward
|
||||||
|
rollup
|
||||||
|
staking
|
||||||
|
swap
|
||||||
|
token
|
||||||
|
tps
|
||||||
|
validator
|
||||||
|
vault
|
||||||
|
wallet
|
||||||
|
whale
|
||||||
|
dao
|
||||||
|
inc
|
||||||
|
limited
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
# Trademark-sensitive words: consumer and automotive brands
|
||||||
|
# Брендовый словарь: потребительские и автомобильные бренды
|
||||||
|
adidas
|
||||||
|
audi
|
||||||
|
bmw
|
||||||
|
burberry
|
||||||
|
canon
|
||||||
|
cartier
|
||||||
|
chanel
|
||||||
|
cocacola
|
||||||
|
ferrari
|
||||||
|
ford
|
||||||
|
gucci
|
||||||
|
honda
|
||||||
|
hyundai
|
||||||
|
ikea
|
||||||
|
kia
|
||||||
|
lacoste
|
||||||
|
lego
|
||||||
|
lexus
|
||||||
|
louisvuitton
|
||||||
|
mazda
|
||||||
|
mercedes
|
||||||
|
nestle
|
||||||
|
nike
|
||||||
|
nissan
|
||||||
|
pepsi
|
||||||
|
porsche
|
||||||
|
prada
|
||||||
|
puma
|
||||||
|
reebok
|
||||||
|
rolex
|
||||||
|
siemens
|
||||||
|
sony
|
||||||
|
starbucks
|
||||||
|
toyota
|
||||||
|
volkswagen
|
||||||
|
walmart
|
||||||
|
zara
|
||||||
|
acura
|
||||||
|
alfa
|
||||||
|
armani
|
||||||
|
astonmartin
|
||||||
|
balenciaga
|
||||||
|
bentley
|
||||||
|
bugatti
|
||||||
|
buick
|
||||||
|
cadillac
|
||||||
|
calvinklein
|
||||||
|
chevrolet
|
||||||
|
chrysler
|
||||||
|
citroen
|
||||||
|
daewoo
|
||||||
|
dior
|
||||||
|
dodge
|
||||||
|
fendi
|
||||||
|
fiat
|
||||||
|
garnier
|
||||||
|
gillette
|
||||||
|
givenchy
|
||||||
|
heineken
|
||||||
|
hermes
|
||||||
|
hugo
|
||||||
|
jaguar
|
||||||
|
jeep
|
||||||
|
kiaa
|
||||||
|
kiax
|
||||||
|
lancome
|
||||||
|
landrover
|
||||||
|
loewe
|
||||||
|
maserati
|
||||||
|
maybach
|
||||||
|
mini
|
||||||
|
mitsubishi
|
||||||
|
mustang
|
||||||
|
opel
|
||||||
|
patagonia
|
||||||
|
pontiac
|
||||||
|
renault
|
||||||
|
rimowa
|
||||||
|
skoda
|
||||||
|
subaru
|
||||||
|
tesco
|
||||||
|
tiffanyco
|
||||||
|
volvo
|
||||||
|
abercrombie
|
||||||
|
airjordan
|
||||||
|
alpine
|
||||||
|
aquafina
|
||||||
|
bacardi
|
||||||
|
baileys
|
||||||
|
barbie
|
||||||
|
benetton
|
||||||
|
bershka
|
||||||
|
bic
|
||||||
|
birkenstock
|
||||||
|
blackberry
|
||||||
|
blumarine
|
||||||
|
bosshugo
|
||||||
|
breitling
|
||||||
|
bridgestone
|
||||||
|
bugaboo
|
||||||
|
bulgari
|
||||||
|
campari
|
||||||
|
casio
|
||||||
|
champion
|
||||||
|
chupa
|
||||||
|
colgate
|
||||||
|
crocs
|
||||||
|
dettol
|
||||||
|
dove
|
||||||
|
drmartens
|
||||||
|
ecco
|
||||||
|
essilor
|
||||||
|
evian
|
||||||
|
fanta
|
||||||
|
fisherprice
|
||||||
|
fossil
|
||||||
|
gatorade
|
||||||
|
geox
|
||||||
|
gillettevenus
|
||||||
|
goodyear
|
||||||
|
guess
|
||||||
|
haagen
|
||||||
|
headshoulders
|
||||||
|
heinz
|
||||||
|
hitachi
|
||||||
|
hondaauto
|
||||||
|
hotwheels
|
||||||
|
jimmychoo
|
||||||
|
joop
|
||||||
|
kenzo
|
||||||
|
kipling
|
||||||
|
kodak
|
||||||
|
konica
|
||||||
|
kotex
|
||||||
|
lacosteparis
|
||||||
|
lanvin
|
||||||
|
leica
|
||||||
|
levis
|
||||||
|
lipton
|
||||||
|
longines
|
||||||
|
lotus
|
||||||
|
lynx
|
||||||
|
maggi
|
||||||
|
magnumice
|
||||||
|
manolo
|
||||||
|
maybelline
|
||||||
|
mazdaauto
|
||||||
|
mcdonalds
|
||||||
|
milka
|
||||||
|
minicooper
|
||||||
|
montblanc
|
||||||
|
moet
|
||||||
|
moncler
|
||||||
|
mopar
|
||||||
|
nestea
|
||||||
|
nespresso
|
||||||
|
newbalance
|
||||||
|
oreo
|
||||||
|
panasonic
|
||||||
|
peugeot
|
||||||
|
philipsone
|
||||||
|
polaroid
|
||||||
|
rayban
|
||||||
|
redbull
|
||||||
|
reebokclassic
|
||||||
|
ribena
|
||||||
|
rimac
|
||||||
|
saab
|
||||||
|
sainsbury
|
||||||
|
shell
|
||||||
|
smartcar
|
||||||
|
smirnoff
|
||||||
|
sprite
|
||||||
|
stanley
|
||||||
|
subway
|
||||||
|
suzuki
|
||||||
|
tacobell
|
||||||
|
target
|
||||||
|
timberland
|
||||||
|
tomford
|
||||||
|
tommyhilfiger
|
||||||
|
toyotacar
|
||||||
|
triumph
|
||||||
|
umbro
|
||||||
|
unilever
|
||||||
|
versace
|
||||||
|
vichy
|
||||||
|
vogue
|
||||||
|
waterman
|
||||||
|
wrangler
|
||||||
|
yoplait
|
||||||
@ -0,0 +1,364 @@
|
|||||||
|
# Trademark-sensitive words: global tech and web brands
|
||||||
|
# Брендовый словарь: глобальные тех- и веб-бренды
|
||||||
|
adobe
|
||||||
|
airbnb
|
||||||
|
alibaba
|
||||||
|
amazon
|
||||||
|
amd
|
||||||
|
android
|
||||||
|
apple
|
||||||
|
asus
|
||||||
|
baidu
|
||||||
|
binance
|
||||||
|
discord
|
||||||
|
dropbox
|
||||||
|
ebay
|
||||||
|
facebook
|
||||||
|
github
|
||||||
|
gitlab
|
||||||
|
gmail
|
||||||
|
google
|
||||||
|
instagram
|
||||||
|
intel
|
||||||
|
linkedin
|
||||||
|
meta
|
||||||
|
microsoft
|
||||||
|
netflix
|
||||||
|
nintendo
|
||||||
|
nvidia
|
||||||
|
openai
|
||||||
|
oracle
|
||||||
|
paypal
|
||||||
|
pinterest
|
||||||
|
reddit
|
||||||
|
samsung
|
||||||
|
shopify
|
||||||
|
skype
|
||||||
|
slack
|
||||||
|
snapchat
|
||||||
|
spotify
|
||||||
|
stripe
|
||||||
|
tencent
|
||||||
|
tesla
|
||||||
|
tiktok
|
||||||
|
twitch
|
||||||
|
uber
|
||||||
|
visa
|
||||||
|
whatsapp
|
||||||
|
xiaomi
|
||||||
|
yahoo
|
||||||
|
youtube
|
||||||
|
zoom
|
||||||
|
activision
|
||||||
|
adobexd
|
||||||
|
airtable
|
||||||
|
akamai
|
||||||
|
algolia
|
||||||
|
amdadeon
|
||||||
|
ampex
|
||||||
|
anthropic
|
||||||
|
arm
|
||||||
|
asana
|
||||||
|
atlassian
|
||||||
|
autodesk
|
||||||
|
bitbucket
|
||||||
|
bitfinex
|
||||||
|
bitrix
|
||||||
|
broadcom
|
||||||
|
canva
|
||||||
|
cloudflare
|
||||||
|
coinbase
|
||||||
|
coursera
|
||||||
|
databricks
|
||||||
|
digitalocean
|
||||||
|
docker
|
||||||
|
eset
|
||||||
|
evernote
|
||||||
|
figma
|
||||||
|
gopro
|
||||||
|
grafana
|
||||||
|
heroku
|
||||||
|
huggingface
|
||||||
|
jetbrains
|
||||||
|
kaspersky
|
||||||
|
lenovo
|
||||||
|
mailchimp
|
||||||
|
mastodon
|
||||||
|
medium
|
||||||
|
miro
|
||||||
|
mozilla
|
||||||
|
notion
|
||||||
|
okta
|
||||||
|
opensea
|
||||||
|
postman
|
||||||
|
quora
|
||||||
|
raspberrypi
|
||||||
|
salesforce
|
||||||
|
sap
|
||||||
|
shazam
|
||||||
|
skypex
|
||||||
|
snowflake
|
||||||
|
soundcloud
|
||||||
|
stackoverflow
|
||||||
|
teamviewer
|
||||||
|
telegram
|
||||||
|
trello
|
||||||
|
unity
|
||||||
|
vercel
|
||||||
|
vmware
|
||||||
|
weibo
|
||||||
|
wordpress
|
||||||
|
7eleven
|
||||||
|
adguard
|
||||||
|
airasia
|
||||||
|
airpods
|
||||||
|
airtag
|
||||||
|
aliexpress
|
||||||
|
allianz
|
||||||
|
amdryzen
|
||||||
|
angrybirds
|
||||||
|
anker
|
||||||
|
aol
|
||||||
|
appstore
|
||||||
|
audible
|
||||||
|
aws
|
||||||
|
azure
|
||||||
|
bard
|
||||||
|
beeline
|
||||||
|
behance
|
||||||
|
bing
|
||||||
|
blizzardent
|
||||||
|
booking
|
||||||
|
bookingcom
|
||||||
|
bose
|
||||||
|
bravebrowser
|
||||||
|
bybit
|
||||||
|
capcut
|
||||||
|
carplay
|
||||||
|
chatgpt
|
||||||
|
chromebook
|
||||||
|
chromeos
|
||||||
|
claude
|
||||||
|
copilot
|
||||||
|
corsair
|
||||||
|
crunchyroll
|
||||||
|
dailymotion
|
||||||
|
deepl
|
||||||
|
deezer
|
||||||
|
deliveryhero
|
||||||
|
disney
|
||||||
|
disneyplus
|
||||||
|
dribbble
|
||||||
|
duolingo
|
||||||
|
epicgames
|
||||||
|
etsy
|
||||||
|
firefox
|
||||||
|
flickr
|
||||||
|
fortnite
|
||||||
|
garmin
|
||||||
|
gettyimages
|
||||||
|
giphy
|
||||||
|
glassdoor
|
||||||
|
godaddy
|
||||||
|
googledocs
|
||||||
|
googlemaps
|
||||||
|
googlesheets
|
||||||
|
googleslides
|
||||||
|
hbo
|
||||||
|
hbomax
|
||||||
|
hotstar
|
||||||
|
hubspot
|
||||||
|
icloud
|
||||||
|
imdb
|
||||||
|
imgur
|
||||||
|
indeed
|
||||||
|
ios
|
||||||
|
ipad
|
||||||
|
iphone
|
||||||
|
itunes
|
||||||
|
jbl
|
||||||
|
jira
|
||||||
|
kindle
|
||||||
|
kik
|
||||||
|
line
|
||||||
|
linux
|
||||||
|
loom
|
||||||
|
luminar
|
||||||
|
mariadb
|
||||||
|
messenger
|
||||||
|
midjourney
|
||||||
|
motorola
|
||||||
|
msn
|
||||||
|
mysql
|
||||||
|
nextcloud
|
||||||
|
office365
|
||||||
|
onedrive
|
||||||
|
openvpn
|
||||||
|
outlook
|
||||||
|
paramount
|
||||||
|
patreon
|
||||||
|
pixar
|
||||||
|
playstation
|
||||||
|
plex
|
||||||
|
primevideo
|
||||||
|
protonmail
|
||||||
|
quicksilver
|
||||||
|
rakuten
|
||||||
|
roku
|
||||||
|
signalapp
|
||||||
|
sketch
|
||||||
|
skyscanner
|
||||||
|
snapseed
|
||||||
|
soundcloudgo
|
||||||
|
sourceforge
|
||||||
|
speedtest
|
||||||
|
squarespace
|
||||||
|
steam
|
||||||
|
swiftui
|
||||||
|
taobao
|
||||||
|
teams
|
||||||
|
teslamotors
|
||||||
|
thunderbird
|
||||||
|
tripadvisor
|
||||||
|
ubisoft
|
||||||
|
verizon
|
||||||
|
viber
|
||||||
|
wechat
|
||||||
|
wetransfer
|
||||||
|
wikimedia
|
||||||
|
wise
|
||||||
|
wix
|
||||||
|
xbox
|
||||||
|
xing
|
||||||
|
yandex
|
||||||
|
zendesk
|
||||||
|
zhihu
|
||||||
|
zillow
|
||||||
|
zomato
|
||||||
|
abb
|
||||||
|
accenture
|
||||||
|
acer
|
||||||
|
adp
|
||||||
|
airbus
|
||||||
|
alcatel
|
||||||
|
alibabaai
|
||||||
|
amdinstinct
|
||||||
|
analogdevices
|
||||||
|
applepay
|
||||||
|
armholdings
|
||||||
|
asml
|
||||||
|
atandt
|
||||||
|
baidumap
|
||||||
|
baiducloud
|
||||||
|
blackmagic
|
||||||
|
blackrock
|
||||||
|
bloomberg
|
||||||
|
boeing
|
||||||
|
broadcomnet
|
||||||
|
capgemini
|
||||||
|
ciena
|
||||||
|
ciscoios
|
||||||
|
citrix
|
||||||
|
crowdstrike
|
||||||
|
datadog
|
||||||
|
dell
|
||||||
|
deloitte
|
||||||
|
deutschetelekom
|
||||||
|
dropboxpaper
|
||||||
|
dxc
|
||||||
|
elastic
|
||||||
|
equinix
|
||||||
|
ericsson
|
||||||
|
esri
|
||||||
|
f5networks
|
||||||
|
foxconn
|
||||||
|
fujitsu
|
||||||
|
garminconnect
|
||||||
|
gehealthcare
|
||||||
|
genpact
|
||||||
|
godot
|
||||||
|
goldmansachs
|
||||||
|
grubhub
|
||||||
|
hcl
|
||||||
|
hikvision
|
||||||
|
honeywell
|
||||||
|
hpe
|
||||||
|
infosys
|
||||||
|
ingrammicro
|
||||||
|
intuit
|
||||||
|
juniper
|
||||||
|
kioxia
|
||||||
|
kla
|
||||||
|
lazada
|
||||||
|
logitech
|
||||||
|
lucid
|
||||||
|
marvell
|
||||||
|
medtronic
|
||||||
|
mercadolibre
|
||||||
|
micron
|
||||||
|
mulesoft
|
||||||
|
naspers
|
||||||
|
nec
|
||||||
|
newrelic
|
||||||
|
nokiax
|
||||||
|
norton
|
||||||
|
ntt
|
||||||
|
nutanix
|
||||||
|
nxp
|
||||||
|
okx
|
||||||
|
onsemi
|
||||||
|
paloalto
|
||||||
|
palantir
|
||||||
|
pandora
|
||||||
|
payoneer
|
||||||
|
paypalx
|
||||||
|
paypalme
|
||||||
|
perplexity
|
||||||
|
philipshealth
|
||||||
|
pipedrive
|
||||||
|
procore
|
||||||
|
qualcomm
|
||||||
|
quantum
|
||||||
|
revolut
|
||||||
|
riotgames
|
||||||
|
roblox
|
||||||
|
robinhood
|
||||||
|
rubrik
|
||||||
|
salesloft
|
||||||
|
saphana
|
||||||
|
servicenow
|
||||||
|
seagate
|
||||||
|
semrush
|
||||||
|
sharp
|
||||||
|
siemensnx
|
||||||
|
splunk
|
||||||
|
square
|
||||||
|
sumup
|
||||||
|
tableau
|
||||||
|
talkdesk
|
||||||
|
tata
|
||||||
|
temu
|
||||||
|
teradata
|
||||||
|
texasinstruments
|
||||||
|
thomsonreuters
|
||||||
|
tiktokshop
|
||||||
|
tinder
|
||||||
|
tiscali
|
||||||
|
toast
|
||||||
|
tokopedia
|
||||||
|
toptal
|
||||||
|
toshiba
|
||||||
|
tradingview
|
||||||
|
trip
|
||||||
|
twilio
|
||||||
|
unity3d
|
||||||
|
ups
|
||||||
|
veeam
|
||||||
|
velodyne
|
||||||
|
vistaprint
|
||||||
|
vodafone
|
||||||
|
webex
|
||||||
|
wipro
|
||||||
|
workday
|
||||||
|
xerox
|
||||||
|
zoho
|
||||||
@ -0,0 +1,502 @@
|
|||||||
|
# Trademark dictionary: top-500 global companies by market cap (source-based import)
|
||||||
|
# Брендовый словарь: топ-500 глобальных компаний по рыночной капитализации (импорт из источника)
|
||||||
|
nvidia
|
||||||
|
alphabetgoogle
|
||||||
|
apple
|
||||||
|
microsoft
|
||||||
|
amazon
|
||||||
|
tsmc
|
||||||
|
broadcom
|
||||||
|
saudiaramco
|
||||||
|
tesla
|
||||||
|
metaplatforms
|
||||||
|
samsung
|
||||||
|
walmart
|
||||||
|
berkshirehathaway
|
||||||
|
skhynix
|
||||||
|
elililly
|
||||||
|
microntechnology
|
||||||
|
jpmorganchase
|
||||||
|
amd
|
||||||
|
exxonmobil
|
||||||
|
visa
|
||||||
|
intel
|
||||||
|
asml
|
||||||
|
johnsonandjohnson
|
||||||
|
oracle
|
||||||
|
tencent
|
||||||
|
costco
|
||||||
|
cisco
|
||||||
|
mastercard
|
||||||
|
caterpillar
|
||||||
|
chinaconstructionbank
|
||||||
|
chevron
|
||||||
|
abbvie
|
||||||
|
netflix
|
||||||
|
lamresearch
|
||||||
|
bankofamerica
|
||||||
|
cocacola
|
||||||
|
unitedhealth
|
||||||
|
appliedmaterials
|
||||||
|
roche
|
||||||
|
agriculturalbankofchina
|
||||||
|
procterandgamble
|
||||||
|
palantir
|
||||||
|
alibaba
|
||||||
|
hsbc
|
||||||
|
generalelectric
|
||||||
|
morganstanley
|
||||||
|
icbc
|
||||||
|
homedepot
|
||||||
|
philipmorris
|
||||||
|
astrazeneca
|
||||||
|
goldmansachs
|
||||||
|
novartis
|
||||||
|
catl
|
||||||
|
merck
|
||||||
|
texasinstruments
|
||||||
|
gevernova
|
||||||
|
bankofchina
|
||||||
|
armholdings
|
||||||
|
lvmh
|
||||||
|
royalbankofcanada
|
||||||
|
nestle
|
||||||
|
petrochina
|
||||||
|
toyota
|
||||||
|
kweichowmoutai
|
||||||
|
shell
|
||||||
|
kla
|
||||||
|
chinamobile
|
||||||
|
raytheontechnologies
|
||||||
|
linde
|
||||||
|
siemens
|
||||||
|
internationalholdingcompany
|
||||||
|
wellsfargo
|
||||||
|
loreal
|
||||||
|
mitsubishiufjfinancial
|
||||||
|
softbank
|
||||||
|
qualcomm
|
||||||
|
citigroup
|
||||||
|
bhpgroup
|
||||||
|
sap
|
||||||
|
ibm
|
||||||
|
americanexpress
|
||||||
|
sandisk
|
||||||
|
tmobileus
|
||||||
|
totalenergies
|
||||||
|
pepsico
|
||||||
|
prosus
|
||||||
|
paloaltonetworks
|
||||||
|
novonordisk
|
||||||
|
verizon
|
||||||
|
mcdonald
|
||||||
|
commonwealthbank
|
||||||
|
hermesinternational
|
||||||
|
analogdevices
|
||||||
|
foxconnindustrialinternet
|
||||||
|
kioxiaholdingscorporation
|
||||||
|
relianceindustries
|
||||||
|
abb
|
||||||
|
nexteraenergy
|
||||||
|
torontodominionbank
|
||||||
|
inditex
|
||||||
|
waltdisney
|
||||||
|
mediatek
|
||||||
|
amgen
|
||||||
|
santander
|
||||||
|
aristanetworks
|
||||||
|
tjxcompanies
|
||||||
|
boeing
|
||||||
|
att
|
||||||
|
schneiderelectric
|
||||||
|
siemensenergy
|
||||||
|
allianz
|
||||||
|
seagate
|
||||||
|
riotinto
|
||||||
|
thermofisherscientific
|
||||||
|
deltaelectronics
|
||||||
|
cnooc
|
||||||
|
crowdstrike
|
||||||
|
marvell
|
||||||
|
blackrock
|
||||||
|
deutschetelekom
|
||||||
|
zhongjiinnolight
|
||||||
|
gileadsciences
|
||||||
|
applovin
|
||||||
|
anheuserbuschinbev
|
||||||
|
intuitivesurgical
|
||||||
|
westerndigital
|
||||||
|
unionpacificcorporation
|
||||||
|
dell
|
||||||
|
charlesschwab
|
||||||
|
corning
|
||||||
|
ubs
|
||||||
|
welltower
|
||||||
|
airbus
|
||||||
|
abbottlaboratories
|
||||||
|
iberdrola
|
||||||
|
uber
|
||||||
|
deerecompany
|
||||||
|
amphenol
|
||||||
|
conocophillips
|
||||||
|
cmbank
|
||||||
|
eaton
|
||||||
|
salesforce
|
||||||
|
pfizer
|
||||||
|
sumitomomitsuifinancialgroup
|
||||||
|
southerncopper
|
||||||
|
hitachi
|
||||||
|
chinashenhuaenergy
|
||||||
|
pinganinsurance
|
||||||
|
blackstonegroup
|
||||||
|
interactivebrokers
|
||||||
|
chinalifeinsurance
|
||||||
|
fastretailing
|
||||||
|
britishamericantobacco
|
||||||
|
tokyoelectron
|
||||||
|
pinduoduo
|
||||||
|
honeywell
|
||||||
|
dbs
|
||||||
|
shopify
|
||||||
|
safran
|
||||||
|
rollsroyceholdings
|
||||||
|
prologis
|
||||||
|
sony
|
||||||
|
petrobras
|
||||||
|
chubb
|
||||||
|
bbva
|
||||||
|
hdfcbank
|
||||||
|
byd
|
||||||
|
unilever
|
||||||
|
deltaelectronicsthailand
|
||||||
|
enbridge
|
||||||
|
mitsubishicorporation
|
||||||
|
lowescompanies
|
||||||
|
spglobal
|
||||||
|
unicredit
|
||||||
|
strykercorporation
|
||||||
|
investorab
|
||||||
|
altriagroup
|
||||||
|
advantest
|
||||||
|
bookingholdings
|
||||||
|
starbucks
|
||||||
|
vertivholdings
|
||||||
|
danaher
|
||||||
|
lockheedmartin
|
||||||
|
airliquide
|
||||||
|
bristolmyerssquibb
|
||||||
|
bhartiairtel
|
||||||
|
cambricontechnologies
|
||||||
|
cvshealth
|
||||||
|
progressive
|
||||||
|
keyence
|
||||||
|
zijinmining
|
||||||
|
capitalone
|
||||||
|
compagniefinanciererichemont
|
||||||
|
bp
|
||||||
|
hyundai
|
||||||
|
newmont
|
||||||
|
bnpparibas
|
||||||
|
intesasanpaolo
|
||||||
|
aia
|
||||||
|
enel
|
||||||
|
bankofmontreal
|
||||||
|
mizuhofinancialgroup
|
||||||
|
accenture
|
||||||
|
foxconn
|
||||||
|
vertexpharmaceuticals
|
||||||
|
zurichinsurancegroup
|
||||||
|
parkerhannifin
|
||||||
|
intuit
|
||||||
|
sanofi
|
||||||
|
servicenow
|
||||||
|
quantaservices
|
||||||
|
alrajhibank
|
||||||
|
southerncompany
|
||||||
|
cibc
|
||||||
|
cmegroup
|
||||||
|
equinix
|
||||||
|
howmetaerospace
|
||||||
|
infineon
|
||||||
|
sksquare
|
||||||
|
adobe
|
||||||
|
glaxosmithkline
|
||||||
|
canadiannaturalresources
|
||||||
|
constellationenergy
|
||||||
|
brookfieldcorporation
|
||||||
|
mitsuibussan
|
||||||
|
medtronic
|
||||||
|
xiaomi
|
||||||
|
tranetechnologies
|
||||||
|
sberbank
|
||||||
|
marriottinternational
|
||||||
|
scotiabank
|
||||||
|
equinor
|
||||||
|
cadencedesignsystems
|
||||||
|
dukeenergy
|
||||||
|
chinayangtzepower
|
||||||
|
synopsys
|
||||||
|
axa
|
||||||
|
williamscompanies
|
||||||
|
fortinet
|
||||||
|
bnymellon
|
||||||
|
essilorluxottica
|
||||||
|
tokiomarine
|
||||||
|
cummins
|
||||||
|
dior
|
||||||
|
fedex
|
||||||
|
icicibank
|
||||||
|
generaldynamics
|
||||||
|
grupomexico
|
||||||
|
caixabank
|
||||||
|
statebankofindia
|
||||||
|
midea
|
||||||
|
mckesson
|
||||||
|
sinopec
|
||||||
|
glencore
|
||||||
|
spotify
|
||||||
|
agnicoeaglemines
|
||||||
|
comcast
|
||||||
|
westpacbanking
|
||||||
|
automaticdataprocessing
|
||||||
|
wastemanagement
|
||||||
|
hcahealthcare
|
||||||
|
kohlbergkravisroberts
|
||||||
|
tataconsultancyservices
|
||||||
|
itauunibanco
|
||||||
|
freeportmcmoran
|
||||||
|
pncfinancialservices
|
||||||
|
ing
|
||||||
|
postalsavingsbankofchina
|
||||||
|
bankofcommunications
|
||||||
|
elevancehealth
|
||||||
|
intercontinentalexchange
|
||||||
|
americantower
|
||||||
|
itochushoji
|
||||||
|
schlumberger
|
||||||
|
csxcorporation
|
||||||
|
enterpriseproducts
|
||||||
|
monsterbeverage
|
||||||
|
usbancorp
|
||||||
|
nationalgrid
|
||||||
|
bostonscientific
|
||||||
|
johnsoncontrols
|
||||||
|
ups
|
||||||
|
recruit
|
||||||
|
mercadolibre
|
||||||
|
mitsubishiheavyindustries
|
||||||
|
eoptolinktechnology
|
||||||
|
nationalaustraliabank
|
||||||
|
ocbcbank
|
||||||
|
atlascopco
|
||||||
|
chugaipharmaceutical
|
||||||
|
barclays
|
||||||
|
airbnb
|
||||||
|
bloomenergy
|
||||||
|
americamovil
|
||||||
|
suncorenergy
|
||||||
|
shinetsuchemical
|
||||||
|
engie
|
||||||
|
eni
|
||||||
|
mondelez
|
||||||
|
lloydsbankinggroup
|
||||||
|
vinci
|
||||||
|
nippontelegraphandtelephone
|
||||||
|
marshandmclennancompanies
|
||||||
|
ciena
|
||||||
|
northropgrumman
|
||||||
|
nxpsemiconductors
|
||||||
|
mitsubishielectric
|
||||||
|
3m
|
||||||
|
rocketlabusa
|
||||||
|
moodys
|
||||||
|
simonproperty
|
||||||
|
murataseisakusho
|
||||||
|
brookfieldassetmanagement
|
||||||
|
canadianpacificrailway
|
||||||
|
baesystems
|
||||||
|
oreillyautomotive
|
||||||
|
monolithicpowersystems
|
||||||
|
apolloglobalmanagement
|
||||||
|
nokia
|
||||||
|
anzbank
|
||||||
|
smic
|
||||||
|
sherwinwilliams
|
||||||
|
datadog
|
||||||
|
eogresources
|
||||||
|
marathonpetroleum
|
||||||
|
valero
|
||||||
|
cigna
|
||||||
|
netease
|
||||||
|
kindermorgan
|
||||||
|
emerson
|
||||||
|
cloudflare
|
||||||
|
hiltonhotels
|
||||||
|
tcenergy
|
||||||
|
luxshareprecision
|
||||||
|
colgatepalmolive
|
||||||
|
illinoistoolworks
|
||||||
|
phillips66
|
||||||
|
japanpostbank
|
||||||
|
carvana
|
||||||
|
munichre
|
||||||
|
nauratechnologygroup
|
||||||
|
rossstores
|
||||||
|
coherent
|
||||||
|
americanelectricpower
|
||||||
|
norfolksouthern
|
||||||
|
doordash
|
||||||
|
ecolab
|
||||||
|
vale
|
||||||
|
canadiannationalrailway
|
||||||
|
japantobacco
|
||||||
|
asegroup
|
||||||
|
energytransferpartners
|
||||||
|
adnocgas
|
||||||
|
barrickgold
|
||||||
|
warnerbrosdiscovery
|
||||||
|
generalmotors
|
||||||
|
aon
|
||||||
|
taqa
|
||||||
|
cintas
|
||||||
|
robinhood
|
||||||
|
digitalrealty
|
||||||
|
volvo
|
||||||
|
regeneronpharmaceuticals
|
||||||
|
royalcaribbean
|
||||||
|
crh
|
||||||
|
lumentum
|
||||||
|
softbankcorp
|
||||||
|
transdigm
|
||||||
|
generali
|
||||||
|
imperialoil
|
||||||
|
bakerhughes
|
||||||
|
hongkongexchangesandclearing
|
||||||
|
motorolasolutions
|
||||||
|
rheinmetall
|
||||||
|
nike
|
||||||
|
macquarie
|
||||||
|
republicservices
|
||||||
|
thetravelerscompanies
|
||||||
|
kddi
|
||||||
|
meituandianping
|
||||||
|
comfortsystems
|
||||||
|
manulifefinancial
|
||||||
|
citicbank
|
||||||
|
airproductsandchemicals
|
||||||
|
maaden
|
||||||
|
chinatelecom
|
||||||
|
merckkgaa
|
||||||
|
cerebrassystems
|
||||||
|
nordeabank
|
||||||
|
bochongkong
|
||||||
|
deutschebank
|
||||||
|
natwestgroup
|
||||||
|
lgenergysolution
|
||||||
|
thesaudinationalbank
|
||||||
|
vingroupcompany
|
||||||
|
deutschepost
|
||||||
|
nuholdings
|
||||||
|
londonstockexchange
|
||||||
|
nongfuspring
|
||||||
|
singtel
|
||||||
|
wesfarmers
|
||||||
|
ferrari
|
||||||
|
creditagricole
|
||||||
|
rosneft
|
||||||
|
truistfinancial
|
||||||
|
sempraenergy
|
||||||
|
aflac
|
||||||
|
dominionenergy
|
||||||
|
btgpactual
|
||||||
|
nbcbank
|
||||||
|
relx
|
||||||
|
teconnectivity
|
||||||
|
paccar
|
||||||
|
wwgrainger
|
||||||
|
bajajfinance
|
||||||
|
unitedrentals
|
||||||
|
sauditelecomcompany
|
||||||
|
occidentalpetroleum
|
||||||
|
keysight
|
||||||
|
microstrategy
|
||||||
|
targaresources
|
||||||
|
oneok
|
||||||
|
realtyincome
|
||||||
|
snowflake
|
||||||
|
wheatonpreciousmetals
|
||||||
|
stmicroelectronics
|
||||||
|
l3harristechnologies
|
||||||
|
societegenerale
|
||||||
|
diamondbackenergy
|
||||||
|
citicsecurities
|
||||||
|
allstate
|
||||||
|
cenovusenergy
|
||||||
|
orange
|
||||||
|
standardchartered
|
||||||
|
dsv
|
||||||
|
autozone
|
||||||
|
mplx
|
||||||
|
larsenandtoubro
|
||||||
|
hoya
|
||||||
|
kbc
|
||||||
|
devonenergy
|
||||||
|
eon
|
||||||
|
target
|
||||||
|
coreweave
|
||||||
|
walmex
|
||||||
|
mercedesbenzgroup
|
||||||
|
marubeni
|
||||||
|
thales
|
||||||
|
deutscheboerse
|
||||||
|
angloamerican
|
||||||
|
industrialbank
|
||||||
|
teradyne
|
||||||
|
sumitomo
|
||||||
|
basf
|
||||||
|
nintendo
|
||||||
|
compassgroup
|
||||||
|
hindustanunilever
|
||||||
|
publicstorage
|
||||||
|
alimentationcouchetard
|
||||||
|
metlife
|
||||||
|
sea
|
||||||
|
ebay
|
||||||
|
ucb
|
||||||
|
carrier
|
||||||
|
takedapharmaceutical
|
||||||
|
corteva
|
||||||
|
ford
|
||||||
|
sumitomodenkikogyo
|
||||||
|
lifeinsurancecorporationofindia
|
||||||
|
bmw
|
||||||
|
arthurgallagher
|
||||||
|
loblawcompanies
|
||||||
|
infosys
|
||||||
|
cencora
|
||||||
|
ametek
|
||||||
|
autodesk
|
||||||
|
nucor
|
||||||
|
greatwestlifeco
|
||||||
|
volkswagen
|
||||||
|
entergy
|
||||||
|
cheniereenergy
|
||||||
|
lafargeholcim
|
||||||
|
nasdaq
|
||||||
|
microchiptechnology
|
||||||
|
sunhungkaiproperties
|
||||||
|
antofagasta
|
||||||
|
firstabudhabibank
|
||||||
|
electronicarts
|
||||||
|
jardinematheson
|
||||||
|
sungrowpowersupply
|
||||||
|
coinbase
|
||||||
|
ambev
|
||||||
|
argenx
|
||||||
|
lukoil
|
||||||
|
panasonic
|
||||||
|
jiangsuhengruimedicine
|
||||||
|
fastenal
|
||||||
|
sandvik
|
||||||
|
wuxiapptec
|
||||||
|
xcelenergy
|
||||||
105
shine-solana/shine/programs/shine_login_guard/src/lib.rs
Normal file
105
shine-solana/shine/programs/shine_login_guard/src/lib.rs
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_lang::solana_program::program::set_return_data;
|
||||||
|
|
||||||
|
declare_id!("3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo");
|
||||||
|
|
||||||
|
mod wordlist {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/generated_dictionary.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASS_FREE: u32 = 0;
|
||||||
|
const CLASS_PREMIUM: u32 = 1;
|
||||||
|
const CLASS_TRADEMARK: u32 = 2;
|
||||||
|
const MAX_WORDS_PER_LOGIN: usize = 3;
|
||||||
|
|
||||||
|
#[program]
|
||||||
|
pub mod shine_login_guard {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn classify_login(_ctx: Context<ClassifyLogin>, login: String) -> Result<()> {
|
||||||
|
let class = classify(&login);
|
||||||
|
set_return_data(&class.to_le_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct ClassifyLogin<'info> {
|
||||||
|
pub signer: Signer<'info>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify(login: &str) -> u32 {
|
||||||
|
let Some(normalized) = normalize_login(login) else {
|
||||||
|
return CLASS_PREMIUM;
|
||||||
|
};
|
||||||
|
if normalized.len() <= 7 {
|
||||||
|
return CLASS_PREMIUM;
|
||||||
|
}
|
||||||
|
match classify_split(&normalized) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => CLASS_FREE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_login(login: &str) -> Option<String> {
|
||||||
|
if login.is_empty() || login.len() > 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = String::with_capacity(login.len());
|
||||||
|
for ch in login.chars() {
|
||||||
|
if ch == '_' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !ch.is_ascii_alphanumeric() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
out.push(ch.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
if out.is_empty() || out.len() > 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_split(login: &str) -> Option<u32> {
|
||||||
|
fn dfs(rest: &str, depth: usize, has_tm: bool) -> Option<u32> {
|
||||||
|
if rest.is_empty() {
|
||||||
|
if depth > 0 && depth <= MAX_WORDS_PER_LOGIN {
|
||||||
|
return Some(if has_tm { CLASS_TRADEMARK } else { CLASS_PREMIUM });
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if depth >= MAX_WORDS_PER_LOGIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let max_piece = rest.len().min(20);
|
||||||
|
let mut premium_found = false;
|
||||||
|
for i in 1..=max_piece {
|
||||||
|
let candidate = &rest[..i];
|
||||||
|
let is_tm = is_trademark_word(candidate);
|
||||||
|
let is_pr = is_tm || is_premium_word(candidate);
|
||||||
|
if !is_pr {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match dfs(&rest[i..], depth + 1, has_tm || is_tm) {
|
||||||
|
Some(CLASS_TRADEMARK) => return Some(CLASS_TRADEMARK),
|
||||||
|
Some(CLASS_PREMIUM) => premium_found = true,
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if premium_found {
|
||||||
|
Some(CLASS_PREMIUM)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dfs(login, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_premium_word(word: &str) -> bool {
|
||||||
|
wordlist::PREMIUM_WORDS.binary_search(&word).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_trademark_word(word: &str) -> bool {
|
||||||
|
wordlist::TRADEMARK_WORDS.binary_search(&word).is_ok()
|
||||||
|
}
|
||||||
28
shine-solana/shine/programs/shine_payments/Cargo.toml
Normal file
28
shine-solana/shine/programs/shine_payments/Cargo.toml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "shine_payments"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Shine Payments v2 (очереди выплат)"
|
||||||
|
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" }
|
||||||
|
pyth-solana-receiver-sdk = { path = "../../.vendor/pyth-crosschain/target_chains/solana/pyth_solana_receiver_sdk" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
no-entrypoint = []
|
||||||
|
no-idl = []
|
||||||
|
no-log-ix-name = []
|
||||||
|
anchor-debug = []
|
||||||
|
custom-heap = []
|
||||||
|
custom-panic = []
|
||||||
|
cpi = []
|
||||||
|
idl-build = ["anchor-lang/idl-build"]
|
||||||
1147
shine-solana/shine/programs/shine_payments/src/lib.rs
Normal file
1147
shine-solana/shine/programs/shine_payments/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
54
shine-solana/shine/programs/shine_payments/src/settings.rs
Normal file
54
shine-solana/shine/programs/shine_payments/src/settings.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
use common::deploy_config;
|
||||||
|
|
||||||
|
/// `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;
|
||||||
|
/// `COEF_LIMIT_SPACE` — размер (в байтах) PDA `CoefLimitState`.
|
||||||
|
pub const COEF_LIMIT_SPACE: usize = 8 + 96;
|
||||||
|
/// `QUEUES_SPACE` — размер (в байтах) PDA `QueuesState`.
|
||||||
|
pub const QUEUES_SPACE: usize = 8 + 192;
|
||||||
|
/// `INFLOW_VAULT_SPACE` — размер (в байтах) PDA `VaultState`.
|
||||||
|
pub const INFLOW_VAULT_SPACE: usize = 8 + 32;
|
||||||
|
/// `TICKET_SPACE` — размер (в байтах) PDA `TicketState`.
|
||||||
|
pub const TICKET_SPACE: usize = 8 + 160;
|
||||||
|
/// `MANAGER_ALLOWANCE_SPACE` — размер (в байтах) PDA `ManagerAllowanceState`.
|
||||||
|
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;
|
||||||
|
/// `START_COEF_PPM` — стартовый коэффициент выплаты при инициализации (`5_000_000` = 5.0x).
|
||||||
|
pub const START_COEF_PPM: u64 = 5_000_000;
|
||||||
|
/// `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;
|
||||||
|
|
||||||
|
/// `ORACLE_MAX_AGE_SECS` — максимальный возраст oracle-цены (в секундах), допустимый для расчетов.
|
||||||
|
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;
|
||||||
561
shine-solana/shine/programs/shine_payments/web/admin_tools.html
Normal file
561
shine-solana/shine/programs/shine_payments/web/admin_tools.html
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Тех. инструменты — 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); }
|
||||||
|
.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; }
|
||||||
|
input { padding: 9px 10px; min-width: 170px; 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; }
|
||||||
|
.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%; }
|
||||||
|
th, td { border: 1px solid var(--line); padding: 6px; text-align: left; font-size: 14px; vertical-align: top; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<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="row">
|
||||||
|
<button id="connectBtn">Подключить кошелек</button>
|
||||||
|
<button id="refreshBtn">Обновить всё</button>
|
||||||
|
<button id="initBtn">Init (один раз)</button>
|
||||||
|
</div>
|
||||||
|
<div id="walletInfo" class="muted"></div>
|
||||||
|
<div id="initResult" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Коэффициент, лимит и награда шага выплат</h3>
|
||||||
|
<div class="muted">Право изменения: <code id="daoAllowed">загрузка...</code></div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Коэффициент (например 5): <input id="coefInput" value="5" /></label>
|
||||||
|
<label>Лимит (USD): <input id="limitInput" value="10000" /></label>
|
||||||
|
<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 class="panel">
|
||||||
|
<h3>Shine Users: экономические параметры</h3>
|
||||||
|
<div class="muted">Право изменения: <code id="usersDaoAllowed">загрузка...</code></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">
|
||||||
|
<h3>Адреса и агрегаты</h3>
|
||||||
|
<div id="balances" class="muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Очередь 1 (все билеты)</h3>
|
||||||
|
<div class="row"><button id="loadQ1Btn">Показать очередь 1</button></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>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||||
|
const USERS_PROGRAM_ID = new solanaWeb3.PublicKey("FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm");
|
||||||
|
const RPC_URL = "https://api.devnet.solana.com";
|
||||||
|
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||||
|
const SEEDS = {
|
||||||
|
config: "shine_payments_v3_config",
|
||||||
|
coef: "shine_payments_v3_coef_limit",
|
||||||
|
queues: "shine_payments_v3_queues",
|
||||||
|
inflow: "shine_payments_v3_inflow_vault",
|
||||||
|
ticketQ1: "shine_payments_v3_q1_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 cache = null;
|
||||||
|
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||||
|
|
||||||
|
function utf8(s) { return new TextEncoder().encode(s); }
|
||||||
|
function u64ToBytes(v) {
|
||||||
|
let x = BigInt(v);
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function readU64(data, offset) {
|
||||||
|
let x = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function concat(...parts) {
|
||||||
|
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
let o = 0;
|
||||||
|
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function trimZeros(v) {
|
||||||
|
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||||||
|
}
|
||||||
|
function lamportsToSolStr(l) {
|
||||||
|
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||||
|
}
|
||||||
|
function centsToUsdStr(c) {
|
||||||
|
return trimZeros((Number(c) / 100).toFixed(2));
|
||||||
|
}
|
||||||
|
function solToLamports(solStr) {
|
||||||
|
const v = Number(solStr.replace(",", "."));
|
||||||
|
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
|
||||||
|
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) {
|
||||||
|
const msg = utf8("global:" + name);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||||
|
return new Uint8Array(hash).slice(0, 8);
|
||||||
|
}
|
||||||
|
function isUnauthorizedDao(msg) {
|
||||||
|
const s = String(msg || "").toLowerCase();
|
||||||
|
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) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const dao = 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;
|
||||||
|
return { version, coefPpm, limitUsdCents, reward };
|
||||||
|
}
|
||||||
|
function parseQueues(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const q1Total = readU64(data, o); o += 8;
|
||||||
|
const q1Paid = readU64(data, o); o += 8;
|
||||||
|
const q1SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q1SumPaid = readU64(data, o); o += 8;
|
||||||
|
const q2Total = readU64(data, o); o += 8;
|
||||||
|
const q2Paid = readU64(data, o); o += 8;
|
||||||
|
const q2SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q2SumPaid = readU64(data, o); o += 8;
|
||||||
|
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||||||
|
}
|
||||||
|
function parseTicket(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const queueId = data[o++];
|
||||||
|
const index = readU64(data, o); o += 8;
|
||||||
|
const isPaid = data[o++] === 1;
|
||||||
|
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||||
|
const payout = readU64(data, o); o += 8;
|
||||||
|
const debtBefore = readU64(data, o); o += 8;
|
||||||
|
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() {
|
||||||
|
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||||
|
return window.solana;
|
||||||
|
}
|
||||||
|
async function connectWallet() {
|
||||||
|
const provider = getProvider();
|
||||||
|
const r = await provider.connect();
|
||||||
|
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
|
||||||
|
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||||||
|
await refreshAll();
|
||||||
|
}
|
||||||
|
async function sendInstruction(ix) {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
const tx = new solanaWeb3.Transaction().add(ix);
|
||||||
|
tx.feePayer = walletPubkey;
|
||||||
|
const bh = await connection.getLatestBlockhash("confirmed");
|
||||||
|
tx.recentBlockhash = bh.blockhash;
|
||||||
|
const signed = await provider.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePdas() {
|
||||||
|
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 [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID);
|
||||||
|
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) {
|
||||||
|
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCore() {
|
||||||
|
const pdas = derivePdas();
|
||||||
|
const [cfgAi, coefAi, qAi, inflowAi] = await Promise.all([
|
||||||
|
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.inflowPda, "confirmed"),
|
||||||
|
]);
|
||||||
|
if (!cfgAi || !coefAi || !qAi || !inflowAi) {
|
||||||
|
cache = { pdas, notInited: true };
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
const config = parseConfig(cfgAi.data);
|
||||||
|
const coef = parseCoef(coefAi.data);
|
||||||
|
const queues = parseQueues(qAi.data);
|
||||||
|
const [daoBal, inflowRent] = await Promise.all([
|
||||||
|
connection.getBalance(config.dao, "confirmed"),
|
||||||
|
connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"),
|
||||||
|
]);
|
||||||
|
cache = {
|
||||||
|
pdas, config, coef, queues,
|
||||||
|
inflowLamports: BigInt(inflowAi.lamports),
|
||||||
|
inflowAvailable: BigInt(Math.max(0, inflowAi.lamports - inflowRent)),
|
||||||
|
daoBalance: BigInt(daoBal),
|
||||||
|
};
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBalances() {
|
||||||
|
const el = document.getElementById("balances");
|
||||||
|
try {
|
||||||
|
const core = await loadCore();
|
||||||
|
if (core.notInited) {
|
||||||
|
el.innerHTML = `<span class="warn">PDA ещё не инициализированы.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coefText = trimZeros((Number(core.coef.coefPpm) / 1_000_000).toFixed(6));
|
||||||
|
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 = `
|
||||||
|
<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 class="muted">Inflow vault — входящий PDA-кошелек выплат программы.</div>
|
||||||
|
<div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
|
||||||
|
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${centsToUsdStr(core.coef.limitUsdCents)} USD</b></div>
|
||||||
|
<div>Осталось лимита для покупки Q1: <b>${centsToUsdStr(limitRemain)} USD</b></div>
|
||||||
|
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
|
||||||
|
<div>Баланс inflow vault: <b>${lamportsToSolStr(core.inflowLamports)} SOL</b></div>
|
||||||
|
<div>Доступно в inflow (сверх ренты): <b>${lamportsToSolStr(core.inflowAvailable)} SOL</b></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=${centsToUsdStr(core.queues.q2SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q2SumPaid)} USD</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInit() {
|
||||||
|
const out = document.getElementById("initResult");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
else if (!provider.isConnected) await provider.connect();
|
||||||
|
|
||||||
|
const pdas = derivePdas();
|
||||||
|
const disc = await ixDiscriminator("init");
|
||||||
|
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: pdas.inflowPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||||
|
];
|
||||||
|
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data: disc });
|
||||||
|
const sig = await sendInstruction(ix);
|
||||||
|
out.innerHTML = `<span class="ok">Init выполнен. Tx: <code>${sig}</code></span>`;
|
||||||
|
await refreshAll();
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCoefLimit() {
|
||||||
|
const out = document.getElementById("updateResult");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
else if (!provider.isConnected) await provider.connect();
|
||||||
|
const core = await loadCore();
|
||||||
|
if (core.notInited) throw new Error("Сначала выполните init");
|
||||||
|
|
||||||
|
const coef = Number(document.getElementById("coefInput").value.trim());
|
||||||
|
if (!Number.isFinite(coef) || coef <= 0) throw new Error("Некорректный коэффициент");
|
||||||
|
const coefPpm = BigInt(Math.round(coef * 1_000_000));
|
||||||
|
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 data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitUsdCents), u64ToBytes(rewardLamports));
|
||||||
|
const keys = [
|
||||||
|
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: core.pdas.coefPda, isSigner: false, isWritable: true },
|
||||||
|
];
|
||||||
|
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 refreshAll();
|
||||||
|
} catch (e) {
|
||||||
|
const raw = String(e.message || e);
|
||||||
|
if (isUnauthorizedDao(raw)) {
|
||||||
|
const dao = document.getElementById("daoAllowed").textContent;
|
||||||
|
out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только DAO-кошельку: <code>${dao}</code>.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const out = document.getElementById(queueId === 1 ? "queue1Table" : "queue2Table");
|
||||||
|
out.textContent = "Загрузка...";
|
||||||
|
try {
|
||||||
|
const core = await loadCore();
|
||||||
|
if (core.notInited) throw new Error("Сначала выполните init");
|
||||||
|
const total = queueId === 1 ? core.queues.q1Total : core.queues.q2Total;
|
||||||
|
if (total === 0n) {
|
||||||
|
out.innerHTML = `<span class="muted">Очередь ${queueId} пока пустая.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 1n; i <= total; i++) {
|
||||||
|
const pda = ticketPda(queueId, i);
|
||||||
|
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||||
|
if (!ai) {
|
||||||
|
rows.push(`<tr><td>${i.toString()}</td><td>${queueId}</td><td colspan="6" class="err">PDA не найден</td></tr>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const t = parseTicket(ai.data);
|
||||||
|
rows.push(`
|
||||||
|
<tr>
|
||||||
|
<td>${t.index.toString()}</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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
out.innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Очередь</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Получатель</th>
|
||||||
|
<th>Сумма выплаты (USD)</th>
|
||||||
|
<th>Очередь до него (от старта)</th>
|
||||||
|
<th>Очередь до него (актуально)</th>
|
||||||
|
<th>PDA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows.join("")}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await refreshBalances();
|
||||||
|
await refreshUsersEconomy();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
|
||||||
|
document.getElementById("initBtn").addEventListener("click", runInit);
|
||||||
|
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("loadQ2Btn").addEventListener("click", () => showQueue(2));
|
||||||
|
refreshAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
428
shine-solana/shine/programs/shine_payments/web/buy_ticket.html
Normal file
428
shine-solana/shine/programs/shine_payments/web/buy_ticket.html
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Покупка билета — 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); }
|
||||||
|
.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; }
|
||||||
|
input { padding: 9px 10px; min-width: 240px; 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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<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="row">
|
||||||
|
<button id="connectBtn">Подключить кошелек</button>
|
||||||
|
<button id="refreshBtn">Обновить состояние</button>
|
||||||
|
</div>
|
||||||
|
<div id="walletInfo" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Текущее состояние (очередь 1)</h3>
|
||||||
|
<div id="stateInfo" class="muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Покупка билета в 1-й очереди</h3>
|
||||||
|
<div class="muted">Можно купить по USD или по SOL. В очередь и лимиты записываются USD-центы. Выплаты по тикетам считаются в USD, а переводятся в SOL по актуальному курсу Pyth в момент шага выплаты.</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Сумма (USD): <input id="amountUsd" value="20" /></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>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||||
|
const RPC_URL = "https://api.devnet.solana.com";
|
||||||
|
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
|
||||||
|
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||||
|
|
||||||
|
const SEEDS = {
|
||||||
|
config: "shine_payments_v3_config",
|
||||||
|
coef: "shine_payments_v3_coef_limit",
|
||||||
|
queues: "shine_payments_v3_queues",
|
||||||
|
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COEF_SCALE = 1_000_000n;
|
||||||
|
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||||
|
let walletPubkey = null;
|
||||||
|
let lastState = null;
|
||||||
|
let activeEdit = "usd";
|
||||||
|
|
||||||
|
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||||
|
|
||||||
|
function utf8(s) { return new TextEncoder().encode(s); }
|
||||||
|
function u64ToBytes(v) {
|
||||||
|
let x = BigInt(v);
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function readU64(data, offset) {
|
||||||
|
let x = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function 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) {
|
||||||
|
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
let o = 0;
|
||||||
|
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function trimZeros(v) {
|
||||||
|
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||||||
|
}
|
||||||
|
function lamportsToSolStr(l) {
|
||||||
|
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||||
|
}
|
||||||
|
function centsToUsdStr(cents) {
|
||||||
|
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");
|
||||||
|
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) {
|
||||||
|
const msg = utf8("global:" + name);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||||
|
return new Uint8Array(hash).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
|
||||||
|
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); 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;
|
||||||
|
return { version, coefPpm, limitUsdCents, reward };
|
||||||
|
}
|
||||||
|
function parseQueues(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const q1Total = readU64(data, o); o += 8;
|
||||||
|
const q1Paid = readU64(data, o); o += 8;
|
||||||
|
const q1SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q1SumPaid = readU64(data, o); o += 8;
|
||||||
|
const q2Total = readU64(data, o); o += 8;
|
||||||
|
const q2Paid = readU64(data, o); o += 8;
|
||||||
|
const q2SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q2SumPaid = readU64(data, o); o += 8;
|
||||||
|
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||||
|
return window.solana;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWallet() {
|
||||||
|
const provider = getProvider();
|
||||||
|
const r = await provider.connect();
|
||||||
|
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
|
||||||
|
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||||||
|
await refreshState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInstruction(ix) {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
const tx = new solanaWeb3.Transaction().add(ix);
|
||||||
|
tx.feePayer = walletPubkey;
|
||||||
|
const bh = await connection.getLatestBlockhash("confirmed");
|
||||||
|
tx.recentBlockhash = bh.blockhash;
|
||||||
|
const signed = await provider.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePdas() {
|
||||||
|
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);
|
||||||
|
return { configPda, coefPda, queuesPda };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCoreState() {
|
||||||
|
const pdas = derivePdas();
|
||||||
|
const [cfgAi, coefAi, queuesAi, oracleAi] = await Promise.all([
|
||||||
|
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
|
||||||
|
]);
|
||||||
|
if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки.");
|
||||||
|
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
|
||||||
|
const config = parseConfig(cfgAi.data);
|
||||||
|
const coef = parseCoef(coefAi.data);
|
||||||
|
const queues = parseQueues(queuesAi.data);
|
||||||
|
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() {
|
||||||
|
const el = document.getElementById("stateInfo");
|
||||||
|
try {
|
||||||
|
lastState = await loadCoreState();
|
||||||
|
const { config, coef, queues, pyth } = lastState;
|
||||||
|
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
|
||||||
|
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 = `
|
||||||
|
<div>DAO: <code>${config.dao}</code></div>
|
||||||
|
<div>Inflow vault: <code>${config.inflow}</code></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>Лимит очереди 1: <b>${centsToUsdStr(coef.limitUsdCents)} USD</b></div>
|
||||||
|
<div>Награда за шаг выплат: <b>${lamportsToSolStr(coef.reward)} SOL</b></div>
|
||||||
|
<div>Из них сейчас не выплачено билетов: <b>${pendingBeforeCount.toString()}</b></div>
|
||||||
|
<div>Из них сейчас не выплачено по сумме: <b>${centsToUsdStr(pendingBeforeSum)} USD</b></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) {
|
||||||
|
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buyByUsd() {
|
||||||
|
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 usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||||||
|
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||||||
|
const payLamports = usdCentsToLamportsCeil(usdCents, pyth);
|
||||||
|
const maxPayLamports = applySlippageUp(payLamports, 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_usd");
|
||||||
|
const data = concat(disc, u64ToBytes(usdCents), u64ToBytes(maxPayLamports), 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 },
|
||||||
|
];
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", refreshState);
|
||||||
|
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();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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>
|
||||||
274
shine-solana/shine/programs/shine_payments/web/dao_tools.html
Normal file
274
shine-solana/shine/programs/shine_payments/web/dao_tools.html
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DAO-права менеджеров — 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); }
|
||||||
|
.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; }
|
||||||
|
input { padding: 9px 10px; min-width: 220px; 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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<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="warn">
|
||||||
|
Пока реального DAO-голосования нет: роль DAO выполняет тестовый кошелек
|
||||||
|
<code>FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P</code>.<br />
|
||||||
|
Позже это заменяется на вызов из настоящего DAO-казначейства/голосования.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<button id="connectBtn">Подключить кошелек</button>
|
||||||
|
<button id="refreshBtn">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div id="walletInfo" class="muted"></div>
|
||||||
|
<div id="daoInfo" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Выдать/добавить лимиты менеджеру</h3>
|
||||||
|
<div class="row">
|
||||||
|
<label>Кошелек менеджера: <input id="managerWallet" placeholder="Base58" /></label>
|
||||||
|
<label>Добавить лимит Q1 (USD): <input id="addQ1" value="100" /></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 class="panel">
|
||||||
|
<h3>Текущие лимиты менеджера</h3>
|
||||||
|
<div class="row">
|
||||||
|
<button id="loadManagerBtn">Показать лимиты</button>
|
||||||
|
</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>
|
||||||
|
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||||
|
const RPC_URL = "https://api.devnet.solana.com";
|
||||||
|
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||||
|
const SEEDS = {
|
||||||
|
config: "shine_payments_v3_config",
|
||||||
|
managerAllowance: "shine_p_v3_manager_allow",
|
||||||
|
};
|
||||||
|
let walletPubkey = null;
|
||||||
|
let configCache = null;
|
||||||
|
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||||
|
|
||||||
|
function utf8(s) { return new TextEncoder().encode(s); }
|
||||||
|
function u64ToBytes(v) {
|
||||||
|
let x = BigInt(v);
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function readU64(data, offset) {
|
||||||
|
let x = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function concat(...parts) {
|
||||||
|
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
let o = 0;
|
||||||
|
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function trimZeros(v) {
|
||||||
|
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||||||
|
}
|
||||||
|
function centsToUsdStr(c) {
|
||||||
|
return trimZeros((Number(c) / 100).toFixed(2));
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const msg = utf8("global:" + name);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||||
|
return new Uint8Array(hash).slice(0, 8);
|
||||||
|
}
|
||||||
|
function isUnauthorizedDao(msg) {
|
||||||
|
const s = String(msg || "").toLowerCase();
|
||||||
|
return s.includes("unauthorizeddao");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const dao = 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 parseManagerAllowance(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||||
|
const q1 = readU64(data, o); o += 8;
|
||||||
|
const q2 = readU64(data, o); o += 8;
|
||||||
|
return { version, manager, q1, q2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||||
|
return window.solana;
|
||||||
|
}
|
||||||
|
async function connectWallet() {
|
||||||
|
const provider = getProvider();
|
||||||
|
const r = await provider.connect();
|
||||||
|
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
|
||||||
|
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
async function sendInstruction(ix) {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
const tx = new solanaWeb3.Transaction().add(ix);
|
||||||
|
tx.feePayer = walletPubkey;
|
||||||
|
const bh = await connection.getLatestBlockhash("confirmed");
|
||||||
|
tx.recentBlockhash = bh.blockhash;
|
||||||
|
const signed = await provider.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveConfigPda() {
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
function deriveManagerAllowancePda(managerWallet) {
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.managerAllowance), managerWallet.toBytes()], PROGRAM_ID);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const configPda = deriveConfigPda();
|
||||||
|
const ai = await connection.getAccountInfo(configPda, "confirmed");
|
||||||
|
if (!ai) throw new Error("Config PDA не найден. Сначала выполните init.");
|
||||||
|
configCache = { configPda, config: parseConfig(ai.data) };
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const el = document.getElementById("daoInfo");
|
||||||
|
try {
|
||||||
|
const { config } = await loadConfig();
|
||||||
|
el.innerHTML = `
|
||||||
|
<div>DAO-кошелек: <code>${config.dao.toBase58()}</code></div>
|
||||||
|
<div class="muted">Выдавать лимиты может только этот кошелек.</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantLimits() {
|
||||||
|
const out = document.getElementById("grantResult");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
else if (!provider.isConnected) await provider.connect();
|
||||||
|
|
||||||
|
const { configPda } = configCache || await loadConfig();
|
||||||
|
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
|
||||||
|
const addQ1 = usdToCents(document.getElementById("addQ1").value.trim());
|
||||||
|
const addQ2 = usdToCents(document.getElementById("addQ2").value.trim());
|
||||||
|
if (addQ1 === 0n && addQ2 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди.");
|
||||||
|
|
||||||
|
const allowancePda = deriveManagerAllowancePda(manager);
|
||||||
|
const disc = await ixDiscriminator("grant_manager_limits");
|
||||||
|
const data = concat(disc, manager.toBytes(), u64ToBytes(addQ1), u64ToBytes(addQ2));
|
||||||
|
const keys = [
|
||||||
|
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: configPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: allowancePda, isSigner: false, isWritable: true },
|
||||||
|
{ 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>`;
|
||||||
|
} catch (e) {
|
||||||
|
const raw = String(e.message || e);
|
||||||
|
if (isUnauthorizedDao(raw)) {
|
||||||
|
const dao = configCache?.config?.dao?.toBase58?.() || "не определен";
|
||||||
|
out.innerHTML = `<span class="warn">Вы подключены не под DAO-кошельком. Нужен: <code>${dao}</code>.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.innerHTML = `<span class="err">${raw}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManagerLimits() {
|
||||||
|
const out = document.getElementById("managerState");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
|
||||||
|
const allowancePda = deriveManagerAllowancePda(manager);
|
||||||
|
const ai = await connection.getAccountInfo(allowancePda, "confirmed");
|
||||||
|
if (!ai) {
|
||||||
|
out.innerHTML = `<span class="warn">Лимиты для этого менеджера ещё не выданы (PDA не создан).</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const st = parseManagerAllowance(ai.data);
|
||||||
|
out.innerHTML = `
|
||||||
|
<div>Manager: <code>${st.manager.toBase58()}</code></div>
|
||||||
|
<div>PDA: <code>${allowancePda.toBase58()}</code></div>
|
||||||
|
<div>Доступно Q1: <b>${centsToUsdStr(st.q1)} USD</b></div>
|
||||||
|
<div>Доступно Q2: <b>${centsToUsdStr(st.q2)} USD</b></div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
||||||
|
document.getElementById("grantBtn").addEventListener("click", grantLimits);
|
||||||
|
document.getElementById("loadManagerBtn").addEventListener("click", loadManagerLimits);
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
90
shine-solana/shine/programs/shine_payments/web/index.html
Normal file
90
shine-solana/shine/programs/shine_payments/web/index.html
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Главная — Shine Payments Devnet</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
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 {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
a.card:hover { background: var(--hover); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Shine Payments Devnet</h1>
|
||||||
|
<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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Логика работы — Shine Payments Devnet</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||||
|
<h1>Логика работы Shine Payments (тестовый этап)</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<p>Система работает в <b>Devnet</b>. Экономика хранится в <b>USD-центах</b>, а реальные переводы происходят в SOL.</p>
|
||||||
|
<p>Курс SOL/USD берётся из Pyth прямо в контракте при покупке и при шаге выплаты. Цена проверяется на актуальность (не старше 120 секунд).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>1. Очереди и билеты</h3>
|
||||||
|
<p>Есть две очереди: очередь 1 и очередь 2. Каждый билет — отдельный PDA с полями: очередь, индекс, получатель, сумма выплаты, флаг выплачен/нет, сумма долга перед билетом.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>2. Покупка билета</h3>
|
||||||
|
<p>Обычная покупка создаёт билет только в очереди 1. Пользователь может ввести сумму в USD или SOL на UI. В контракте сумма переводится по курсу в USD-центы, а выплата билета рассчитывается как <code>purchase_usd_cents * coef</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>3. Менеджерские билеты</h3>
|
||||||
|
<p>DAO может выдать менеджеру лимиты на добавление билетов отдельно в очередь 1 и очередь 2. Менеджер создаёт билеты без денежного перевода, но с уменьшением своего доступного лимита.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>4. Порядок выплат</h3>
|
||||||
|
<p>Приоритет всегда у очереди 1. Если в очереди 1 нет невыплаченных билетов, идут выплаты очереди 2. Если в процессе выплат очереди 2 снова появляется билет в очереди 1, приоритет возвращается к очереди 1.</p>
|
||||||
|
<p>Шаг выплаты: для очереди 1 в DAO уходит 1x от выплаты тикета, для очереди 2 в DAO уходит 2x от выплаты тикета. Дополнительно вызывающий получает награду в SOL.</p>
|
||||||
|
<p>Если обе очереди пустые/выплачены — доступный остаток inflow-вольта переводится в DAO (без награды вызывающему).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>5. Тестовый режим пополнения выплат</h3>
|
||||||
|
<p>Пока регистрация/авто-поток пополнения не завершены, inflow-вольт для выплат пополняется вручную, после чего выполняются шаги выплат.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel muted">
|
||||||
|
Подробная версия в документе репозитория: <code>shine/doc/SHINE_PAYMENTS_V2.md</code>.
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,280 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Менеджерские билеты — 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); }
|
||||||
|
.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; }
|
||||||
|
input, select { padding: 9px 10px; min-width: 190px; 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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<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="row">
|
||||||
|
<button id="connectBtn">Подключить кошелек менеджера</button>
|
||||||
|
<button id="refreshBtn">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div id="walletInfo" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Лимиты менеджера</h3>
|
||||||
|
<div id="limitsInfo" class="muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Создать билет менеджером</h3>
|
||||||
|
<div class="row">
|
||||||
|
<label>Очередь:
|
||||||
|
<select id="queueId">
|
||||||
|
<option value="1">Очередь 1</option>
|
||||||
|
<option value="2">Очередь 2</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Получатель: <input id="recipient" placeholder="Base58 адрес" /></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>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||||
|
const RPC_URL = "https://api.devnet.solana.com";
|
||||||
|
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||||
|
const SEEDS = {
|
||||||
|
managerAllowance: "shine_p_v3_manager_allow",
|
||||||
|
queues: "shine_payments_v3_queues",
|
||||||
|
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||||
|
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||||
|
};
|
||||||
|
let walletPubkey = null;
|
||||||
|
let queuesCache = null;
|
||||||
|
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||||
|
|
||||||
|
function utf8(s) { return new TextEncoder().encode(s); }
|
||||||
|
function u64ToBytes(v) {
|
||||||
|
let x = BigInt(v);
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function readU64(data, offset) {
|
||||||
|
let x = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function concat(...parts) {
|
||||||
|
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
let o = 0;
|
||||||
|
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function trimZeros(v) {
|
||||||
|
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||||||
|
}
|
||||||
|
function centsToUsdStr(c) {
|
||||||
|
return trimZeros((Number(c) / 100).toFixed(2));
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const msg = utf8("global:" + name);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||||
|
return new Uint8Array(hash).slice(0, 8);
|
||||||
|
}
|
||||||
|
function isManagerErrors(msg) {
|
||||||
|
const s = String(msg || "").toLowerCase();
|
||||||
|
return s.includes("managerlimitexceeded") || s.includes("invalidmanagerwallet");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseManagerAllowance(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||||
|
const q1 = readU64(data, o); o += 8;
|
||||||
|
const q2 = readU64(data, o); o += 8;
|
||||||
|
return { version, manager, q1, q2 };
|
||||||
|
}
|
||||||
|
function parseQueues(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const q1Total = readU64(data, o); o += 8;
|
||||||
|
const q1Paid = readU64(data, o); o += 8;
|
||||||
|
const q1SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q1SumPaid = readU64(data, o); o += 8;
|
||||||
|
const q2Total = readU64(data, o); o += 8;
|
||||||
|
const q2Paid = readU64(data, o); o += 8;
|
||||||
|
const q2SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q2SumPaid = readU64(data, o); o += 8;
|
||||||
|
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||||
|
return window.solana;
|
||||||
|
}
|
||||||
|
async function connectWallet() {
|
||||||
|
const provider = getProvider();
|
||||||
|
const r = await provider.connect();
|
||||||
|
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
|
||||||
|
document.getElementById("walletInfo").textContent = "Кошелек менеджера: " + walletPubkey.toBase58();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
async function sendInstruction(ix) {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
const tx = new solanaWeb3.Transaction().add(ix);
|
||||||
|
tx.feePayer = walletPubkey;
|
||||||
|
const bh = await connection.getLatestBlockhash("confirmed");
|
||||||
|
tx.recentBlockhash = bh.blockhash;
|
||||||
|
const signed = await provider.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveManagerAllowancePda(managerWallet) {
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync(
|
||||||
|
[utf8(SEEDS.managerAllowance), managerWallet.toBytes()],
|
||||||
|
PROGRAM_ID
|
||||||
|
);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
function deriveQueuesPda() {
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
function deriveTicketPda(queueId, index) {
|
||||||
|
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCore() {
|
||||||
|
if (!walletPubkey) throw new Error("Сначала подключите кошелек менеджера.");
|
||||||
|
const allowancePda = deriveManagerAllowancePda(walletPubkey);
|
||||||
|
const queuesPda = deriveQueuesPda();
|
||||||
|
const [allowanceAi, queuesAi] = await Promise.all([
|
||||||
|
connection.getAccountInfo(allowancePda, "confirmed"),
|
||||||
|
connection.getAccountInfo(queuesPda, "confirmed"),
|
||||||
|
]);
|
||||||
|
if (!queuesAi) throw new Error("Queues PDA не найден. Сначала выполните init.");
|
||||||
|
queuesCache = parseQueues(queuesAi.data);
|
||||||
|
return {
|
||||||
|
allowancePda,
|
||||||
|
allowance: allowanceAi ? parseManagerAllowance(allowanceAi.data) : null,
|
||||||
|
queuesPda,
|
||||||
|
queues: queuesCache,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const el = document.getElementById("limitsInfo");
|
||||||
|
try {
|
||||||
|
const core = await loadCore();
|
||||||
|
if (!core.allowance) {
|
||||||
|
el.innerHTML = `<span class="warn">Для этого кошелька лимиты менеджера пока не выданы.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div>Manager: <code>${core.allowance.manager.toBase58()}</code></div>
|
||||||
|
<div>PDA лимитов: <code>${core.allowancePda.toBase58()}</code></div>
|
||||||
|
<div>Доступно Q1: <b>${centsToUsdStr(core.allowance.q1)} USD</b></div>
|
||||||
|
<div>Доступно Q2: <b>${centsToUsdStr(core.allowance.q2)} USD</b></div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManagerTicket() {
|
||||||
|
const out = document.getElementById("createResult");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
else if (!provider.isConnected) await provider.connect();
|
||||||
|
|
||||||
|
const core = await loadCore();
|
||||||
|
if (!core.allowance) throw new Error("Для этого кошелька лимиты менеджера не выданы.");
|
||||||
|
|
||||||
|
const queueId = Number(document.getElementById("queueId").value);
|
||||||
|
if (queueId !== 1 && queueId !== 2) throw new Error("Очередь должна быть 1 или 2");
|
||||||
|
const recipient = new solanaWeb3.PublicKey(document.getElementById("recipient").value.trim());
|
||||||
|
const payout = usdToCents(document.getElementById("payoutUsd").value.trim());
|
||||||
|
|
||||||
|
const nextIndex = queueId === 1 ? (core.queues.q1Total + 1n) : (core.queues.q2Total + 1n);
|
||||||
|
const ticketPda = deriveTicketPda(queueId, nextIndex);
|
||||||
|
|
||||||
|
const disc = await ixDiscriminator("manager_add_ticket");
|
||||||
|
const data = concat(disc, new Uint8Array([queueId]), recipient.toBytes(), u64ToBytes(payout));
|
||||||
|
const keys = [
|
||||||
|
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: core.allowancePda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: core.queuesPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||||||
|
{ 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 refresh();
|
||||||
|
} catch (e) {
|
||||||
|
const raw = String(e.message || e);
|
||||||
|
if (isManagerErrors(raw)) {
|
||||||
|
out.innerHTML = `<span class="warn">Операция отклонена: лимит менеджера недостаточен или кошелек не имеет прав менеджера.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.innerHTML = `<span class="err">${raw}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
||||||
|
document.getElementById("createBtn").addEventListener("click", createManagerTicket);
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Что ещё нужно до DAO — Shine Payments Devnet</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||||
|
<h1>Что ещё нужно до реального DAO</h1>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<p>Сейчас роль DAO выполняет обычный кошелёк (тестовый режим Devnet).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Что нужно добавить в production:</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Заменить обычный DAO-кошелёк на DAO-казначейство/голосование.</li>
|
||||||
|
<li>DAO-решения (выдача лимитов менеджерам, изменение параметров) проводить через голосование.</li>
|
||||||
|
<li>Зафиксировать production-источник цены (oracle governance, fallback-политика, мониторинг stale-данных).</li>
|
||||||
|
<li>Ограничить тестовые ключи и закрыть доступ к приватным данным.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<p>Текущий дизайн уже совместим с DAO-заменой: достаточно сменить авторизацию вызовов на DAO-механику без изменения базовой структуры очередей и билетов.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Сценарий тестирования — Shine Payments Devnet</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||||
|
<h1>Сценарий тестирования Shine Payments (Devnet)</h1>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Вариант А: один кошелёк</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Открыть <code>admin_tools</code>, выполнить <code>init</code>.</li>
|
||||||
|
<li>Открыть <code>buy_ticket</code>, купить несколько билетов (часть через USD, часть через SOL).</li>
|
||||||
|
<li>Открыть <code>dao_tools</code>, выдать лимиты менеджеру (тем же кошельком).</li>
|
||||||
|
<li>Открыть <code>manager_tools</code>, создать билеты в очередь 1 и очередь 2.</li>
|
||||||
|
<li>Пополнить inflow-вольт вручную.</li>
|
||||||
|
<li>Открыть <code>track_ticket</code>, выполнять шаги выплат до погашения очередей.</li>
|
||||||
|
<li>Проверить, что в шагах: Q1 = ticket + DAO(1x) + reward, Q2 = ticket + DAO(2x) + reward.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Вариант Б: несколько кошельков</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Кошелёк 1: DAO (выдаёт лимиты менеджерам).</li>
|
||||||
|
<li>Кошелёк 2: менеджер (создаёт билеты в очередь 1/2).</li>
|
||||||
|
<li>Кошельки 3+: покупатели (создают обычные билеты через покупку).</li>
|
||||||
|
<li>Любой кошелёк может запускать шаг выплат.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Как вернуть средства после тестов</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Довести выплаты до нужного состояния (или остановить на текущем шаге).</li>
|
||||||
|
<li>Сделать переводы с тестовых кошельков обратно на исходный кошелёк.</li>
|
||||||
|
<li>При необходимости закрыть неиспользуемые program/PDA-аккаунты и вернуть ренту (через CLI).</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<p>Пока DAO-governance не подключена, ключевые действия DAO выполняются обычным тестовым кошельком. В production это заменяется голосованием DAO.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
506
shine-solana/shine/programs/shine_payments/web/track_ticket.html
Normal file
506
shine-solana/shine/programs/shine_payments/web/track_ticket.html
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Отслеживание билета — 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); }
|
||||||
|
.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; }
|
||||||
|
input { padding: 9px 10px; min-width: 240px; 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; }
|
||||||
|
.paid { color: var(--ok); font-weight: 700; }
|
||||||
|
.waiting { color: var(--muted); }
|
||||||
|
.xfer { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||||
|
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<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="row">
|
||||||
|
<button id="connectBtn">Подключить кошелек</button>
|
||||||
|
<button id="refreshBtn">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div id="walletInfo" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Поиск билетов</h3>
|
||||||
|
<div class="row">
|
||||||
|
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
|
||||||
|
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
|
||||||
|
<button id="findBtn">Найти</button>
|
||||||
|
</div>
|
||||||
|
<div id="ticketResult" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Состояние шага выплат</h3>
|
||||||
|
<div id="payoutInfo" class="muted">Загрузка...</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="stepBtn">Сделать шаг выплат</button>
|
||||||
|
</div>
|
||||||
|
<div id="stepResult" class="muted"></div>
|
||||||
|
<div class="warn">Вызывающий шаг выплат платит сетевую комиссию транзакции и получает on-chain награду. Идея в том, что награда делает вызов экономически выгодным, поэтому всегда есть мотивация нажимать кнопку шага выплат.</div>
|
||||||
|
<div class="muted">Автоматического таймера в контракте нет: в Solana любая инструкция должна быть инициирована внешним вызовом.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||||
|
const RPC_URL = "https://api.devnet.solana.com";
|
||||||
|
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
|
||||||
|
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||||
|
|
||||||
|
const SEEDS = {
|
||||||
|
config: "shine_payments_v3_config",
|
||||||
|
coef: "shine_payments_v3_coef_limit",
|
||||||
|
queues: "shine_payments_v3_queues",
|
||||||
|
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||||
|
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||||
|
let walletPubkey = null;
|
||||||
|
let cachedCore = null;
|
||||||
|
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||||
|
|
||||||
|
function utf8(s) { return new TextEncoder().encode(s); }
|
||||||
|
function u64ToBytes(v) {
|
||||||
|
let x = BigInt(v);
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function readU64(data, offset) {
|
||||||
|
let x = 0n;
|
||||||
|
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function 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) {
|
||||||
|
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
let o = 0;
|
||||||
|
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function trimZeros(v) {
|
||||||
|
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||||||
|
}
|
||||||
|
function lamportsToSolStr(l) {
|
||||||
|
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||||
|
}
|
||||||
|
function centsToUsdStr(c) {
|
||||||
|
return trimZeros((Number(c) / 100).toFixed(2));
|
||||||
|
}
|
||||||
|
async function ixDiscriminator(name) {
|
||||||
|
const msg = utf8("global:" + name);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||||
|
return new Uint8Array(hash).slice(0, 8);
|
||||||
|
}
|
||||||
|
function isNotEnoughForStep(msg) {
|
||||||
|
const s = String(msg || "").toLowerCase();
|
||||||
|
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) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const dao = 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;
|
||||||
|
return { version, coefPpm, limitUsdCents, reward };
|
||||||
|
}
|
||||||
|
function parseQueues(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const q1Total = readU64(data, o); o += 8;
|
||||||
|
const q1Paid = readU64(data, o); o += 8;
|
||||||
|
const q1SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q1SumPaid = readU64(data, o); o += 8;
|
||||||
|
const q2Total = readU64(data, o); o += 8;
|
||||||
|
const q2Paid = readU64(data, o); o += 8;
|
||||||
|
const q2SumTotal = readU64(data, o); o += 8;
|
||||||
|
const q2SumPaid = readU64(data, o); o += 8;
|
||||||
|
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||||||
|
}
|
||||||
|
function parseTicket(data) {
|
||||||
|
let o = 0;
|
||||||
|
const version = data[o++];
|
||||||
|
const queueId = data[o++];
|
||||||
|
const index = readU64(data, o); o += 8;
|
||||||
|
const isPaid = data[o++] === 1;
|
||||||
|
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||||
|
const payoutUsdCents = readU64(data, o); o += 8;
|
||||||
|
const debtBeforeUsdCents = readU64(data, o); o += 8;
|
||||||
|
return { version, queueId, index, isPaid, recipient, payoutUsdCents, debtBeforeUsdCents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||||
|
return window.solana;
|
||||||
|
}
|
||||||
|
async function connectWallet() {
|
||||||
|
const provider = getProvider();
|
||||||
|
const r = await provider.connect();
|
||||||
|
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
|
||||||
|
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||||||
|
await refreshAll();
|
||||||
|
}
|
||||||
|
async function sendInstruction(ix) {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
const tx = new solanaWeb3.Transaction().add(ix);
|
||||||
|
tx.feePayer = walletPubkey;
|
||||||
|
const bh = await connection.getLatestBlockhash("confirmed");
|
||||||
|
tx.recentBlockhash = bh.blockhash;
|
||||||
|
const signed = await provider.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCorePdas() {
|
||||||
|
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);
|
||||||
|
return { configPda, coefPda, queuesPda };
|
||||||
|
}
|
||||||
|
function deriveTicketPda(queueId, index) {
|
||||||
|
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
|
||||||
|
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
|
||||||
|
return pda;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCoreState() {
|
||||||
|
const pdas = deriveCorePdas();
|
||||||
|
const [cfgAi, coefAi, qAi, oracleAi] = await Promise.all([
|
||||||
|
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||||||
|
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
|
||||||
|
]);
|
||||||
|
if (!cfgAi || !coefAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки.");
|
||||||
|
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
|
||||||
|
const config = parseConfig(cfgAi.data);
|
||||||
|
const coef = parseCoef(coefAi.data);
|
||||||
|
const queues = parseQueues(qAi.data);
|
||||||
|
const pyth = parsePythPriceUpdateV2(oracleAi.data);
|
||||||
|
const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed");
|
||||||
|
if (!inflowAi) throw new Error("Inflow vault отсутствует");
|
||||||
|
const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed");
|
||||||
|
const available = BigInt(Math.max(0, inflowAi.lamports - rentMin));
|
||||||
|
cachedCore = { pdas, config, coef, queues, pyth, available };
|
||||||
|
return cachedCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStepQueue(queues) {
|
||||||
|
const q1Pending = queues.q1Total - queues.q1Paid;
|
||||||
|
const q2Pending = queues.q2Total - queues.q2Paid;
|
||||||
|
if (q1Pending > 0n) return 1;
|
||||||
|
if (q2Pending > 0n) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
function nextPayoutTicket(queues) {
|
||||||
|
const queue = nextStepQueue(queues);
|
||||||
|
if (queue === 0) return null;
|
||||||
|
const index = queue === 1 ? (queues.q1Paid + 1n) : (queues.q2Paid + 1n);
|
||||||
|
return { queue, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPayoutInfo() {
|
||||||
|
const el = document.getElementById("payoutInfo");
|
||||||
|
try {
|
||||||
|
const core = await loadCoreState();
|
||||||
|
const queue = nextStepQueue(core.queues);
|
||||||
|
const pythAge = Math.max(0, Math.floor(Date.now() / 1000 - Number(core.pyth.publishTime)));
|
||||||
|
if (queue === 0) {
|
||||||
|
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>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
|
||||||
|
<div class="warn">При шаге эта сумма уйдет в DAO, награда не начисляется.</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
|
||||||
|
const nextPda = deriveTicketPda(queue, nextIndex);
|
||||||
|
const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
|
||||||
|
if (!nextAi) {
|
||||||
|
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()} для очереди ${queue}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = parseTicket(nextAi.data);
|
||||||
|
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);
|
||||||
|
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>#${next.index.toString()}</b></div>
|
||||||
|
<div>Тикет: <b>${centsToUsdStr(next.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(next.payoutUsdCents, core.pyth)} SOL)</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>${missing === 0n
|
||||||
|
? '<span class="ok">Хватает для шага выплаты.</span>'
|
||||||
|
: `<span class="warn">Не хватает: ${lamportsToSolStr(missing)} SOL</span>`
|
||||||
|
}</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTicketCard(core, pda, t) {
|
||||||
|
const next = nextPayoutTicket(core.queues);
|
||||||
|
const isNext = !!next && next.queue === t.queueId && next.index === t.index;
|
||||||
|
const isOwner = walletPubkey && walletPubkey.toBase58() === t.recipient.toBase58();
|
||||||
|
const canTransfer = !t.isPaid && isOwner && !isNext;
|
||||||
|
const whyBlocked = t.isPaid
|
||||||
|
? "Тикет уже выплачен"
|
||||||
|
: !isOwner
|
||||||
|
? "Передача доступна только текущему получателю тикета"
|
||||||
|
: isNext
|
||||||
|
? "Это следующий тикет на выплату, передача заблокирована"
|
||||||
|
: "";
|
||||||
|
return `
|
||||||
|
<div class="panel">
|
||||||
|
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
|
||||||
|
<div>PDA: <code>${pda.toBase58()}</code></div>
|
||||||
|
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
|
||||||
|
<div>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
|
||||||
|
<div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
|
||||||
|
<div class="xfer">
|
||||||
|
<div><b>Передача билета</b></div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="newRecipient_${t.queueId}_${t.index.toString()}" placeholder="Новый получатель (Base58)" />
|
||||||
|
<button
|
||||||
|
class="transferBtn"
|
||||||
|
data-queue="${t.queueId}"
|
||||||
|
data-index="${t.index.toString()}"
|
||||||
|
data-pda="${pda.toBase58()}"
|
||||||
|
${canTransfer ? "" : "disabled"}
|
||||||
|
>Передать</button>
|
||||||
|
</div>
|
||||||
|
<div id="transferResult_${t.queueId}_${t.index.toString()}" class="${canTransfer ? "muted" : "warn"}">${canTransfer ? "Доступно для текущего владельца тикета." : whyBlocked}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTicketRecipient(queueId, index, ticketPdaBase58) {
|
||||||
|
const resultEl = document.getElementById(`transferResult_${queueId}_${index}`);
|
||||||
|
const inputEl = document.getElementById(`newRecipient_${queueId}_${index}`);
|
||||||
|
resultEl.className = "muted";
|
||||||
|
resultEl.textContent = "";
|
||||||
|
try {
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
const newRecipientRaw = (inputEl.value || "").trim();
|
||||||
|
if (!newRecipientRaw) throw new Error("Введите адрес нового получателя");
|
||||||
|
const newRecipient = new solanaWeb3.PublicKey(newRecipientRaw);
|
||||||
|
|
||||||
|
const core = cachedCore || await loadCoreState();
|
||||||
|
const disc = await ixDiscriminator("change_ticket_recipient");
|
||||||
|
const data = concat(disc, newRecipient.toBytes());
|
||||||
|
const keys = [
|
||||||
|
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: new solanaWeb3.PublicKey(ticketPdaBase58), isSigner: false, isWritable: true },
|
||||||
|
];
|
||||||
|
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
|
||||||
|
const sig = await sendInstruction(ix);
|
||||||
|
resultEl.className = "ok";
|
||||||
|
resultEl.innerHTML = `Передача выполнена. Tx: <code>${sig}</code>`;
|
||||||
|
await refreshAll();
|
||||||
|
await findTickets();
|
||||||
|
} catch (e) {
|
||||||
|
resultEl.className = "err";
|
||||||
|
resultEl.textContent = String(e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTickets() {
|
||||||
|
const out = document.getElementById("ticketResult");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const core = await loadCoreState();
|
||||||
|
const idxRaw = document.getElementById("ticketIndex").value.trim();
|
||||||
|
const walletRaw = document.getElementById("recipientWallet").value.trim();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
if (idxRaw) {
|
||||||
|
const idx = BigInt(idxRaw);
|
||||||
|
for (const queue of [1, 2]) {
|
||||||
|
const pda = deriveTicketPda(queue, idx);
|
||||||
|
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||||
|
if (!ai) continue;
|
||||||
|
results.push({ pda, t: parseTicket(ai.data) });
|
||||||
|
}
|
||||||
|
if (results.length === 0) throw new Error(`Тикет #${idx.toString()} не найден ни в одной очереди`);
|
||||||
|
} else if (walletRaw) {
|
||||||
|
const recipient = new solanaWeb3.PublicKey(walletRaw);
|
||||||
|
for (const queue of [1, 2]) {
|
||||||
|
const total = queue === 1 ? core.queues.q1Total : core.queues.q2Total;
|
||||||
|
for (let i = 1n; i <= total; i++) {
|
||||||
|
const pda = deriveTicketPda(queue, i);
|
||||||
|
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||||
|
if (!ai) continue;
|
||||||
|
const t = parseTicket(ai.data);
|
||||||
|
if (t.recipient.toBase58() === recipient.toBase58()) results.push({ pda, t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
|
||||||
|
} else {
|
||||||
|
throw new Error("Введите номер билета или кошелек получателя");
|
||||||
|
}
|
||||||
|
out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stepPayout() {
|
||||||
|
const out = document.getElementById("stepResult");
|
||||||
|
out.textContent = "";
|
||||||
|
try {
|
||||||
|
const provider = getProvider();
|
||||||
|
if (!walletPubkey) await connectWallet();
|
||||||
|
else if (!provider.isConnected) await provider.connect();
|
||||||
|
|
||||||
|
const core = cachedCore || await loadCoreState();
|
||||||
|
const queue = nextStepQueue(core.queues);
|
||||||
|
|
||||||
|
let nextTicketPda;
|
||||||
|
let recipient;
|
||||||
|
if (queue === 0) {
|
||||||
|
nextTicketPda = deriveTicketPda(1, core.queues.q1Paid + 1n);
|
||||||
|
recipient = walletPubkey;
|
||||||
|
} else {
|
||||||
|
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
|
||||||
|
nextTicketPda = deriveTicketPda(queue, nextIndex);
|
||||||
|
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
|
||||||
|
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} для очереди ${queue} не найден`);
|
||||||
|
recipient = parseTicket(ai.data).recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disc = await ixDiscriminator("step_payout");
|
||||||
|
const data = concat(disc);
|
||||||
|
const keys = [
|
||||||
|
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: core.pdas.configPda, 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: nextTicketPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: recipient, 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 sig = await sendInstruction(ix);
|
||||||
|
out.innerHTML = `<span class="ok">Шаг выполнен. Tx: <code>${sig}</code></span>`;
|
||||||
|
await refreshAll();
|
||||||
|
} catch (e) {
|
||||||
|
const raw = String(e.message || e);
|
||||||
|
if (isNotEnoughForStep(raw)) {
|
||||||
|
out.innerHTML = `<span class="warn">Недостаточно средств для шага выплаты. Это нормальная обработанная ошибка.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.innerHTML = `<span class="err">${raw}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await refreshPayoutInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
|
||||||
|
document.getElementById("findBtn").addEventListener("click", findTickets);
|
||||||
|
document.getElementById("stepBtn").addEventListener("click", stepPayout);
|
||||||
|
document.getElementById("ticketResult").addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest(".transferBtn");
|
||||||
|
if (!btn) return;
|
||||||
|
const queueId = Number(btn.dataset.queue);
|
||||||
|
const index = btn.dataset.index;
|
||||||
|
const pda = btn.dataset.pda;
|
||||||
|
changeTicketRecipient(queueId, index, pda);
|
||||||
|
});
|
||||||
|
refreshAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
shine-solana/shine/programs/shine_users/Cargo.toml
Normal file
29
shine-solana/shine/programs/shine_users/Cargo.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "shine_users"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "User registration smart contract"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
name = "shine_users"
|
||||||
|
test = false
|
||||||
|
doctest = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anchor-lang = "0.31.1"
|
||||||
|
common = { path = "../common" }
|
||||||
|
shine_login_guard = { path = "../shine_login_guard", features = ["cpi", "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"]
|
||||||
32
shine-solana/shine/programs/shine_users/src/lib.rs
Normal file
32
shine-solana/shine/programs/shine_users/src/lib.rs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
use anchor_lang::prelude::*;
|
||||||
|
|
||||||
|
pub mod settings;
|
||||||
|
pub mod users;
|
||||||
|
|
||||||
|
use users::*;
|
||||||
|
|
||||||
|
declare_id!("FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm");
|
||||||
|
|
||||||
|
#[program]
|
||||||
|
pub mod shine {
|
||||||
|
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<()> {
|
||||||
|
users::create_user_pda(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) -> Result<()> {
|
||||||
|
users::update_user_pda(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
shine-solana/shine/programs/shine_users/src/settings.rs
Normal file
30
shine-solana/shine/programs/shine_users/src/settings.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
use common::deploy_config;
|
||||||
|
|
||||||
|
/// `USER_PDA_SEED_PREFIX` — префикс seed для пользовательского PDA (`login=<...>`).
|
||||||
|
pub const USER_PDA_SEED_PREFIX: &str = "login=";
|
||||||
|
/// `USERS_ECONOMY_CONFIG_SEED` — seed PDA с экономическими параметрами программы `shine_users`.
|
||||||
|
pub const USERS_ECONOMY_CONFIG_SEED: &[u8] = b"shine_users_economy_config";
|
||||||
|
/// `USER_PDA_SPACE` — стартовый размер PDA пользователя, дальше запись может расширяться через realloc.
|
||||||
|
pub const USER_PDA_SPACE: usize = 768;
|
||||||
|
/// `USERS_ECONOMY_CONFIG_SPACE` — размер PDA с экономическими параметрами `shine_users`.
|
||||||
|
pub const USERS_ECONOMY_CONFIG_SPACE: usize = 8 + 96;
|
||||||
|
|
||||||
|
/// `DAO_AUTHORITY` — адрес DAO-авторити, который имеет право обновлять economy-конфиг.
|
||||||
|
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";
|
||||||
|
/// `SHINE_LOGIN_GUARD_PROGRAM_ID` — адрес отдельной программы проверки премиальности логина.
|
||||||
|
pub const SHINE_LOGIN_GUARD_PROGRAM_ID: &str = deploy_config::SHINE_LOGIN_GUARD_PROGRAM_ID;
|
||||||
|
/// `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;
|
||||||
|
/// `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;
|
||||||
993
shine-solana/shine/programs/shine_users/src/users.rs
Normal file
993
shine-solana/shine/programs/shine_users/src/users.rs
Normal file
@ -0,0 +1,993 @@
|
|||||||
|
use crate::settings;
|
||||||
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_lang::solana_program::{
|
||||||
|
ed25519_program,
|
||||||
|
hash::hashv,
|
||||||
|
instruction::Instruction,
|
||||||
|
program::{get_return_data, invoke},
|
||||||
|
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 std::str::FromStr;
|
||||||
|
|
||||||
|
const MAGIC: &[u8; 5] = b"SHiNE";
|
||||||
|
const FORMAT_MAJOR: u8 = 1;
|
||||||
|
const FORMAT_MINOR: u8 = 0;
|
||||||
|
const KEY_STATUS_CREATED: u8 = 0;
|
||||||
|
const MAX_SYNC_SERVERS: usize = 32;
|
||||||
|
const MAX_AUTO_REALLOC_INCREASE: usize = 10_000;
|
||||||
|
const RESERVED_BYTES: [u8; 5] = [0, 0, 0, 0, 0];
|
||||||
|
const ZERO_HASH: [u8; 32] = [0; 32];
|
||||||
|
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||||
|
pub struct UserMutableFields {
|
||||||
|
pub blockchain_key: Pubkey,
|
||||||
|
pub device_key: Pubkey,
|
||||||
|
pub chain_number: u16,
|
||||||
|
pub is_server: bool,
|
||||||
|
pub server_key: Pubkey,
|
||||||
|
pub server_address: String,
|
||||||
|
pub sync_servers: Vec<String>,
|
||||||
|
pub access_servers: Vec<String>,
|
||||||
|
pub trusted_count: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||||
|
pub struct CreateUserPdaArgs {
|
||||||
|
pub login: String,
|
||||||
|
pub root_key: Pubkey,
|
||||||
|
pub created_at_ms: u64,
|
||||||
|
pub additional_limit: u64,
|
||||||
|
pub fields: UserMutableFields,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||||
|
pub struct UpdateUserPdaArgs {
|
||||||
|
pub login: String,
|
||||||
|
pub root_key: Pubkey,
|
||||||
|
pub created_at_ms: u64,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
pub version: u32,
|
||||||
|
pub prev_hash: Vec<u8>,
|
||||||
|
pub additional_limit: u64,
|
||||||
|
pub fields: UserMutableFields,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserRecord {
|
||||||
|
pub created_at_ms: u64,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
pub version: u32,
|
||||||
|
pub prev_hash: [u8; 32],
|
||||||
|
pub login: String,
|
||||||
|
pub root_key_status: u8,
|
||||||
|
pub root_key: Pubkey,
|
||||||
|
pub blockchain_key_status: u8,
|
||||||
|
pub blockchain_key: Pubkey,
|
||||||
|
pub device_key_status: u8,
|
||||||
|
pub device_key: Pubkey,
|
||||||
|
pub chain_number: u16,
|
||||||
|
pub balance: u64,
|
||||||
|
pub is_server: bool,
|
||||||
|
pub server_key: Pubkey,
|
||||||
|
pub server_address: String,
|
||||||
|
pub sync_servers: Vec<String>,
|
||||||
|
pub access_servers: Vec<String>,
|
||||||
|
pub trusted_count: u8,
|
||||||
|
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)]
|
||||||
|
pub struct CreateUserPda<'info> {
|
||||||
|
/// CHECK: подписант транзакции, валидируется Anchor как signer и mut.
|
||||||
|
#[account(mut, signer)]
|
||||||
|
pub signer: AccountInfo<'info>,
|
||||||
|
/// CHECK: PDA пользователя, адрес проверяется вручную через seed в обработчике.
|
||||||
|
#[account(mut)]
|
||||||
|
pub user_pda: AccountInfo<'info>,
|
||||||
|
pub system_program: Program<'info, System>,
|
||||||
|
/// CHECK: inflow-вольт shine_payments, адрес проверяется вручную как PDA.
|
||||||
|
#[account(mut)]
|
||||||
|
pub inflow_vault: AccountInfo<'info>,
|
||||||
|
/// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции.
|
||||||
|
pub instructions: AccountInfo<'info>,
|
||||||
|
/// CHECK: PDA с экономическими настройками пользователей, адрес проверяется вручную.
|
||||||
|
pub users_economy_config_pda: AccountInfo<'info>,
|
||||||
|
pub login_guard_program: Program<'info, shine_login_guard::program::ShineLoginGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct UpdateUserPda<'info> {
|
||||||
|
/// CHECK: подписант транзакции, валидируется Anchor как signer и mut.
|
||||||
|
#[account(mut, signer)]
|
||||||
|
pub signer: AccountInfo<'info>,
|
||||||
|
/// CHECK: PDA пользователя, адрес проверяется вручную через seed в обработчике.
|
||||||
|
#[account(mut)]
|
||||||
|
pub user_pda: AccountInfo<'info>,
|
||||||
|
pub system_program: Program<'info, System>,
|
||||||
|
/// CHECK: inflow-вольт shine_payments, адрес проверяется вручную как PDA.
|
||||||
|
#[account(mut)]
|
||||||
|
pub inflow_vault: AccountInfo<'info>,
|
||||||
|
/// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции.
|
||||||
|
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<()> {
|
||||||
|
validate_login(&args.login)?;
|
||||||
|
require_keys_eq!(
|
||||||
|
ctx.accounts.login_guard_program.key(),
|
||||||
|
Pubkey::from_str(settings::SHINE_LOGIN_GUARD_PROGRAM_ID)
|
||||||
|
.map_err(|_| error!(ErrCode::InvalidLoginGuardResponse))?,
|
||||||
|
ErrCode::InvalidLoginGuardResponse
|
||||||
|
);
|
||||||
|
classify_login_or_fail(
|
||||||
|
&ctx.accounts.login_guard_program.to_account_info(),
|
||||||
|
&ctx.accounts.signer,
|
||||||
|
&args.login,
|
||||||
|
)?;
|
||||||
|
validate_fields(&args.fields)?;
|
||||||
|
validate_inflow_vault(&ctx.accounts.inflow_vault)?;
|
||||||
|
require!(
|
||||||
|
args.additional_limit % settings::LIMIT_STEP == 0,
|
||||||
|
ErrCode::InvalidLimitIncrement
|
||||||
|
);
|
||||||
|
let economy = read_users_economy_config(&ctx.accounts.users_economy_config_pda)?;
|
||||||
|
|
||||||
|
let login_seed = login_seed_normalized(&args.login);
|
||||||
|
let (expected_pda, bump) = find_user_pda(ctx.program_id, &login_seed);
|
||||||
|
require_keys_eq!(
|
||||||
|
expected_pda,
|
||||||
|
ctx.accounts.user_pda.key(),
|
||||||
|
ErrCode::InvalidPdaAddress
|
||||||
|
);
|
||||||
|
require!(
|
||||||
|
ctx.accounts.user_pda.owner == &Pubkey::default(),
|
||||||
|
ErrCode::UserAlreadyExists
|
||||||
|
);
|
||||||
|
|
||||||
|
let start_balance = economy
|
||||||
|
.start_bonus_limit
|
||||||
|
.checked_add(args.additional_limit)
|
||||||
|
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||||
|
|
||||||
|
let mut record = UserRecord {
|
||||||
|
created_at_ms: args.created_at_ms,
|
||||||
|
updated_at_ms: args.created_at_ms,
|
||||||
|
version: 0,
|
||||||
|
prev_hash: ZERO_HASH,
|
||||||
|
login: args.login.clone(),
|
||||||
|
root_key_status: KEY_STATUS_CREATED,
|
||||||
|
root_key: args.root_key,
|
||||||
|
blockchain_key_status: KEY_STATUS_CREATED,
|
||||||
|
blockchain_key: args.fields.blockchain_key,
|
||||||
|
device_key_status: KEY_STATUS_CREATED,
|
||||||
|
device_key: args.fields.device_key,
|
||||||
|
chain_number: args.fields.chain_number,
|
||||||
|
balance: start_balance,
|
||||||
|
is_server: args.fields.is_server,
|
||||||
|
server_key: args.fields.server_key,
|
||||||
|
server_address: args.fields.server_address.clone(),
|
||||||
|
sync_servers: args.fields.sync_servers.clone(),
|
||||||
|
access_servers: args.fields.access_servers.clone(),
|
||||||
|
trusted_count: args.fields.trusted_count,
|
||||||
|
signature: [0; 64],
|
||||||
|
};
|
||||||
|
|
||||||
|
let unsigned = serialize_unsigned_record(&record)?;
|
||||||
|
record.signature = verify_record_signature(
|
||||||
|
&ctx.accounts.instructions,
|
||||||
|
&record.root_key,
|
||||||
|
&args.signature,
|
||||||
|
&unsigned,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let serialized = serialize_full_record(&record)?;
|
||||||
|
require!(
|
||||||
|
serialized.len() <= settings::USER_PDA_SPACE,
|
||||||
|
ErrCode::RecordTooLarge
|
||||||
|
);
|
||||||
|
let padded = pad_to_fixed_size(serialized, settings::USER_PDA_SPACE)?;
|
||||||
|
|
||||||
|
let pda_seeds: &[&[u8]] = &[
|
||||||
|
settings::USER_PDA_SEED_PREFIX.as_bytes(),
|
||||||
|
login_seed.as_bytes(),
|
||||||
|
&[bump],
|
||||||
|
];
|
||||||
|
create_pda(
|
||||||
|
&ctx.accounts.user_pda,
|
||||||
|
&ctx.accounts.signer,
|
||||||
|
&ctx.accounts.system_program.to_account_info(),
|
||||||
|
ctx.program_id,
|
||||||
|
pda_seeds,
|
||||||
|
settings::USER_PDA_SPACE as u64,
|
||||||
|
)?;
|
||||||
|
write_to_pda(&ctx.accounts.user_pda, &padded)?;
|
||||||
|
|
||||||
|
let total_fee = economy
|
||||||
|
.registration_fee_lamports
|
||||||
|
.checked_add(limit_fee_lamports(args.additional_limit, economy.lamports_per_limit_step)?)
|
||||||
|
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||||
|
transfer_lamports(
|
||||||
|
&ctx.accounts.signer,
|
||||||
|
&ctx.accounts.inflow_vault,
|
||||||
|
&ctx.accounts.system_program.to_account_info(),
|
||||||
|
total_fee,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_login_or_fail<'info>(
|
||||||
|
login_guard_program: &AccountInfo<'info>,
|
||||||
|
signer: &AccountInfo<'info>,
|
||||||
|
login: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let cpi_ctx = CpiContext::new(
|
||||||
|
login_guard_program.clone(),
|
||||||
|
shine_login_guard::cpi::accounts::ClassifyLogin {
|
||||||
|
signer: signer.to_account_info(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
shine_login_guard::cpi::classify_login(cpi_ctx, login.to_string())?;
|
||||||
|
|
||||||
|
let (program_id, raw) = get_return_data().ok_or(error!(ErrCode::InvalidLoginGuardResponse))?;
|
||||||
|
require_keys_eq!(
|
||||||
|
program_id,
|
||||||
|
*login_guard_program.key,
|
||||||
|
ErrCode::InvalidLoginGuardResponse
|
||||||
|
);
|
||||||
|
require!(raw.len() == 4, ErrCode::InvalidLoginGuardResponse);
|
||||||
|
let class = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]);
|
||||||
|
match class {
|
||||||
|
0 => Ok(()),
|
||||||
|
1 => Err(error!(ErrCode::PremiumLogin)),
|
||||||
|
2 => Err(error!(ErrCode::TrademarkLoginRequiresReview)),
|
||||||
|
_ => Err(error!(ErrCode::InvalidLoginGuardResponse)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) -> Result<()> {
|
||||||
|
validate_login(&args.login)?;
|
||||||
|
validate_fields(&args.fields)?;
|
||||||
|
validate_inflow_vault(&ctx.accounts.inflow_vault)?;
|
||||||
|
require!(
|
||||||
|
args.additional_limit % settings::LIMIT_STEP == 0,
|
||||||
|
ErrCode::InvalidLimitIncrement
|
||||||
|
);
|
||||||
|
let economy = read_users_economy_config(&ctx.accounts.users_economy_config_pda)?;
|
||||||
|
|
||||||
|
let login_seed = login_seed_normalized(&args.login);
|
||||||
|
let (expected_pda, _) = find_user_pda(ctx.program_id, &login_seed);
|
||||||
|
require_keys_eq!(
|
||||||
|
expected_pda,
|
||||||
|
ctx.accounts.user_pda.key(),
|
||||||
|
ErrCode::InvalidPdaAddress
|
||||||
|
);
|
||||||
|
require!(
|
||||||
|
ctx.accounts.user_pda.owner == ctx.program_id,
|
||||||
|
ErrCode::InvalidPdaAddress
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = safe_read_pda(&ctx.accounts.user_pda);
|
||||||
|
require!(!raw.is_empty(), ErrCode::EmptyPdaData);
|
||||||
|
let old_record = deserialize_record_from_pda(&raw)?;
|
||||||
|
|
||||||
|
require!(
|
||||||
|
old_record.login == args.login,
|
||||||
|
ErrCode::ImmutableFieldChanged
|
||||||
|
);
|
||||||
|
require!(
|
||||||
|
old_record.created_at_ms == args.created_at_ms,
|
||||||
|
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!(
|
||||||
|
args.version == old_record.version.saturating_add(1),
|
||||||
|
ErrCode::InvalidVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
let expected_prev_hash = hash_unsigned_record(&old_record)?;
|
||||||
|
let provided_prev_hash = vec_to_hash32(&args.prev_hash)?;
|
||||||
|
require!(
|
||||||
|
expected_prev_hash == provided_prev_hash,
|
||||||
|
ErrCode::InvalidPrevHash
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_balance = old_record
|
||||||
|
.balance
|
||||||
|
.checked_add(args.additional_limit)
|
||||||
|
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||||
|
require!(new_balance >= old_record.balance, ErrCode::BalanceDecrease);
|
||||||
|
|
||||||
|
let mut new_record = UserRecord {
|
||||||
|
created_at_ms: old_record.created_at_ms,
|
||||||
|
updated_at_ms: args.updated_at_ms,
|
||||||
|
version: args.version,
|
||||||
|
prev_hash: provided_prev_hash,
|
||||||
|
login: old_record.login.clone(),
|
||||||
|
root_key_status: old_record.root_key_status,
|
||||||
|
root_key: old_record.root_key,
|
||||||
|
blockchain_key_status: old_record.blockchain_key_status,
|
||||||
|
blockchain_key: args.fields.blockchain_key,
|
||||||
|
device_key_status: old_record.device_key_status,
|
||||||
|
device_key: args.fields.device_key,
|
||||||
|
chain_number: args.fields.chain_number,
|
||||||
|
balance: new_balance,
|
||||||
|
is_server: args.fields.is_server,
|
||||||
|
server_key: args.fields.server_key,
|
||||||
|
server_address: args.fields.server_address.clone(),
|
||||||
|
sync_servers: args.fields.sync_servers.clone(),
|
||||||
|
access_servers: args.fields.access_servers.clone(),
|
||||||
|
trusted_count: args.fields.trusted_count,
|
||||||
|
signature: [0; 64],
|
||||||
|
};
|
||||||
|
|
||||||
|
let unsigned = serialize_unsigned_record(&new_record)?;
|
||||||
|
new_record.signature = verify_record_signature(
|
||||||
|
&ctx.accounts.instructions,
|
||||||
|
&new_record.root_key,
|
||||||
|
&args.signature,
|
||||||
|
&unsigned,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let serialized = serialize_full_record(&new_record)?;
|
||||||
|
ensure_pda_size_and_rent(
|
||||||
|
&ctx.accounts.user_pda,
|
||||||
|
&ctx.accounts.signer,
|
||||||
|
&ctx.accounts.system_program.to_account_info(),
|
||||||
|
serialized.len(),
|
||||||
|
)?;
|
||||||
|
write_to_pda(&ctx.accounts.user_pda, &serialized)?;
|
||||||
|
|
||||||
|
let topup_fee = limit_fee_lamports(args.additional_limit, economy.lamports_per_limit_step)?;
|
||||||
|
if topup_fee > 0 {
|
||||||
|
transfer_lamports(
|
||||||
|
&ctx.accounts.signer,
|
||||||
|
&ctx.accounts.inflow_vault,
|
||||||
|
&ctx.accounts.system_program.to_account_info(),
|
||||||
|
topup_fee,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>> {
|
||||||
|
let login_bytes = record.login.as_bytes();
|
||||||
|
require!(login_bytes.len() <= u8::MAX as usize, ErrCode::InvalidLogin);
|
||||||
|
|
||||||
|
let server_address_bytes = record.server_address.as_bytes();
|
||||||
|
require!(
|
||||||
|
server_address_bytes.len() <= u8::MAX as usize,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
require!(record.access_servers.len() <= u8::MAX as usize, ErrCode::InvalidRecordData);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(MAGIC);
|
||||||
|
out.push(FORMAT_MAJOR);
|
||||||
|
out.push(FORMAT_MINOR);
|
||||||
|
out.extend_from_slice(&0u16.to_le_bytes());
|
||||||
|
|
||||||
|
out.extend_from_slice(&record.created_at_ms.to_le_bytes());
|
||||||
|
out.extend_from_slice(&record.updated_at_ms.to_le_bytes());
|
||||||
|
out.extend_from_slice(&record.version.to_le_bytes());
|
||||||
|
out.extend_from_slice(&record.prev_hash);
|
||||||
|
|
||||||
|
out.push(login_bytes.len() as u8);
|
||||||
|
out.extend_from_slice(login_bytes);
|
||||||
|
|
||||||
|
out.push(record.root_key_status);
|
||||||
|
out.extend_from_slice(record.root_key.as_ref());
|
||||||
|
out.push(record.blockchain_key_status);
|
||||||
|
out.extend_from_slice(record.blockchain_key.as_ref());
|
||||||
|
out.push(record.device_key_status);
|
||||||
|
out.extend_from_slice(record.device_key.as_ref());
|
||||||
|
|
||||||
|
out.extend_from_slice(&record.chain_number.to_le_bytes());
|
||||||
|
out.extend_from_slice(&record.balance.to_le_bytes());
|
||||||
|
|
||||||
|
out.push(if record.is_server { 1 } else { 0 });
|
||||||
|
if record.is_server {
|
||||||
|
out.extend_from_slice(record.server_key.as_ref());
|
||||||
|
out.push(server_address_bytes.len() as u8);
|
||||||
|
out.extend_from_slice(server_address_bytes);
|
||||||
|
require!(
|
||||||
|
record.sync_servers.len() <= MAX_SYNC_SERVERS,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
out.push(record.sync_servers.len() as u8);
|
||||||
|
for login in &record.sync_servers {
|
||||||
|
let bytes = login.as_bytes();
|
||||||
|
require!(bytes.len() <= u8::MAX as usize, ErrCode::InvalidRecordData);
|
||||||
|
out.push(bytes.len() as u8);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(record.access_servers.len() as u8);
|
||||||
|
for login in &record.access_servers {
|
||||||
|
let bytes = login.as_bytes();
|
||||||
|
require!(bytes.len() <= u8::MAX as usize, ErrCode::InvalidRecordData);
|
||||||
|
out.push(bytes.len() as u8);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(record.trusted_count);
|
||||||
|
out.extend_from_slice(&RESERVED_BYTES);
|
||||||
|
|
||||||
|
let record_len = out
|
||||||
|
.len()
|
||||||
|
.checked_add(64)
|
||||||
|
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||||
|
require!(record_len <= u16::MAX as usize, ErrCode::RecordTooLarge);
|
||||||
|
let len_bytes = (record_len as u16).to_le_bytes();
|
||||||
|
out[7] = len_bytes[0];
|
||||||
|
out[8] = len_bytes[1];
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_full_record(record: &UserRecord) -> Result<Vec<u8>> {
|
||||||
|
let mut out = serialize_unsigned_record(record)?;
|
||||||
|
out.extend_from_slice(&record.signature);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord> {
|
||||||
|
require!(raw.len() >= 9, ErrCode::InvalidRecordData);
|
||||||
|
require!(&raw[0..5] == MAGIC, ErrCode::InvalidRecordMagic);
|
||||||
|
require!(
|
||||||
|
raw[5] == FORMAT_MAJOR && raw[6] == FORMAT_MINOR,
|
||||||
|
ErrCode::InvalidRecordFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
let record_len = u16::from_le_bytes([raw[7], raw[8]]) as usize;
|
||||||
|
require!(record_len >= 9 + 64, ErrCode::InvalidRecordLength);
|
||||||
|
require!(record_len <= raw.len(), ErrCode::InvalidRecordLength);
|
||||||
|
|
||||||
|
let useful = &raw[..record_len];
|
||||||
|
let mut cursor = 9usize;
|
||||||
|
|
||||||
|
let created_at_ms = read_u64(useful, &mut cursor)?;
|
||||||
|
let updated_at_ms = read_u64(useful, &mut cursor)?;
|
||||||
|
let version = read_u32(useful, &mut cursor)?;
|
||||||
|
let prev_hash = read_fixed_32(useful, &mut cursor)?;
|
||||||
|
let login = read_len_prefixed_string(useful, &mut cursor)?;
|
||||||
|
|
||||||
|
let root_key_status = read_u8(useful, &mut cursor)?;
|
||||||
|
let root_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
|
||||||
|
let blockchain_key_status = read_u8(useful, &mut cursor)?;
|
||||||
|
let blockchain_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
|
||||||
|
let device_key_status = read_u8(useful, &mut cursor)?;
|
||||||
|
let device_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?);
|
||||||
|
|
||||||
|
let chain_number = read_u16(useful, &mut cursor)?;
|
||||||
|
let balance = read_u64(useful, &mut cursor)?;
|
||||||
|
|
||||||
|
let is_server = read_u8(useful, &mut cursor)? == 1;
|
||||||
|
let (server_key, server_address) = if is_server {
|
||||||
|
(
|
||||||
|
Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?),
|
||||||
|
read_len_prefixed_string(useful, &mut cursor)?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Pubkey::default(), String::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
let sync_servers = if is_server {
|
||||||
|
let sync_count = read_u8(useful, &mut cursor)? as usize;
|
||||||
|
require!(sync_count <= MAX_SYNC_SERVERS, ErrCode::InvalidRecordData);
|
||||||
|
let mut out = Vec::with_capacity(sync_count);
|
||||||
|
for _ in 0..sync_count {
|
||||||
|
out.push(read_len_prefixed_string(useful, &mut cursor)?);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let access_count = read_u8(useful, &mut cursor)? as usize;
|
||||||
|
let mut access_servers = Vec::with_capacity(access_count);
|
||||||
|
for _ in 0..access_count {
|
||||||
|
access_servers.push(read_len_prefixed_string(useful, &mut cursor)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trusted_count = read_u8(useful, &mut cursor)?;
|
||||||
|
require!(
|
||||||
|
useful.get(cursor..cursor + 5) == Some(&RESERVED_BYTES),
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
cursor += 5;
|
||||||
|
|
||||||
|
let signature = read_fixed_64(useful, &mut cursor)?;
|
||||||
|
require!(cursor == useful.len(), ErrCode::InvalidRecordLength);
|
||||||
|
|
||||||
|
Ok(UserRecord {
|
||||||
|
created_at_ms,
|
||||||
|
updated_at_ms,
|
||||||
|
version,
|
||||||
|
prev_hash,
|
||||||
|
login,
|
||||||
|
root_key_status,
|
||||||
|
root_key,
|
||||||
|
blockchain_key_status,
|
||||||
|
blockchain_key,
|
||||||
|
device_key_status,
|
||||||
|
device_key,
|
||||||
|
chain_number,
|
||||||
|
balance,
|
||||||
|
is_server,
|
||||||
|
server_key,
|
||||||
|
server_address,
|
||||||
|
sync_servers,
|
||||||
|
access_servers,
|
||||||
|
trusted_count,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_unsigned_record(record: &UserRecord) -> Result<[u8; 32]> {
|
||||||
|
let unsigned = serialize_unsigned_record(record)?;
|
||||||
|
let digest = hashv(&[&unsigned]);
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
out.copy_from_slice(digest.as_ref());
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_record_signature(
|
||||||
|
instructions_sysvar: &AccountInfo,
|
||||||
|
root_key: &Pubkey,
|
||||||
|
signature: &[u8],
|
||||||
|
unsigned: &[u8],
|
||||||
|
) -> Result<[u8; 64]> {
|
||||||
|
require_keys_eq!(
|
||||||
|
*instructions_sysvar.key,
|
||||||
|
anchor_lang::solana_program::sysvar::instructions::id(),
|
||||||
|
ErrCode::InvalidSignature
|
||||||
|
);
|
||||||
|
let provided_sig = vec_to_signature(signature)?;
|
||||||
|
let msg_hash = hashv(&[unsigned]);
|
||||||
|
|
||||||
|
let current_ix_index = load_current_index_checked(instructions_sysvar)
|
||||||
|
.map_err(|_| error!(ErrCode::InvalidSignature))?;
|
||||||
|
require!(current_ix_index > 0, ErrCode::InvalidSignature);
|
||||||
|
let ed_ix = load_instruction_at_checked((current_ix_index - 1) as usize, instructions_sysvar)
|
||||||
|
.map_err(|_| error!(ErrCode::InvalidSignature))?;
|
||||||
|
|
||||||
|
let parsed = parse_ed25519_ix(&ed_ix)?;
|
||||||
|
require_keys_eq!(parsed.pubkey, *root_key, ErrCode::InvalidSignature);
|
||||||
|
require!(
|
||||||
|
parsed.message == msg_hash.as_ref(),
|
||||||
|
ErrCode::InvalidSignature
|
||||||
|
);
|
||||||
|
require!(parsed.signature == provided_sig, ErrCode::InvalidSignature);
|
||||||
|
|
||||||
|
Ok(parsed.signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedEd25519 {
|
||||||
|
pub pubkey: Pubkey,
|
||||||
|
pub signature: [u8; 64],
|
||||||
|
pub message: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ed25519_ix(ix: &Instruction) -> Result<ParsedEd25519> {
|
||||||
|
require_keys_eq!(
|
||||||
|
ix.program_id,
|
||||||
|
ed25519_program::id(),
|
||||||
|
ErrCode::InvalidSignature
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = &ix.data;
|
||||||
|
require!(data.len() >= 16, ErrCode::InvalidSignature);
|
||||||
|
require!(data[0] == 1, ErrCode::InvalidSignature); // одна подпись
|
||||||
|
|
||||||
|
let signature_offset = le_u16(data, 2)? as usize;
|
||||||
|
let signature_ix_index = le_u16(data, 4)?;
|
||||||
|
let pubkey_offset = le_u16(data, 6)? as usize;
|
||||||
|
let pubkey_ix_index = le_u16(data, 8)?;
|
||||||
|
let message_offset = le_u16(data, 10)? as usize;
|
||||||
|
let message_size = le_u16(data, 12)? as usize;
|
||||||
|
let message_ix_index = le_u16(data, 14)?;
|
||||||
|
|
||||||
|
require!(signature_ix_index == u16::MAX, ErrCode::InvalidSignature);
|
||||||
|
require!(pubkey_ix_index == u16::MAX, ErrCode::InvalidSignature);
|
||||||
|
require!(message_ix_index == u16::MAX, ErrCode::InvalidSignature);
|
||||||
|
|
||||||
|
let signature_end = signature_offset
|
||||||
|
.checked_add(64)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
let pubkey_end = pubkey_offset
|
||||||
|
.checked_add(32)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
let message_end = message_offset
|
||||||
|
.checked_add(message_size)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
|
||||||
|
let signature_slice = data
|
||||||
|
.get(signature_offset..signature_end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
let pubkey_slice = data
|
||||||
|
.get(pubkey_offset..pubkey_end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
let message = data
|
||||||
|
.get(message_offset..message_end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
let mut signature = [0u8; 64];
|
||||||
|
signature.copy_from_slice(signature_slice);
|
||||||
|
let pubkey = Pubkey::new_from_array(
|
||||||
|
<[u8; 32]>::try_from(pubkey_slice).map_err(|_| error!(ErrCode::InvalidSignature))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ParsedEd25519 {
|
||||||
|
pubkey,
|
||||||
|
signature,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn le_u16(data: &[u8], offset: usize) -> Result<u16> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(2)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
let s = data
|
||||||
|
.get(offset..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidSignature))?;
|
||||||
|
Ok(u16::from_le_bytes([s[0], s[1]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_login(login: &str) -> Result<()> {
|
||||||
|
require!(!login.is_empty(), ErrCode::InvalidLogin);
|
||||||
|
require!(login.len() <= 20, ErrCode::InvalidLogin);
|
||||||
|
for ch in login.chars() {
|
||||||
|
if !(ch.is_ascii_alphabetic() || ch.is_ascii_digit() || ch == '_') {
|
||||||
|
return Err(error!(ErrCode::InvalidLogin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_seed_normalized(login: &str) -> String {
|
||||||
|
login.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_fields(fields: &UserMutableFields) -> Result<()> {
|
||||||
|
if fields.is_server {
|
||||||
|
require!(
|
||||||
|
!fields.server_address.is_empty(),
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
require!(
|
||||||
|
fields.server_address.as_bytes().len() <= u8::MAX as usize,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
require!(
|
||||||
|
fields.sync_servers.len() <= MAX_SYNC_SERVERS,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
for login in &fields.sync_servers {
|
||||||
|
require!(!login.is_empty(), ErrCode::InvalidRecordData);
|
||||||
|
require!(
|
||||||
|
login.as_bytes().len() <= u8::MAX as usize,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
require!(fields.server_address.is_empty(), ErrCode::InvalidRecordData);
|
||||||
|
require!(fields.sync_servers.is_empty(), ErrCode::InvalidRecordData);
|
||||||
|
}
|
||||||
|
require!(
|
||||||
|
fields.access_servers.len() <= u8::MAX as usize,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
for login in &fields.access_servers {
|
||||||
|
require!(!login.is_empty(), ErrCode::InvalidRecordData);
|
||||||
|
require!(
|
||||||
|
login.as_bytes().len() <= u8::MAX as usize,
|
||||||
|
ErrCode::InvalidRecordData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_inflow_vault(inflow_vault: &AccountInfo) -> Result<()> {
|
||||||
|
let payments_program_id = Pubkey::from_str(settings::SHINE_PAYMENTS_PROGRAM_ID)
|
||||||
|
.map_err(|_| error!(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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_lamports<'info>(
|
||||||
|
payer: &AccountInfo<'info>,
|
||||||
|
recipient: &AccountInfo<'info>,
|
||||||
|
system_program: &AccountInfo<'info>,
|
||||||
|
lamports: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
if lamports == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ix = system_instruction::transfer(payer.key, recipient.key, lamports);
|
||||||
|
invoke(
|
||||||
|
&ix,
|
||||||
|
&[payer.clone(), recipient.clone(), system_program.clone()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_pda_size_and_rent<'info>(
|
||||||
|
pda: &AccountInfo<'info>,
|
||||||
|
payer: &AccountInfo<'info>,
|
||||||
|
system_program: &AccountInfo<'info>,
|
||||||
|
required_len: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let current_len = pda.data_len();
|
||||||
|
if required_len <= current_len {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let increase = required_len
|
||||||
|
.checked_sub(current_len)
|
||||||
|
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||||
|
require!(increase <= MAX_AUTO_REALLOC_INCREASE, ErrCode::RecordTooLarge);
|
||||||
|
|
||||||
|
let rent = Rent::get()?;
|
||||||
|
let required_lamports = rent.minimum_balance(required_len);
|
||||||
|
let current_lamports = pda.lamports();
|
||||||
|
let top_up = required_lamports.saturating_sub(current_lamports);
|
||||||
|
if top_up > 0 {
|
||||||
|
transfer_lamports(payer, pda, system_program, top_up)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
pda.realloc(required_len, false)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit_fee_lamports(limit_delta: u64, lamports_per_limit_step: u64) -> Result<u64> {
|
||||||
|
let units = limit_delta / settings::LIMIT_STEP;
|
||||||
|
units
|
||||||
|
.checked_mul(lamports_per_limit_step)
|
||||||
|
.ok_or(error!(ErrCode::MathOverflow))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_user_pda(program_id: &Pubkey, login: &str) -> (Pubkey, u8) {
|
||||||
|
Pubkey::find_program_address(
|
||||||
|
&[settings::USER_PDA_SEED_PREFIX.as_bytes(), login.as_bytes()],
|
||||||
|
program_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>> {
|
||||||
|
require!(bytes.len() <= target_size, ErrCode::RecordTooLarge);
|
||||||
|
bytes.resize(target_size, 0);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec_to_signature(input: &[u8]) -> Result<[u8; 64]> {
|
||||||
|
require!(input.len() == 64, ErrCode::InvalidSignature);
|
||||||
|
let mut out = [0u8; 64];
|
||||||
|
out.copy_from_slice(input);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec_to_hash32(input: &[u8]) -> Result<[u8; 32]> {
|
||||||
|
require!(input.len() == 32, ErrCode::InvalidPrevHash);
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
out.copy_from_slice(input);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u8(data: &[u8], cursor: &mut usize) -> Result<u8> {
|
||||||
|
let v = *data
|
||||||
|
.get(*cursor)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor += 1;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(data: &[u8], cursor: &mut usize) -> Result<u16> {
|
||||||
|
let end = cursor
|
||||||
|
.checked_add(2)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
let slice = data
|
||||||
|
.get(*cursor..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor = end;
|
||||||
|
Ok(u16::from_le_bytes([slice[0], slice[1]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], cursor: &mut usize) -> Result<u32> {
|
||||||
|
let end = cursor
|
||||||
|
.checked_add(4)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
let slice = data
|
||||||
|
.get(*cursor..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor = end;
|
||||||
|
Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(data: &[u8], cursor: &mut usize) -> Result<u64> {
|
||||||
|
let end = cursor
|
||||||
|
.checked_add(8)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
let slice = data
|
||||||
|
.get(*cursor..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor = end;
|
||||||
|
Ok(u64::from_le_bytes([
|
||||||
|
slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_fixed_32(data: &[u8], cursor: &mut usize) -> Result<[u8; 32]> {
|
||||||
|
let end = cursor
|
||||||
|
.checked_add(32)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
let slice = data
|
||||||
|
.get(*cursor..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor = end;
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
out.copy_from_slice(slice);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_fixed_64(data: &[u8], cursor: &mut usize) -> Result<[u8; 64]> {
|
||||||
|
let end = cursor
|
||||||
|
.checked_add(64)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
let slice = data
|
||||||
|
.get(*cursor..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor = end;
|
||||||
|
let mut out = [0u8; 64];
|
||||||
|
out.copy_from_slice(slice);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_len_prefixed_string(data: &[u8], cursor: &mut usize) -> Result<String> {
|
||||||
|
let len = read_u8(data, cursor)? as usize;
|
||||||
|
let end = cursor
|
||||||
|
.checked_add(len)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
let slice = data
|
||||||
|
.get(*cursor..end)
|
||||||
|
.ok_or(error!(ErrCode::InvalidRecordData))?;
|
||||||
|
*cursor = end;
|
||||||
|
let value = std::str::from_utf8(slice).map_err(|_| error!(ErrCode::InvalidRecordData))?;
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
@ -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); });
|
||||||
@ -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);});
|
||||||
@ -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);});
|
||||||
@ -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);});
|
||||||
@ -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);});
|
||||||
@ -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);});
|
||||||
@ -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);});
|
||||||
@ -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.
|
||||||
@ -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 };
|
||||||
@ -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"
|
||||||
@ -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"
|
||||||
@ -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"
|
||||||
10
shine-solana/shine/scripts/CreateGovernmentTokenAndDAO/04_create_dao.sh
Executable file
10
shine-solana/shine/scripts/CreateGovernmentTokenAndDAO/04_create_dao.sh
Executable 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"
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
#
|
||||||
|
# RU: Передает права Mint/Freeze/PermanentDelegate на Governance PDA из конфига.
|
||||||
|
# Перед отправкой транзакции внутри JS будет подтверждение "yes".
|
||||||
|
# EN: Transfers Mint/Freeze/PermanentDelegate authorities to Governance PDA
|
||||||
|
# from config. JS script asks for "yes" confirmation before sending.
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
|
||||||
|
node "$SCRIPT_DIR/js/04_transfer_rights_to_governance_pda_exec.js" "$CONFIG_PATH"
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
# CreateGovernmentTokenAndDAO
|
||||||
|
|
||||||
|
## RU
|
||||||
|
|
||||||
|
Единый набор скриптов для:
|
||||||
|
1. создания governance token,
|
||||||
|
2. выдачи/сжигания membership токенов,
|
||||||
|
3. передачи прав на Governance PDA,
|
||||||
|
4. создания DAO (Realm/Governance/Treasury).
|
||||||
|
|
||||||
|
### Важная структура ключей
|
||||||
|
|
||||||
|
Используются две папки:
|
||||||
|
- `keypairs/dao_creator/` — ключ инициатора DAO и плательщика (ровно 1 `*.json`).
|
||||||
|
- `keypairs/government_token/` — ключ mint governance token (ровно 1 `*.json`).
|
||||||
|
|
||||||
|
Скрипты автоматически берут единственный файл из этих папок.
|
||||||
|
Если в папке `government_token` 0 файлов или больше 1 — скрипт завершится ошибкой.
|
||||||
|
|
||||||
|
### Скрипты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./01_create_governance_token.sh
|
||||||
|
./02_mint_token_to_wallet.sh <WALLET>
|
||||||
|
./03_force_burn_from_wallet.sh <WALLET>
|
||||||
|
./04_create_dao.sh
|
||||||
|
./05_transfer_rights_to_governance_pda.sh
|
||||||
|
./grind_vanity_mint.sh [PREFIX] [COUNT] [ignore-case]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Базовый порядок
|
||||||
|
|
||||||
|
1. (Опционально) `grind_vanity_mint.sh`, затем ОБЯЗАТЕЛЬНО скопировать выбранный json в `keypairs/government_token/`.
|
||||||
|
Пример:
|
||||||
|
```bash
|
||||||
|
cp ./runs/<FOUND_KEYPAIR>.json ./keypairs/government_token/selected_mint.json
|
||||||
|
```
|
||||||
|
2. `01_create_governance_token.sh`
|
||||||
|
3. В `governance_token.config.env` указать `GT_MINT_ADDRESS`.
|
||||||
|
4. `02_mint_token_to_wallet.sh <WALLET>`
|
||||||
|
5. `03_force_burn_from_wallet.sh <WALLET>`
|
||||||
|
6. `04_create_dao.sh`
|
||||||
|
7. Внести полученный Governance PDA в `GT_GOVERNANCE_PDA`.
|
||||||
|
8. `05_transfer_rights_to_governance_pda.sh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EN
|
||||||
|
|
||||||
|
Unified scripts for:
|
||||||
|
1. governance token creation,
|
||||||
|
2. membership mint/burn,
|
||||||
|
3. authority transfer to Governance PDA,
|
||||||
|
4. DAO creation (Realm/Governance/Treasury).
|
||||||
|
|
||||||
|
### Required keypair layout
|
||||||
|
|
||||||
|
Two folders are used:
|
||||||
|
- `keypairs/dao_creator/` — DAO creator/payer keypair (exactly 1 `*.json`).
|
||||||
|
- `keypairs/government_token/` — governance token mint keypair (exactly 1 `*.json`).
|
||||||
|
|
||||||
|
Scripts auto-detect the single file in each folder.
|
||||||
|
If `government_token` has 0 files or more than 1 file, script fails with error.
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./01_create_governance_token.sh
|
||||||
|
./02_mint_token_to_wallet.sh <WALLET>
|
||||||
|
./03_force_burn_from_wallet.sh <WALLET>
|
||||||
|
./04_create_dao.sh
|
||||||
|
./05_transfer_rights_to_governance_pda.sh
|
||||||
|
./grind_vanity_mint.sh [PREFIX] [COUNT] [ignore-case]
|
||||||
|
```
|
||||||
19
shine-solana/shine/scripts/CreateGovernmentTokenAndDAO/grind_vanity_mint.sh
Executable file
19
shine-solana/shine/scripts/CreateGovernmentTokenAndDAO/grind_vanity_mint.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
#
|
||||||
|
# RU: Подбирает vanity mint keypair через `solana-keygen grind`.
|
||||||
|
# Параметры: [PREFIX] [COUNT] [ignore-case]
|
||||||
|
# EN: Finds vanity mint keypair using `solana-keygen grind`.
|
||||||
|
# Args: [PREFIX] [COUNT] [ignore-case]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_PATH="$SCRIPT_DIR/governance_token.config.env"
|
||||||
|
PREFIX="${1:-}"
|
||||||
|
COUNT="${2:-1}"
|
||||||
|
IGNORE_CASE="${3:-}"
|
||||||
|
|
||||||
|
if [[ -n "$PREFIX" ]]; then
|
||||||
|
node "$SCRIPT_DIR/js/grind_vanity_mint_exec.js" "$CONFIG_PATH" "$PREFIX" "$COUNT" "$IGNORE_CASE"
|
||||||
|
else
|
||||||
|
node "$SCRIPT_DIR/js/grind_vanity_mint_exec.js" "$CONFIG_PATH"
|
||||||
|
fi
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { Connection, SystemProgram, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
|
||||||
|
const { TOKEN_PROGRAM_ID, getMintLen, createInitializeMintInstruction } = require("@solana/spl-token");
|
||||||
|
const { parseEnvConfig, assertRequired, resolveConfigPath, loadKeypair, findSingleJsonFile, saveKeypair, parseCluster, nowStamp, ui, getOperatorKeypairFromConfig } = require("./_common");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
|
||||||
|
assertRequired(cfg, "GT_CLUSTER"); assertRequired(cfg, "GT_RUNS_DIR");
|
||||||
|
const operator = getOperatorKeypairFromConfig(cfg);
|
||||||
|
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
|
||||||
|
const gtDir = path.resolve(cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR || path.join(__dirname, "..", "keypairs", "government_token"));
|
||||||
|
fs.mkdirSync(gtDir, { recursive: true });
|
||||||
|
const mintKeypairPath = findSingleJsonFile(gtDir);
|
||||||
|
const mint = loadKeypair(mintKeypairPath);
|
||||||
|
const mintLen = getMintLen([]);
|
||||||
|
const rent = await connection.getMinimumBalanceForRentExemption(mintLen, "confirmed");
|
||||||
|
ui.title("=== Создание governance token (SPL classic) / Create governance token (SPL classic) ===");
|
||||||
|
const tx = new Transaction().add(
|
||||||
|
SystemProgram.createAccount({ fromPubkey: operator.publicKey, newAccountPubkey: mint.publicKey, space: mintLen, lamports: rent, programId: TOKEN_PROGRAM_ID }),
|
||||||
|
createInitializeMintInstruction(mint.publicKey, 0, operator.publicKey, operator.publicKey, TOKEN_PROGRAM_ID)
|
||||||
|
);
|
||||||
|
const sig = await sendAndConfirmTransaction(connection, tx, [operator, mint], { commitment: "confirmed" });
|
||||||
|
const runsDir = path.resolve(cfg.GT_RUNS_DIR); fs.mkdirSync(runsDir, { recursive: true });
|
||||||
|
const outMintPath = mintKeypairPath;
|
||||||
|
saveKeypair(outMintPath, mint);
|
||||||
|
fs.writeFileSync(path.join(runsDir, `${nowStamp()}_create_token.json`), JSON.stringify({ mint: mint.publicKey.toBase58(), txCreateMint: sig }, null, 2));
|
||||||
|
ui.ok(`OK: Mint ${mint.publicKey.toBase58()}`);
|
||||||
|
ui.info(`RU: Использован keypair: ${mintKeypairPath}`);
|
||||||
|
ui.info(`EN: Used keypair: ${mintKeypairPath}`);
|
||||||
|
ui.info(`RU: Вставьте этот mint в файл: ${path.resolve(__dirname, "..", "governance_token.config.env")}`);
|
||||||
|
ui.info(`RU: Строка: GT_MINT_ADDRESS="${mint.publicKey.toBase58()}"`);
|
||||||
|
ui.info(`EN: Put this mint into file: ${path.resolve(__dirname, "..", "governance_token.config.env")}`);
|
||||||
|
ui.info(`EN: Line: GT_MINT_ADDRESS="${mint.publicKey.toBase58()}"`);
|
||||||
|
ui.info(`Mint keypair: ${outMintPath}`);
|
||||||
|
ui.info(`Tx: ${sig}`);
|
||||||
|
}
|
||||||
|
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
|
||||||
|
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createMintToInstruction, getAccount } = require("@solana/spl-token");
|
||||||
|
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, ui, getMintPublicKeyFromConfig, getOperatorKeypairFromConfig } = require("./_common");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
|
||||||
|
const receiver = new PublicKey(process.argv[3]);
|
||||||
|
if (!process.argv[3]) throw new Error("Использование / Usage: node .../02...js <config.env> <receiver_wallet>");
|
||||||
|
assertRequired(cfg, "GT_CLUSTER");
|
||||||
|
const mint = getMintPublicKeyFromConfig(cfg);
|
||||||
|
const operator = getOperatorKeypairFromConfig(cfg);
|
||||||
|
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
|
||||||
|
const ata = getAssociatedTokenAddressSync(mint, receiver, false, TOKEN_2022_PROGRAM_ID);
|
||||||
|
const ataInfo = await connection.getAccountInfo(ata, "confirmed");
|
||||||
|
if (ataInfo) {
|
||||||
|
const tokenAcc = await getAccount(connection, ata, "confirmed", TOKEN_2022_PROGRAM_ID);
|
||||||
|
if (tokenAcc.amount >= 1n) {
|
||||||
|
throw new Error(
|
||||||
|
`На кошельке уже есть membership token / Wallet already has membership token. wallet=${receiver.toBase58()} amount=${tokenAcc.amount.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ix = [
|
||||||
|
createAssociatedTokenAccountIdempotentInstruction(operator.publicKey, ata, receiver, mint, TOKEN_2022_PROGRAM_ID),
|
||||||
|
createMintToInstruction(mint, ata, operator.publicKey, 1n, [], TOKEN_2022_PROGRAM_ID),
|
||||||
|
];
|
||||||
|
ui.title("=== Выпуск 1 membership токена / Mint 1 membership token ===");
|
||||||
|
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ix), [operator], { commitment: "confirmed" });
|
||||||
|
ui.ok("Успешно / Success");
|
||||||
|
ui.info(`Mint: ${mint.toBase58()}`); ui.info(`Wallet: ${receiver.toBase58()}`); ui.info(`Tx: ${sig}`);
|
||||||
|
}
|
||||||
|
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
|
||||||
|
const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createBurnCheckedInstruction } = require("@solana/spl-token");
|
||||||
|
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, ui, getMintPublicKeyFromConfig, getOperatorKeypairFromConfig } = require("./_common");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
|
||||||
|
const targetOwner = new PublicKey(process.argv[3]);
|
||||||
|
if (!process.argv[3]) throw new Error("Использование / Usage: node .../03...js <config.env> <target_owner_wallet>");
|
||||||
|
assertRequired(cfg, "GT_CLUSTER");
|
||||||
|
const mint = getMintPublicKeyFromConfig(cfg);
|
||||||
|
const operator = getOperatorKeypairFromConfig(cfg);
|
||||||
|
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
|
||||||
|
const targetAta = getAssociatedTokenAddressSync(mint, targetOwner, false, TOKEN_2022_PROGRAM_ID);
|
||||||
|
const ix = createBurnCheckedInstruction(targetAta, mint, operator.publicKey, 1n, 0, [], TOKEN_2022_PROGRAM_ID);
|
||||||
|
ui.title("=== Принудительное сжигание 1 токена / Force burn 1 token ===");
|
||||||
|
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [operator], { commitment: "confirmed" });
|
||||||
|
ui.ok("Успешно / Success");
|
||||||
|
ui.info(`Mint: ${mint.toBase58()}`); ui.info(`Wallet: ${targetOwner.toBase58()}`); ui.info(`Tx: ${sig}`);
|
||||||
|
}
|
||||||
|
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js");
|
||||||
|
const { TOKEN_2022_PROGRAM_ID, AuthorityType, createSetAuthorityInstruction } = require("@solana/spl-token");
|
||||||
|
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, askYes, ui, getMintPublicKeyFromConfig, getOperatorKeypairFromConfig } = require("./_common");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
|
||||||
|
assertRequired(cfg, "GT_CLUSTER"); assertRequired(cfg, "GT_GOVERNANCE_PDA");
|
||||||
|
const mint = getMintPublicKeyFromConfig(cfg);
|
||||||
|
const operator = getOperatorKeypairFromConfig(cfg);
|
||||||
|
const governancePda = new PublicKey(cfg.GT_GOVERNANCE_PDA);
|
||||||
|
const connection = new Connection(parseCluster(cfg.GT_CLUSTER), "confirmed");
|
||||||
|
ui.title("=== Передача прав DAO / Transfer rights to DAO ===");
|
||||||
|
ui.warn(`RU: Будут переданы права Mint/Freeze/PermanentDelegate от ${operator.publicKey.toBase58()} на ${governancePda.toBase58()}`);
|
||||||
|
ui.warn(`EN: Mint/Freeze/PermanentDelegate authorities will be transferred to governance PDA.`);
|
||||||
|
const ok = await askYes("Введите yes / Type yes to continue: ");
|
||||||
|
if (!ok) return ui.warn("Отменено / Cancelled");
|
||||||
|
const ixs = [
|
||||||
|
createSetAuthorityInstruction(mint, operator.publicKey, AuthorityType.MintTokens, governancePda, [], TOKEN_2022_PROGRAM_ID),
|
||||||
|
createSetAuthorityInstruction(mint, operator.publicKey, AuthorityType.FreezeAccount, governancePda, [], TOKEN_2022_PROGRAM_ID),
|
||||||
|
createSetAuthorityInstruction(mint, operator.publicKey, AuthorityType.PermanentDelegate, governancePda, [], TOKEN_2022_PROGRAM_ID),
|
||||||
|
];
|
||||||
|
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ixs), [operator], { commitment: "confirmed" });
|
||||||
|
ui.ok("Успешно / Success");
|
||||||
|
ui.info(`Mint: ${mint.toBase58()}`); ui.info(`DAO PDA: ${governancePda.toBase58()}`); ui.info(`Tx: ${sig}`);
|
||||||
|
}
|
||||||
|
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const BN = require("bn.js");
|
||||||
|
const {
|
||||||
|
Connection,
|
||||||
|
PublicKey,
|
||||||
|
Transaction,
|
||||||
|
sendAndConfirmTransaction,
|
||||||
|
} = require("@solana/web3.js");
|
||||||
|
const {
|
||||||
|
getAssociatedTokenAddressSync,
|
||||||
|
TOKEN_2022_PROGRAM_ID,
|
||||||
|
TOKEN_PROGRAM_ID,
|
||||||
|
} = require("@solana/spl-token");
|
||||||
|
const {
|
||||||
|
MintMaxVoteWeightSource,
|
||||||
|
VoteThreshold,
|
||||||
|
VoteThresholdType,
|
||||||
|
VoteTipping,
|
||||||
|
GovernanceConfig,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
GoverningTokenConfigAccountArgs,
|
||||||
|
GoverningTokenType,
|
||||||
|
withCreateRealm,
|
||||||
|
withDepositGoverningTokens,
|
||||||
|
withCreateGovernance,
|
||||||
|
withCreateNativeTreasury,
|
||||||
|
withSetRealmAuthority,
|
||||||
|
SetRealmAuthorityAction,
|
||||||
|
} = require("@solana/spl-governance");
|
||||||
|
const { parseEnvConfig, assertRequired, resolveConfigPath, parseCluster, nowStamp, getOperatorKeypairFromConfig, getMintPublicKeyFromConfig, ui } = require("./_common");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const configPath = resolveConfigPath(process.argv[2]);
|
||||||
|
const cfg = parseEnvConfig(configPath);
|
||||||
|
[
|
||||||
|
"GT_CLUSTER", "DAO_REALM_NAME", "SPL_GOVERNANCE_PROGRAM_ID", "DAO_VOTING_TIME_SEC", "DAO_APPROVAL_THRESHOLD_PERCENT"
|
||||||
|
].forEach((k) => assertRequired(cfg, k));
|
||||||
|
|
||||||
|
const cluster = cfg.GT_CLUSTER;
|
||||||
|
const connection = new Connection(parseCluster(cluster), "confirmed");
|
||||||
|
const operator = getOperatorKeypairFromConfig(cfg);
|
||||||
|
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
|
||||||
|
const mint = getMintPublicKeyFromConfig(cfg);
|
||||||
|
const votingTimeSec = Number(cfg.DAO_VOTING_TIME_SEC);
|
||||||
|
const thresholdPct = Number(cfg.DAO_APPROVAL_THRESHOLD_PERCENT);
|
||||||
|
const runsDir = path.resolve(cfg.DAO_RUNS_DIR || path.join(__dirname, "runs"));
|
||||||
|
fs.mkdirSync(runsDir, { recursive: true });
|
||||||
|
|
||||||
|
const mintAi = await connection.getAccountInfo(mint, "confirmed");
|
||||||
|
if (!mintAi) throw new Error(`Governing mint not found: ${mint.toBase58()}`);
|
||||||
|
if (!mintAi.owner.equals(TOKEN_PROGRAM_ID)) {
|
||||||
|
throw new Error(
|
||||||
|
`Этот CreateDAO ожидает governing mint под классическим SPL Token (${TOKEN_PROGRAM_ID.toBase58()}). ` +
|
||||||
|
`Текущий mint owner: ${mintAi.owner.toBase58()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [realmPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[Buffer.from("governance"), Buffer.from(cfg.DAO_REALM_NAME, "utf8")],
|
||||||
|
governanceProgramId
|
||||||
|
);
|
||||||
|
const realmExists = (await connection.getAccountInfo(realmPda)) !== null;
|
||||||
|
if (realmExists) throw new Error(`Realm already exists: ${realmPda.toBase58()}`);
|
||||||
|
|
||||||
|
const ownerAtaToken2022 = getAssociatedTokenAddressSync(mint, operator.publicKey, false, TOKEN_2022_PROGRAM_ID);
|
||||||
|
const ownerAtaToken = getAssociatedTokenAddressSync(mint, operator.publicKey, false, TOKEN_PROGRAM_ID);
|
||||||
|
let ownerAta = ownerAtaToken2022;
|
||||||
|
let ownerAtaInfo = await connection.getAccountInfo(ownerAtaToken2022, "confirmed");
|
||||||
|
let tokenProgramId = TOKEN_2022_PROGRAM_ID;
|
||||||
|
if (!ownerAtaInfo) {
|
||||||
|
ownerAta = ownerAtaToken;
|
||||||
|
ownerAtaInfo = await connection.getAccountInfo(ownerAtaToken, "confirmed");
|
||||||
|
tokenProgramId = TOKEN_PROGRAM_ID;
|
||||||
|
}
|
||||||
|
if (!ownerAtaInfo) throw new Error("Operator ATA for governing mint not found. Mint at least 1 token to operator first.");
|
||||||
|
|
||||||
|
const programVersion = PROGRAM_VERSION_V3;
|
||||||
|
const ixRealm = [];
|
||||||
|
const communityTokenConfig = new GoverningTokenConfigAccountArgs({
|
||||||
|
voterWeightAddin: undefined,
|
||||||
|
maxVoterWeightAddin: undefined,
|
||||||
|
tokenType: GoverningTokenType.Membership,
|
||||||
|
});
|
||||||
|
const realmPk = await withCreateRealm(
|
||||||
|
ixRealm,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
cfg.DAO_REALM_NAME,
|
||||||
|
operator.publicKey,
|
||||||
|
mint,
|
||||||
|
operator.publicKey,
|
||||||
|
undefined,
|
||||||
|
MintMaxVoteWeightSource.FULL_SUPPLY_FRACTION,
|
||||||
|
new BN(1),
|
||||||
|
communityTokenConfig,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const sigRealm = await sendAndConfirmTransaction(connection, new Transaction().add(...ixRealm), [operator], { commitment: "confirmed" });
|
||||||
|
|
||||||
|
const ixDeposit = [];
|
||||||
|
const tokenOwnerRecordPk = await withDepositGoverningTokens(
|
||||||
|
ixDeposit,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
realmPk,
|
||||||
|
ownerAta,
|
||||||
|
mint,
|
||||||
|
operator.publicKey,
|
||||||
|
operator.publicKey,
|
||||||
|
operator.publicKey,
|
||||||
|
new BN(1),
|
||||||
|
true,
|
||||||
|
tokenProgramId
|
||||||
|
);
|
||||||
|
const sigDeposit = await sendAndConfirmTransaction(connection, new Transaction().add(...ixDeposit), [operator], { commitment: "confirmed" });
|
||||||
|
|
||||||
|
const governanceConfig = new GovernanceConfig({
|
||||||
|
communityVoteThreshold: new VoteThreshold({ type: VoteThresholdType.YesVotePercentage, value: thresholdPct }),
|
||||||
|
minCommunityTokensToCreateProposal: new BN(1),
|
||||||
|
minInstructionHoldUpTime: 0,
|
||||||
|
baseVotingTime: votingTimeSec,
|
||||||
|
communityVoteTipping: VoteTipping.Early,
|
||||||
|
minCouncilTokensToCreateProposal: new BN(0),
|
||||||
|
councilVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
|
||||||
|
councilVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
|
||||||
|
communityVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
|
||||||
|
councilVoteTipping: VoteTipping.Disabled,
|
||||||
|
votingCoolOffTime: 0,
|
||||||
|
depositExemptProposalCount: 0,
|
||||||
|
});
|
||||||
|
const ixGov = [];
|
||||||
|
const governancePk = await withCreateGovernance(
|
||||||
|
ixGov,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
realmPk,
|
||||||
|
realmPk,
|
||||||
|
governanceConfig,
|
||||||
|
tokenOwnerRecordPk,
|
||||||
|
operator.publicKey,
|
||||||
|
operator.publicKey
|
||||||
|
);
|
||||||
|
const treasuryPk = await withCreateNativeTreasury(ixGov, governanceProgramId, programVersion, governancePk, operator.publicKey);
|
||||||
|
const sigGov = await sendAndConfirmTransaction(connection, new Transaction().add(...ixGov), [operator], { commitment: "confirmed" });
|
||||||
|
|
||||||
|
const ixRealmAuthority = [];
|
||||||
|
withSetRealmAuthority(
|
||||||
|
ixRealmAuthority,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
realmPk,
|
||||||
|
operator.publicKey,
|
||||||
|
governancePk,
|
||||||
|
SetRealmAuthorityAction.SetChecked
|
||||||
|
);
|
||||||
|
const sigSetRealmAuthority = await sendAndConfirmTransaction(connection, new Transaction().add(...ixRealmAuthority), [operator], { commitment: "confirmed" });
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cluster,
|
||||||
|
realmName: cfg.DAO_REALM_NAME,
|
||||||
|
governanceProgramId: governanceProgramId.toBase58(),
|
||||||
|
governingMint: mint.toBase58(),
|
||||||
|
operator: operator.publicKey.toBase58(),
|
||||||
|
realm: realmPk.toBase58(),
|
||||||
|
governance: governancePk.toBase58(),
|
||||||
|
nativeTreasury: treasuryPk.toBase58(),
|
||||||
|
tokenOwnerRecord: tokenOwnerRecordPk.toBase58(),
|
||||||
|
txRealm: sigRealm,
|
||||||
|
txDeposit: sigDeposit,
|
||||||
|
txGovernanceTreasury: sigGov,
|
||||||
|
txSetRealmAuthority: sigSetRealmAuthority,
|
||||||
|
};
|
||||||
|
const reportPath = path.join(runsDir, `${nowStamp()}_create_dao.json`);
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||||
|
|
||||||
|
ui.ok("DAO created successfully / DAO успешно создан");
|
||||||
|
ui.info(`Realm: ${realmPk.toBase58()}`);
|
||||||
|
ui.info(`Governance PDA: ${governancePk.toBase58()}`);
|
||||||
|
ui.info(`Treasury: ${treasuryPk.toBase58()}`);
|
||||||
|
ui.info(`Report: ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("CreateDAO error:", e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
const { Keypair, PublicKey, clusterApiUrl } = require("@solana/web3.js");
|
||||||
|
|
||||||
|
function parseEnvConfig(configPath) {
|
||||||
|
const raw = fs.readFileSync(configPath, "utf8");
|
||||||
|
const out = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let val = trimmed.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
||||||
|
val = val.replace(/\$HOME/g, process.env.HOME || "");
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRequired(cfg, key) {
|
||||||
|
if (!cfg[key]) throw new Error(`В конфиге отсутствует обязательный параметр / Missing config key: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(argvPath) {
|
||||||
|
return argvPath ? path.resolve(argvPath) : path.resolve(__dirname, "..", "governance_token.config.env");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeypair(filePath) {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
return Keypair.fromSecretKey(Uint8Array.from(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSingleJsonFile(dirPath) {
|
||||||
|
const abs = path.resolve(dirPath);
|
||||||
|
if (!fs.existsSync(abs)) throw new Error(`Папка не найдена / Directory not found: ${abs}`);
|
||||||
|
const files = fs.readdirSync(abs).filter((f) => {
|
||||||
|
const p = path.join(abs, f);
|
||||||
|
return fs.statSync(p).isFile() && f.endsWith(".json");
|
||||||
|
});
|
||||||
|
if (files.length !== 1) {
|
||||||
|
throw new Error(`В папке должен быть ровно 1 json-файл / Directory must contain exactly 1 json file: ${abs}. Сейчас: ${files.length}`);
|
||||||
|
}
|
||||||
|
return path.join(abs, files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveKeypair(filePath, keypair) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(Array.from(keypair.secretKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCluster(cluster) {
|
||||||
|
if (cluster === "devnet" || cluster === "mainnet-beta" || cluster === "testnet") return clusterApiUrl(cluster);
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowStamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askYes(prompt) {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise((resolve) => rl.question(prompt, resolve));
|
||||||
|
rl.close();
|
||||||
|
return answer.trim() === "yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function colors(s, code) { return `\x1b[${code}m${s}\x1b[0m`; }
|
||||||
|
const ui = {
|
||||||
|
info: (s) => console.log(colors(s, "36")),
|
||||||
|
ok: (s) => console.log(colors(s, "32")),
|
||||||
|
warn: (s) => console.log(colors(s, "33")),
|
||||||
|
err: (s) => console.log(colors(s, "31")),
|
||||||
|
title: (s) => console.log(colors(s, "1;35")),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMintPublicKeyFromConfig(cfg) {
|
||||||
|
if (cfg.GT_MINT_ADDRESS && cfg.GT_MINT_ADDRESS.trim()) return new PublicKey(cfg.GT_MINT_ADDRESS.trim());
|
||||||
|
if (cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR && cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR.trim()) {
|
||||||
|
const kpPath = findSingleJsonFile(path.resolve(cfg.GT_GOVERNMENT_TOKEN_KEYPAIR_DIR));
|
||||||
|
return loadKeypair(kpPath).publicKey;
|
||||||
|
}
|
||||||
|
if (cfg.GT_MINT_KEYPAIR_PATH && cfg.GT_MINT_KEYPAIR_PATH.trim()) return loadKeypair(path.resolve(cfg.GT_MINT_KEYPAIR_PATH)).publicKey;
|
||||||
|
throw new Error("Не задан mint: укажите GT_MINT_ADDRESS или положите 1 keypair в GT_GOVERNMENT_TOKEN_KEYPAIR_DIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperatorKeypairFromConfig(cfg) {
|
||||||
|
if (cfg.GT_DAO_CREATOR_KEYPAIR_DIR && cfg.GT_DAO_CREATOR_KEYPAIR_DIR.trim()) {
|
||||||
|
const kpPath = findSingleJsonFile(path.resolve(cfg.GT_DAO_CREATOR_KEYPAIR_DIR));
|
||||||
|
return loadKeypair(kpPath);
|
||||||
|
}
|
||||||
|
if (cfg.GT_OPERATOR_KEYPAIR_PATH && cfg.GT_OPERATOR_KEYPAIR_PATH.trim()) {
|
||||||
|
return loadKeypair(path.resolve(cfg.GT_OPERATOR_KEYPAIR_PATH));
|
||||||
|
}
|
||||||
|
throw new Error("Не задан ключ оператора: укажите GT_DAO_CREATOR_KEYPAIR_DIR или GT_OPERATOR_KEYPAIR_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseEnvConfig,
|
||||||
|
assertRequired,
|
||||||
|
resolveConfigPath,
|
||||||
|
loadKeypair,
|
||||||
|
findSingleJsonFile,
|
||||||
|
saveKeypair,
|
||||||
|
parseCluster,
|
||||||
|
nowStamp,
|
||||||
|
askYes,
|
||||||
|
ui,
|
||||||
|
getMintPublicKeyFromConfig,
|
||||||
|
getOperatorKeypairFromConfig,
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const { parseEnvConfig, resolveConfigPath, nowStamp, ui } = require("./_common");
|
||||||
|
const DEFAULT_PREFIX = "SHi";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cfg = parseEnvConfig(resolveConfigPath(process.argv[2]));
|
||||||
|
const runsDir = path.resolve(cfg.GT_RUNS_DIR || path.join(__dirname, "..", "runs"));
|
||||||
|
fs.mkdirSync(runsDir, { recursive: true });
|
||||||
|
const prefix = process.argv[3] || cfg.GT_VANITY_PREFIX || DEFAULT_PREFIX;
|
||||||
|
if (!/^[1-9A-HJ-NP-Za-km-z]+$/.test(prefix)) throw new Error("Префикс Base58 без 0/O/I/l");
|
||||||
|
ui.title("=== Vanity подбор mint keypair / Vanity mint keypair grind ===");
|
||||||
|
ui.info(`Prefix: ${prefix}`);
|
||||||
|
const args = ["grind", "--starts-with", `${prefix}:1`];
|
||||||
|
const p = spawn("solana-keygen", args, { cwd: runsDir, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const lines = [];
|
||||||
|
const on = (d) => {
|
||||||
|
for (const l of String(d).split("\n")) {
|
||||||
|
const line = l.trim(); if (!line) continue;
|
||||||
|
lines.push(line); console.log(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
p.stdout.on("data", on); p.stderr.on("data", on);
|
||||||
|
const code = await new Promise((resolve) => p.on("close", resolve));
|
||||||
|
if (code !== 0) throw new Error(`solana-keygen grind exit code ${code}`);
|
||||||
|
const rp = path.join(runsDir, `${nowStamp()}_vanity_grind_report.json`);
|
||||||
|
fs.writeFileSync(rp, JSON.stringify({ createdAt: new Date().toISOString(), prefix, command: `solana-keygen ${args.join(" ")}`, outputLog: lines }, null, 2));
|
||||||
|
ui.ok("Готово / Done");
|
||||||
|
ui.info(`Report: ${rp}`);
|
||||||
|
ui.info(`RU: Скопируйте выбранный keypair из runs в keypairs/government_token/ (один json-файл).`);
|
||||||
|
ui.info(`EN: Copy selected keypair from runs to keypairs/government_token/ (single json file).`);
|
||||||
|
}
|
||||||
|
main().catch((e) => { ui.err(`Ошибка / Error: ${e?.message || e}`); process.exit(1); });
|
||||||
60
shine-solana/shine/scripts/dao/README.md
Normal file
60
shine-solana/shine/scripts/dao/README.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# DAO scripts (актуальные)
|
||||||
|
|
||||||
|
## 1) Проверка конфигурации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/dao/create_realm_dao_full_test.sh scripts/dao/dao.config.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Реальное создание FULL DAO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/dao/create_realm_dao_full_build_exec.js scripts/dao/dao.config.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Что делает:
|
||||||
|
|
||||||
|
1. Создает governance mint (SPL, decimals=0, supply из конфига).
|
||||||
|
2. Добавляет on-chain metadata для mint (URI и картинка из Arweave).
|
||||||
|
3. Создает Realm / Governance / Native Treasury.
|
||||||
|
4. Депозитит governance токены в Realm.
|
||||||
|
5. Пишет отчеты в `scripts/dao/runs/*.json` и `*.txt`.
|
||||||
|
|
||||||
|
## 3) Revoke/Burn membership токенов
|
||||||
|
|
||||||
|
### Вариант A (рекомендуется): через DAO голосование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/dao/propose_vote_execute_revoke_full_exec.js \
|
||||||
|
scripts/dao/dao.config.env \
|
||||||
|
<REALM_PUBKEY> \
|
||||||
|
<GOVERNANCE_PUBKEY> \
|
||||||
|
<MINT_PUBKEY> \
|
||||||
|
<TARGET_OWNER_PUBKEY> \
|
||||||
|
[AMOUNT]
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт делает полный цикл:
|
||||||
|
|
||||||
|
1. `create proposal`
|
||||||
|
2. `insert revoke instruction`
|
||||||
|
3. `sign off`
|
||||||
|
4. `cast vote`
|
||||||
|
5. `execute`
|
||||||
|
|
||||||
|
### Вариант B (технический/админский): прямой revoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/dao/revoke_member_token_full_exec.js \
|
||||||
|
scripts/dao/dao.config.env \
|
||||||
|
<REALM_PUBKEY> \
|
||||||
|
<MINT_PUBKEY> \
|
||||||
|
<TARGET_OWNER_PUBKEY> \
|
||||||
|
[AMOUNT]
|
||||||
|
```
|
||||||
|
|
||||||
|
Важное:
|
||||||
|
|
||||||
|
1. Для `RevokeGoverningTokens` токен должен быть membership-типом (в full-скрипте это уже так).
|
||||||
|
2. Для сценария “только DAO голосованием” используйте вариант A.
|
||||||
|
3. Вариант B оставлен как технический инструмент.
|
||||||
456
shine-solana/shine/scripts/dao/create_realm_dao_full_build_exec.js
Executable file
456
shine-solana/shine/scripts/dao/create_realm_dao_full_build_exec.js
Executable file
@ -0,0 +1,456 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
const BN = require("bn.js");
|
||||||
|
const {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
PublicKey,
|
||||||
|
SystemProgram,
|
||||||
|
Transaction,
|
||||||
|
sendAndConfirmTransaction,
|
||||||
|
clusterApiUrl,
|
||||||
|
} = require("@solana/web3.js");
|
||||||
|
const {
|
||||||
|
TOKEN_PROGRAM_ID,
|
||||||
|
AuthorityType,
|
||||||
|
getMintLen,
|
||||||
|
createInitializeMintInstruction,
|
||||||
|
getAssociatedTokenAddressSync,
|
||||||
|
createAssociatedTokenAccountIdempotentInstruction,
|
||||||
|
createMintToInstruction,
|
||||||
|
createSetAuthorityInstruction,
|
||||||
|
} = require("@solana/spl-token");
|
||||||
|
const {
|
||||||
|
MintMaxVoteWeightSource,
|
||||||
|
VoteThreshold,
|
||||||
|
VoteThresholdType,
|
||||||
|
VoteTipping,
|
||||||
|
GovernanceConfig,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
GoverningTokenConfigAccountArgs,
|
||||||
|
GoverningTokenType,
|
||||||
|
withCreateRealm,
|
||||||
|
withDepositGoverningTokens,
|
||||||
|
withCreateGovernance,
|
||||||
|
withCreateNativeTreasury,
|
||||||
|
withSetRealmAuthority,
|
||||||
|
SetRealmAuthorityAction,
|
||||||
|
} = require("@solana/spl-governance");
|
||||||
|
const { createUmi } = require("@metaplex-foundation/umi-bundle-defaults");
|
||||||
|
const {
|
||||||
|
createSignerFromKeypair,
|
||||||
|
signerIdentity,
|
||||||
|
percentAmount,
|
||||||
|
none,
|
||||||
|
some,
|
||||||
|
} = require("@metaplex-foundation/umi");
|
||||||
|
const { fromWeb3JsKeypair, fromWeb3JsPublicKey } = require("@metaplex-foundation/umi-web3js-adapters");
|
||||||
|
const { mplTokenMetadata, createV1, TokenStandard } = require("@metaplex-foundation/mpl-token-metadata");
|
||||||
|
|
||||||
|
function parseEnvConfig(configPath) {
|
||||||
|
const raw = fs.readFileSync(configPath, "utf8");
|
||||||
|
const out = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let val = trimmed.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
val = val.replace(/\$HOME/g, process.env.HOME || "");
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRequired(cfg, key) {
|
||||||
|
if (!cfg[key]) throw new Error(`В конфиге отсутствует обязательный параметр: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeypair(filePath) {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
return Keypair.fromSecretKey(Uint8Array.from(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lamportsToSol(lamports) {
|
||||||
|
return Number(lamports) / 1_000_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowStamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(
|
||||||
|
d.getMinutes()
|
||||||
|
)}-${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askYes() {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise((resolve) =>
|
||||||
|
rl.question("Введите YES для реального создания ПОЛНОГО DAO: ", resolve)
|
||||||
|
);
|
||||||
|
rl.close();
|
||||||
|
return answer.trim() === "YES";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureArweaveUri(name, uri) {
|
||||||
|
if (!uri) throw new Error(`${name} пустой`);
|
||||||
|
if (!(uri.startsWith("https://arweave.net/") || uri.startsWith("ar://"))) {
|
||||||
|
throw new Error(`${name} должен указывать на Arweave (https://arweave.net/... или ar://...)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachTokenMetadataViaUmi(cfg, cluster, issuer, mintPubkey, mintKeypair) {
|
||||||
|
ensureArweaveUri("DAO_GOV_TOKEN_METADATA_URI", cfg.DAO_GOV_TOKEN_METADATA_URI);
|
||||||
|
ensureArweaveUri("DAO_GOV_TOKEN_IMAGE_URL", cfg.DAO_GOV_TOKEN_IMAGE_URL);
|
||||||
|
|
||||||
|
const umi = createUmi(clusterApiUrl(cluster));
|
||||||
|
const umiSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(issuer));
|
||||||
|
const umiMintSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(mintKeypair));
|
||||||
|
umi.use(signerIdentity(umiSigner));
|
||||||
|
umi.use(mplTokenMetadata());
|
||||||
|
|
||||||
|
const builder = createV1(umi, {
|
||||||
|
mint: umiMintSigner,
|
||||||
|
authority: umiSigner,
|
||||||
|
payer: umiSigner,
|
||||||
|
updateAuthority: umiSigner,
|
||||||
|
name: cfg.DAO_GOV_NFT_NAME,
|
||||||
|
symbol: cfg.DAO_GOV_NFT_SYMBOL,
|
||||||
|
uri: cfg.DAO_GOV_TOKEN_METADATA_URI,
|
||||||
|
sellerFeeBasisPoints: percentAmount(0),
|
||||||
|
tokenStandard: TokenStandard.Fungible,
|
||||||
|
decimals: some(0),
|
||||||
|
creators: none(),
|
||||||
|
collection: none(),
|
||||||
|
uses: none(),
|
||||||
|
collectionDetails: none(),
|
||||||
|
ruleSet: none(),
|
||||||
|
printSupply: none(),
|
||||||
|
primarySaleHappened: false,
|
||||||
|
isMutable: true,
|
||||||
|
isCollection: false,
|
||||||
|
splTokenProgram: fromWeb3JsPublicKey(TOKEN_PROGRAM_ID),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await builder.sendAndConfirm(umi);
|
||||||
|
const sig = Buffer.from(res.signature).toString("base64");
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const configPath = process.argv[2]
|
||||||
|
? path.resolve(process.argv[2])
|
||||||
|
: path.resolve(__dirname, "dao.config.env");
|
||||||
|
if (!fs.existsSync(configPath)) throw new Error(`Конфиг не найден: ${configPath}`);
|
||||||
|
|
||||||
|
const cfg = parseEnvConfig(configPath);
|
||||||
|
[
|
||||||
|
"DAO_CLUSTER",
|
||||||
|
"DAO_REALM_NAME",
|
||||||
|
"DAO_GOV_NFT_NAME",
|
||||||
|
"DAO_GOV_NFT_SYMBOL",
|
||||||
|
"DAO_GOV_NFT_SUPPLY",
|
||||||
|
"DAO_VOTING_TIME_SEC",
|
||||||
|
"DAO_APPROVAL_THRESHOLD_PERCENT",
|
||||||
|
"DAO_ISSUER_KEYPAIR",
|
||||||
|
"SPL_GOVERNANCE_PROGRAM_ID",
|
||||||
|
"DAO_GOV_TOKEN_METADATA_URI",
|
||||||
|
"DAO_GOV_TOKEN_IMAGE_URL",
|
||||||
|
].forEach((k) => assertRequired(cfg, k));
|
||||||
|
|
||||||
|
const cluster = cfg.DAO_CLUSTER;
|
||||||
|
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
|
||||||
|
const issuer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR));
|
||||||
|
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
|
||||||
|
|
||||||
|
const supply = Number(cfg.DAO_GOV_NFT_SUPPLY);
|
||||||
|
const votingTimeSec = Number(cfg.DAO_VOTING_TIME_SEC);
|
||||||
|
const thresholdPct = Number(cfg.DAO_APPROVAL_THRESHOLD_PERCENT);
|
||||||
|
if (!Number.isInteger(supply) || supply <= 0) throw new Error("DAO_GOV_NFT_SUPPLY должен быть целым > 0");
|
||||||
|
if (!Number.isInteger(votingTimeSec) || votingTimeSec < 3600)
|
||||||
|
throw new Error("DAO_VOTING_TIME_SEC должен быть >= 3600 (ограничение Realms)");
|
||||||
|
if (!Number.isInteger(thresholdPct) || thresholdPct < 51 || thresholdPct > 100)
|
||||||
|
throw new Error("DAO_APPROVAL_THRESHOLD_PERCENT должен быть в диапазоне 51..100");
|
||||||
|
|
||||||
|
const [realmPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[Buffer.from("governance"), Buffer.from(cfg.DAO_REALM_NAME, "utf8")],
|
||||||
|
governanceProgramId
|
||||||
|
);
|
||||||
|
const realmExists = (await connection.getAccountInfo(realmPda)) !== null;
|
||||||
|
if (realmExists) throw new Error(`Realm уже существует: ${realmPda.toBase58()}`);
|
||||||
|
|
||||||
|
const startBalance = await connection.getBalance(issuer.publicKey, "confirmed");
|
||||||
|
console.log("============================================================");
|
||||||
|
console.log("СОЗДАНИЕ DAO (FULL)");
|
||||||
|
console.log("------------------------------------------------------------");
|
||||||
|
console.log("Сеть: ", cluster);
|
||||||
|
console.log("Realm name: ", cfg.DAO_REALM_NAME);
|
||||||
|
console.log("Realm PDA: ", realmPda.toBase58());
|
||||||
|
console.log("Governance program: ", governanceProgramId.toBase58());
|
||||||
|
console.log("Issuer: ", issuer.publicKey.toBase58());
|
||||||
|
console.log("Баланс до старта: ", `${lamportsToSol(startBalance)} SOL`);
|
||||||
|
console.log("Token name/symbol: ", `${cfg.DAO_GOV_NFT_NAME} / ${cfg.DAO_GOV_NFT_SYMBOL}`);
|
||||||
|
console.log("Token supply: ", supply);
|
||||||
|
console.log("Voting time sec: ", votingTimeSec);
|
||||||
|
console.log("Threshold %: ", thresholdPct);
|
||||||
|
console.log("Arweave metadata URI:", cfg.DAO_GOV_TOKEN_METADATA_URI);
|
||||||
|
console.log("Arweave image URL: ", cfg.DAO_GOV_TOKEN_IMAGE_URL);
|
||||||
|
console.log("============================================================");
|
||||||
|
|
||||||
|
const ok = await askYes();
|
||||||
|
if (!ok) {
|
||||||
|
console.log("Отменено пользователем.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mintKeypair = Keypair.generate();
|
||||||
|
const mintLen = getMintLen([]);
|
||||||
|
const mintRent = await connection.getMinimumBalanceForRentExemption(mintLen);
|
||||||
|
const issuerAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, issuer.publicKey, false, TOKEN_PROGRAM_ID);
|
||||||
|
|
||||||
|
const txMint = new Transaction().add(
|
||||||
|
SystemProgram.createAccount({
|
||||||
|
fromPubkey: issuer.publicKey,
|
||||||
|
newAccountPubkey: mintKeypair.publicKey,
|
||||||
|
space: mintLen,
|
||||||
|
lamports: mintRent,
|
||||||
|
programId: TOKEN_PROGRAM_ID,
|
||||||
|
}),
|
||||||
|
createInitializeMintInstruction(mintKeypair.publicKey, 0, issuer.publicKey, issuer.publicKey, TOKEN_PROGRAM_ID),
|
||||||
|
createAssociatedTokenAccountIdempotentInstruction(
|
||||||
|
issuer.publicKey,
|
||||||
|
issuerAta,
|
||||||
|
issuer.publicKey,
|
||||||
|
mintKeypair.publicKey,
|
||||||
|
TOKEN_PROGRAM_ID
|
||||||
|
),
|
||||||
|
createMintToInstruction(mintKeypair.publicKey, issuerAta, issuer.publicKey, supply, [], TOKEN_PROGRAM_ID)
|
||||||
|
);
|
||||||
|
const sigMint = await sendAndConfirmTransaction(connection, txMint, [issuer, mintKeypair], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sigMetadata = await attachTokenMetadataViaUmi(
|
||||||
|
cfg,
|
||||||
|
cluster,
|
||||||
|
issuer,
|
||||||
|
mintKeypair.publicKey,
|
||||||
|
mintKeypair
|
||||||
|
);
|
||||||
|
|
||||||
|
const programVersion = PROGRAM_VERSION_V3;
|
||||||
|
const ixRealm = [];
|
||||||
|
const communityTokenConfig = new GoverningTokenConfigAccountArgs({
|
||||||
|
voterWeightAddin: undefined,
|
||||||
|
maxVoterWeightAddin: undefined,
|
||||||
|
tokenType: GoverningTokenType.Membership,
|
||||||
|
});
|
||||||
|
const realmPk = await withCreateRealm(
|
||||||
|
ixRealm,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
cfg.DAO_REALM_NAME,
|
||||||
|
issuer.publicKey,
|
||||||
|
mintKeypair.publicKey,
|
||||||
|
issuer.publicKey,
|
||||||
|
undefined,
|
||||||
|
MintMaxVoteWeightSource.FULL_SUPPLY_FRACTION,
|
||||||
|
new BN(1),
|
||||||
|
communityTokenConfig,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const sigRealm = await sendAndConfirmTransaction(connection, new Transaction().add(...ixRealm), [issuer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ixDeposit = [];
|
||||||
|
const tokenOwnerRecordPk = await withDepositGoverningTokens(
|
||||||
|
ixDeposit,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
realmPk,
|
||||||
|
issuerAta,
|
||||||
|
mintKeypair.publicKey,
|
||||||
|
issuer.publicKey,
|
||||||
|
issuer.publicKey,
|
||||||
|
issuer.publicKey,
|
||||||
|
new BN(supply),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const sigDeposit = await sendAndConfirmTransaction(connection, new Transaction().add(...ixDeposit), [issuer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const governanceConfig = new GovernanceConfig({
|
||||||
|
communityVoteThreshold: new VoteThreshold({ type: VoteThresholdType.YesVotePercentage, value: thresholdPct }),
|
||||||
|
minCommunityTokensToCreateProposal: new BN(1),
|
||||||
|
minInstructionHoldUpTime: 0,
|
||||||
|
baseVotingTime: votingTimeSec,
|
||||||
|
communityVoteTipping: VoteTipping.Early,
|
||||||
|
minCouncilTokensToCreateProposal: new BN(0),
|
||||||
|
councilVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
|
||||||
|
councilVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
|
||||||
|
communityVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
|
||||||
|
councilVoteTipping: VoteTipping.Disabled,
|
||||||
|
votingCoolOffTime: 0,
|
||||||
|
depositExemptProposalCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ixGov = [];
|
||||||
|
const governancePk = await withCreateGovernance(
|
||||||
|
ixGov,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
realmPk,
|
||||||
|
realmPk,
|
||||||
|
governanceConfig,
|
||||||
|
tokenOwnerRecordPk,
|
||||||
|
issuer.publicKey,
|
||||||
|
issuer.publicKey
|
||||||
|
);
|
||||||
|
const treasuryPk = await withCreateNativeTreasury(ixGov, governanceProgramId, programVersion, governancePk, issuer.publicKey);
|
||||||
|
const sigGov = await sendAndConfirmTransaction(connection, new Transaction().add(...ixGov), [issuer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Для DAO revoke governing tokens mint authority должен быть у governance PDA.
|
||||||
|
const ixSetMintAuthority = [
|
||||||
|
createSetAuthorityInstruction(
|
||||||
|
mintKeypair.publicKey,
|
||||||
|
issuer.publicKey,
|
||||||
|
AuthorityType.MintTokens,
|
||||||
|
governancePk,
|
||||||
|
[],
|
||||||
|
TOKEN_PROGRAM_ID
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const sigSetMintAuthority = await sendAndConfirmTransaction(
|
||||||
|
connection,
|
||||||
|
new Transaction().add(...ixSetMintAuthority),
|
||||||
|
[issuer],
|
||||||
|
{ commitment: "confirmed" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ixRealmAuthority = [];
|
||||||
|
withSetRealmAuthority(
|
||||||
|
ixRealmAuthority,
|
||||||
|
governanceProgramId,
|
||||||
|
programVersion,
|
||||||
|
realmPk,
|
||||||
|
issuer.publicKey,
|
||||||
|
governancePk,
|
||||||
|
SetRealmAuthorityAction.SetChecked
|
||||||
|
);
|
||||||
|
const sigSetRealmAuthority = await sendAndConfirmTransaction(
|
||||||
|
connection,
|
||||||
|
new Transaction().add(...ixRealmAuthority),
|
||||||
|
[issuer],
|
||||||
|
{ commitment: "confirmed" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const endBalance = await connection.getBalance(issuer.publicKey, "confirmed");
|
||||||
|
const spentLamports = startBalance - endBalance;
|
||||||
|
const report = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cluster,
|
||||||
|
configPath,
|
||||||
|
realmName: cfg.DAO_REALM_NAME,
|
||||||
|
governanceProgramId: governanceProgramId.toBase58(),
|
||||||
|
issuer: issuer.publicKey.toBase58(),
|
||||||
|
communityMint: mintKeypair.publicKey.toBase58(),
|
||||||
|
issuerAta: issuerAta.toBase58(),
|
||||||
|
realm: realmPk.toBase58(),
|
||||||
|
tokenOwnerRecord: tokenOwnerRecordPk.toBase58(),
|
||||||
|
governance: governancePk.toBase58(),
|
||||||
|
nativeTreasury: treasuryPk.toBase58(),
|
||||||
|
metadataUri: cfg.DAO_GOV_TOKEN_METADATA_URI,
|
||||||
|
imageUrl: cfg.DAO_GOV_TOKEN_IMAGE_URL,
|
||||||
|
txMint: sigMint,
|
||||||
|
txMetadata: sigMetadata,
|
||||||
|
txRealm: sigRealm,
|
||||||
|
txDeposit: sigDeposit,
|
||||||
|
txGovernanceTreasury: sigGov,
|
||||||
|
txSetMintAuthorityToGovernance: sigSetMintAuthority,
|
||||||
|
txSetRealmAuthority: sigSetRealmAuthority,
|
||||||
|
votingTimeSec,
|
||||||
|
thresholdPercent: thresholdPct,
|
||||||
|
tokenSupply: supply,
|
||||||
|
startBalanceLamports: startBalance,
|
||||||
|
endBalanceLamports: endBalance,
|
||||||
|
spentLamports,
|
||||||
|
startBalanceSol: lamportsToSol(startBalance),
|
||||||
|
endBalanceSol: lamportsToSol(endBalance),
|
||||||
|
spentSol: lamportsToSol(spentLamports),
|
||||||
|
};
|
||||||
|
const reportDir = path.resolve(__dirname, "runs");
|
||||||
|
fs.mkdirSync(reportDir, { recursive: true });
|
||||||
|
const reportBaseName = `${nowStamp()}_${cfg.DAO_REALM_NAME.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80)}_full`;
|
||||||
|
const reportJsonPath = path.join(reportDir, `${reportBaseName}.json`);
|
||||||
|
const reportTxtPath = path.join(reportDir, `${reportBaseName}.txt`);
|
||||||
|
fs.writeFileSync(reportJsonPath, JSON.stringify(report, null, 2));
|
||||||
|
fs.writeFileSync(
|
||||||
|
reportTxtPath,
|
||||||
|
[
|
||||||
|
`createdAt: ${report.createdAt}`,
|
||||||
|
`cluster: ${report.cluster}`,
|
||||||
|
`realmName: ${report.realmName}`,
|
||||||
|
`governanceProgramId: ${report.governanceProgramId}`,
|
||||||
|
`issuer: ${report.issuer}`,
|
||||||
|
`communityMint: ${report.communityMint}`,
|
||||||
|
`issuerAta: ${report.issuerAta}`,
|
||||||
|
`realm: ${report.realm}`,
|
||||||
|
`tokenOwnerRecord: ${report.tokenOwnerRecord}`,
|
||||||
|
`governance: ${report.governance}`,
|
||||||
|
`nativeTreasury: ${report.nativeTreasury}`,
|
||||||
|
`metadataUri: ${report.metadataUri}`,
|
||||||
|
`imageUrl: ${report.imageUrl}`,
|
||||||
|
`txMint: ${report.txMint}`,
|
||||||
|
`txMetadata: ${report.txMetadata}`,
|
||||||
|
`txRealm: ${report.txRealm}`,
|
||||||
|
`txDeposit: ${report.txDeposit}`,
|
||||||
|
`txGovernanceTreasury: ${report.txGovernanceTreasury}`,
|
||||||
|
`txSetMintAuthorityToGovernance: ${report.txSetMintAuthorityToGovernance}`,
|
||||||
|
`txSetRealmAuthority: ${report.txSetRealmAuthority}`,
|
||||||
|
`tokenSupply: ${report.tokenSupply}`,
|
||||||
|
`votingTimeSec: ${report.votingTimeSec}`,
|
||||||
|
`thresholdPercent: ${report.thresholdPercent}`,
|
||||||
|
`startBalanceSol: ${report.startBalanceSol}`,
|
||||||
|
`endBalanceSol: ${report.endBalanceSol}`,
|
||||||
|
`spentSol: ${report.spentSol}`,
|
||||||
|
`configPath: ${report.configPath}`,
|
||||||
|
].join("\n") + "\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("============================================================");
|
||||||
|
console.log("DAO FULL СОЗДАНО");
|
||||||
|
console.log("------------------------------------------------------------");
|
||||||
|
console.log("Community mint (SPL + metadata): ", mintKeypair.publicKey.toBase58());
|
||||||
|
console.log("Realm: ", realmPk.toBase58());
|
||||||
|
console.log("Governance: ", governancePk.toBase58());
|
||||||
|
console.log("Native treasury PDA: ", treasuryPk.toBase58());
|
||||||
|
console.log("Tx mint: ", sigMint);
|
||||||
|
console.log("Tx metadata: ", sigMetadata);
|
||||||
|
console.log("Tx realm: ", sigRealm);
|
||||||
|
console.log("Tx deposit: ", sigDeposit);
|
||||||
|
console.log("Tx governance+treasury: ", sigGov);
|
||||||
|
console.log("Tx set mint authority -> governance: ", sigSetMintAuthority);
|
||||||
|
console.log("Tx set realm authority -> governance: ", sigSetRealmAuthority);
|
||||||
|
console.log("Баланс после: ", `${lamportsToSol(endBalance)} SOL`);
|
||||||
|
console.log("Потрачено: ", `${lamportsToSol(spentLamports)} SOL`);
|
||||||
|
console.log("Отчёт JSON: ", reportJsonPath);
|
||||||
|
console.log("Отчёт TXT: ", reportTxtPath);
|
||||||
|
console.log("============================================================");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Ошибка создания DAO FULL:", e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
106
shine-solana/shine/scripts/dao/create_realm_dao_full_test.sh
Executable file
106
shine-solana/shine/scripts/dao/create_realm_dao_full_test.sh
Executable file
@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_PATH="${1:-$SCRIPT_DIR/dao.config.env}"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||||
|
echo "Ошибка: не найден конфиг $CONFIG_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG_PATH"
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Ошибка: команда '$1' не найдена"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd solana
|
||||||
|
require_cmd solana-keygen
|
||||||
|
require_cmd node
|
||||||
|
|
||||||
|
if [[ -z "${DAO_REALM_NAME:-}" || -z "${DAO_CLUSTER:-}" || -z "${DAO_ISSUER_KEYPAIR:-}" || -z "${SPL_GOVERNANCE_PROGRAM_ID:-}" ]]; then
|
||||||
|
echo "Ошибка: обязательные поля конфига пустые"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$DAO_ISSUER_KEYPAIR" ]]; then
|
||||||
|
echo "Ошибка: keypair не найден: $DAO_ISSUER_KEYPAIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${DAO_REALM_NAME}" == *"TEMPLATE"* || "${DAO_REALM_NAME}" == *"CHANGE_ME"* ]]; then
|
||||||
|
echo "Ошибка: похоже, не заменили тестовое имя DAO_REALM_NAME"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "${DAO_VOTING_TIME_SEC}" =~ ^[0-9]+$ ]] || ! [[ "${DAO_GOV_NFT_SUPPLY}" =~ ^[0-9]+$ ]] || ! [[ "${DAO_APPROVAL_THRESHOLD_PERCENT}" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Ошибка: числовые параметры заданы некорректно"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( DAO_APPROVAL_THRESHOLD_PERCENT < 51 || DAO_APPROVAL_THRESHOLD_PERCENT > 100 )); then
|
||||||
|
echo "Ошибка: DAO_APPROVAL_THRESHOLD_PERCENT должен быть в диапазоне 51..100"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ISSUER_PUBKEY="$(solana-keygen pubkey "$DAO_ISSUER_KEYPAIR")"
|
||||||
|
ISSUER_BALANCE="$(solana balance "$ISSUER_PUBKEY" --url "$DAO_CLUSTER" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
REALM_PDA="$(node - "$DAO_REALM_NAME" "$SPL_GOVERNANCE_PROGRAM_ID" <<'NODE'
|
||||||
|
const { PublicKey } = require("@solana/web3.js");
|
||||||
|
const realmName = process.argv[2];
|
||||||
|
const programId = new PublicKey(process.argv[3]);
|
||||||
|
const [pda] = PublicKey.findProgramAddressSync(
|
||||||
|
[Buffer.from("governance"), Buffer.from(realmName, "utf8")],
|
||||||
|
programId
|
||||||
|
);
|
||||||
|
console.log(pda.toBase58());
|
||||||
|
NODE
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "$REALM_PDA" ]]; then
|
||||||
|
echo "Ошибка: не удалось вычислить PDA realm."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REALM_EXISTS="no"
|
||||||
|
if solana account "$REALM_PDA" --url "$DAO_CLUSTER" >/dev/null 2>&1; then
|
||||||
|
REALM_EXISTS="yes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
============================================================
|
||||||
|
ПРЕДСТАРТОВАЯ ПРОВЕРКА DAO (Realms)
|
||||||
|
------------------------------------------------------------
|
||||||
|
Сеть: $DAO_CLUSTER
|
||||||
|
Realm name: $DAO_REALM_NAME
|
||||||
|
Realm PDA: $REALM_PDA
|
||||||
|
Realm уже существует: $REALM_EXISTS
|
||||||
|
Governance program: $SPL_GOVERNANCE_PROGRAM_ID
|
||||||
|
Эмиттер (issuer): $ISSUER_PUBKEY
|
||||||
|
Баланс эмиттера: ${ISSUER_BALANCE:-unknown}
|
||||||
|
NFT name: $DAO_GOV_NFT_NAME
|
||||||
|
NFT symbol: $DAO_GOV_NFT_SYMBOL
|
||||||
|
NFT supply: $DAO_GOV_NFT_SUPPLY
|
||||||
|
Voting time (sec): $DAO_VOTING_TIME_SEC
|
||||||
|
Threshold %: $DAO_APPROVAL_THRESHOLD_PERCENT
|
||||||
|
Конфиг: $CONFIG_PATH
|
||||||
|
============================================================
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "$REALM_EXISTS" == "yes" ]]; then
|
||||||
|
echo "Стоп: realm с таким именем уже существует в этой сети."
|
||||||
|
echo "Поменяйте DAO_REALM_NAME в конфиге и запустите снова."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Проверка пройдена."
|
||||||
|
echo "Этот скрипт делает только preflight-валидацию."
|
||||||
|
echo "Для реального создания DAO запускайте исполняющий скрипт:"
|
||||||
|
echo "node scripts/dao/create_realm_dao_full_build_exec.js scripts/dao/dao.config.env"
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const BN = require("bn.js");
|
||||||
|
const { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction, clusterApiUrl } = require("@solana/web3.js");
|
||||||
|
const {
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
InstructionData,
|
||||||
|
AccountMetaData,
|
||||||
|
withRevokeGoverningTokens,
|
||||||
|
withExecuteTransaction,
|
||||||
|
} = require("@solana/spl-governance");
|
||||||
|
|
||||||
|
function parseEnvConfig(configPath) {
|
||||||
|
const raw = fs.readFileSync(configPath, "utf8");
|
||||||
|
const out = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let val = trimmed.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
val = val.replace(/\$HOME/g, process.env.HOME || "");
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeypair(filePath) {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
return Keypair.fromSecretKey(Uint8Array.from(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 main() {
|
||||||
|
const configPath = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, "dao.config.env");
|
||||||
|
const realm = new PublicKey(process.argv[3]);
|
||||||
|
const governance = new PublicKey(process.argv[4]);
|
||||||
|
const proposal = new PublicKey(process.argv[5]);
|
||||||
|
const proposalTx = new PublicKey(process.argv[6]);
|
||||||
|
const mint = new PublicKey(process.argv[7]);
|
||||||
|
const targetOwner = new PublicKey(process.argv[8]);
|
||||||
|
const amount = new BN(process.argv[9] || "1");
|
||||||
|
if (!process.argv[8]) {
|
||||||
|
throw new Error(
|
||||||
|
"Использование: node scripts/dao/execute_revoke_transaction_full_exec.js <config.env> <realm> <governance> <proposal> <proposal_tx> <mint> <target_owner> [amount]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cfg = parseEnvConfig(configPath);
|
||||||
|
const cluster = cfg.DAO_CLUSTER || "devnet";
|
||||||
|
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
|
||||||
|
const signer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR));
|
||||||
|
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
|
||||||
|
|
||||||
|
const ixRawRevoke = [];
|
||||||
|
await withRevokeGoverningTokens(
|
||||||
|
ixRawRevoke,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
targetOwner,
|
||||||
|
mint,
|
||||||
|
governance,
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
|
||||||
|
|
||||||
|
const ixExecute = [];
|
||||||
|
await withExecuteTransaction(
|
||||||
|
ixExecute,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
governance,
|
||||||
|
proposal,
|
||||||
|
proposalTx,
|
||||||
|
[revokeInstructionData]
|
||||||
|
);
|
||||||
|
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ixExecute), [signer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
console.log("Execute success. Tx:", sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Ошибка execute revoke:", e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
399
shine-solana/shine/scripts/dao/propose_vote_execute_revoke_full_exec.js
Executable file
399
shine-solana/shine/scripts/dao/propose_vote_execute_revoke_full_exec.js
Executable file
@ -0,0 +1,399 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
const BN = require("bn.js");
|
||||||
|
const {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
PublicKey,
|
||||||
|
Transaction,
|
||||||
|
sendAndConfirmTransaction,
|
||||||
|
clusterApiUrl,
|
||||||
|
} = require("@solana/web3.js");
|
||||||
|
const {
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
Vote,
|
||||||
|
YesNoVote,
|
||||||
|
VoteType,
|
||||||
|
InstructionData,
|
||||||
|
AccountMetaData,
|
||||||
|
withRevokeGoverningTokens,
|
||||||
|
withCreateProposal,
|
||||||
|
withInsertTransaction,
|
||||||
|
withSignOffProposal,
|
||||||
|
withCastVote,
|
||||||
|
withExecuteTransaction,
|
||||||
|
withFinalizeVote,
|
||||||
|
getTokenOwnerRecordAddress,
|
||||||
|
getProposalTransactionAddress,
|
||||||
|
} = require("@solana/spl-governance");
|
||||||
|
|
||||||
|
function parseEnvConfig(configPath) {
|
||||||
|
const raw = fs.readFileSync(configPath, "utf8");
|
||||||
|
const out = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let val = trimmed.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
val = val.replace(/\$HOME/g, process.env.HOME || "");
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeypair(filePath) {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
return Keypair.fromSecretKey(Uint8Array.from(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowStamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(
|
||||||
|
d.getMinutes()
|
||||||
|
)}-${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askYes() {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise((resolve) =>
|
||||||
|
rl.question("Введите YES для proposal->vote->execute revoke: ", resolve)
|
||||||
|
);
|
||||||
|
rl.close();
|
||||||
|
return answer.trim() === "YES";
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyExecuteError(msg) {
|
||||||
|
const s = String(msg || "").toLowerCase();
|
||||||
|
if (s.includes("0x20d") || s.includes("hold up time")) {
|
||||||
|
return "HOLD_UP_TIME";
|
||||||
|
}
|
||||||
|
if (s.includes("0x21d") || s.includes("invalid mint authority")) {
|
||||||
|
return "INVALID_MINT_AUTHORITY";
|
||||||
|
}
|
||||||
|
return "OTHER";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const configPath = process.argv[2]
|
||||||
|
? path.resolve(process.argv[2])
|
||||||
|
: path.resolve(__dirname, "dao.config.env");
|
||||||
|
const realmStr = process.argv[3];
|
||||||
|
const governanceStr = process.argv[4];
|
||||||
|
const mintStr = process.argv[5];
|
||||||
|
const targetOwnerStr = process.argv[6];
|
||||||
|
const amountStr = process.argv[7] || "1";
|
||||||
|
if (!realmStr || !governanceStr || !mintStr || !targetOwnerStr) {
|
||||||
|
throw new Error(
|
||||||
|
"Использование: node scripts/dao/propose_vote_execute_revoke_full_exec.js <config.env> <realm> <governance> <mint> <target_owner_pubkey> [amount]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = parseEnvConfig(configPath);
|
||||||
|
const cluster = cfg.DAO_CLUSTER || "devnet";
|
||||||
|
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
|
||||||
|
const proposerKpPath = cfg.DAO_ISSUER_KEYPAIR;
|
||||||
|
if (!proposerKpPath) throw new Error("В конфиге нет DAO_ISSUER_KEYPAIR");
|
||||||
|
const proposer = loadKeypair(path.resolve(proposerKpPath));
|
||||||
|
|
||||||
|
const realm = new PublicKey(realmStr);
|
||||||
|
const governance = new PublicKey(governanceStr);
|
||||||
|
const mint = new PublicKey(mintStr);
|
||||||
|
const targetOwner = new PublicKey(targetOwnerStr);
|
||||||
|
const amount = new BN(amountStr);
|
||||||
|
if (amount.lten(0)) throw new Error("amount должен быть > 0");
|
||||||
|
|
||||||
|
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
|
||||||
|
const proposerRecord = await getTokenOwnerRecordAddress(
|
||||||
|
governanceProgramId,
|
||||||
|
realm,
|
||||||
|
mint,
|
||||||
|
proposer.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("============================================================");
|
||||||
|
console.log("DAO REVOKE THROUGH VOTE");
|
||||||
|
console.log("------------------------------------------------------------");
|
||||||
|
console.log("Сеть: ", cluster);
|
||||||
|
console.log("Governance program: ", governanceProgramId.toBase58());
|
||||||
|
console.log("Realm: ", realm.toBase58());
|
||||||
|
console.log("Governance: ", governance.toBase58());
|
||||||
|
console.log("Mint: ", mint.toBase58());
|
||||||
|
console.log("Target owner: ", targetOwner.toBase58());
|
||||||
|
console.log("Amount: ", amount.toString());
|
||||||
|
console.log("Proposer: ", proposer.publicKey.toBase58());
|
||||||
|
console.log("Proposer record: ", proposerRecord.toBase58());
|
||||||
|
console.log("============================================================");
|
||||||
|
|
||||||
|
const ok = await askYes();
|
||||||
|
if (!ok) {
|
||||||
|
console.log("Отменено пользователем.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposalName = `Revoke ${amount.toString()} from ${targetOwner
|
||||||
|
.toBase58()
|
||||||
|
.slice(0, 8)}...`;
|
||||||
|
const proposalDescription = cfg.DAO_REVOKE_PROPOSAL_URI || cfg.DAO_GOV_TOKEN_METADATA_URI || "https://arweave.net/";
|
||||||
|
|
||||||
|
const ixCreateProposal = [];
|
||||||
|
const proposalPk = await withCreateProposal(
|
||||||
|
ixCreateProposal,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
governance,
|
||||||
|
proposerRecord,
|
||||||
|
proposalName,
|
||||||
|
proposalDescription,
|
||||||
|
mint,
|
||||||
|
proposer.publicKey,
|
||||||
|
undefined,
|
||||||
|
VoteType.SINGLE_CHOICE,
|
||||||
|
["Approve"],
|
||||||
|
true,
|
||||||
|
proposer.publicKey
|
||||||
|
);
|
||||||
|
const sigCreateProposal = await sendAndConfirmTransaction(
|
||||||
|
connection,
|
||||||
|
new Transaction().add(...ixCreateProposal),
|
||||||
|
[proposer],
|
||||||
|
{ commitment: "confirmed" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ixRawRevoke = [];
|
||||||
|
await withRevokeGoverningTokens(
|
||||||
|
ixRawRevoke,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
targetOwner,
|
||||||
|
mint,
|
||||||
|
governance,
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
if (ixRawRevoke.length !== 1) throw new Error("Ожидалась одна инструкция revoke");
|
||||||
|
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
|
||||||
|
|
||||||
|
const ixInsert = [];
|
||||||
|
const proposalTxPk = await withInsertTransaction(
|
||||||
|
ixInsert,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
governance,
|
||||||
|
proposalPk,
|
||||||
|
proposerRecord,
|
||||||
|
proposer.publicKey,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
[revokeInstructionData],
|
||||||
|
proposer.publicKey
|
||||||
|
);
|
||||||
|
const sigInsert = await sendAndConfirmTransaction(connection, new Transaction().add(...ixInsert), [proposer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ixSignOff = [];
|
||||||
|
withSignOffProposal(
|
||||||
|
ixSignOff,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
governance,
|
||||||
|
proposalPk,
|
||||||
|
proposer.publicKey,
|
||||||
|
undefined,
|
||||||
|
proposerRecord
|
||||||
|
);
|
||||||
|
const sigSignOff = await sendAndConfirmTransaction(connection, new Transaction().add(...ixSignOff), [proposer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ixVote = [];
|
||||||
|
const vote = Vote.fromYesNoVote(YesNoVote.Yes);
|
||||||
|
const voteRecordPk = await withCastVote(
|
||||||
|
ixVote,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
governance,
|
||||||
|
proposalPk,
|
||||||
|
proposerRecord,
|
||||||
|
proposerRecord,
|
||||||
|
proposer.publicKey,
|
||||||
|
mint,
|
||||||
|
vote,
|
||||||
|
proposer.publicKey
|
||||||
|
);
|
||||||
|
const sigVote = await sendAndConfirmTransaction(connection, new Transaction().add(...ixVote), [proposer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedProposalTxPk = await getProposalTransactionAddress(
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
proposalPk,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
if (!computedProposalTxPk.equals(proposalTxPk)) {
|
||||||
|
throw new Error("Несовпадение адреса proposal transaction");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sigFinalize = null;
|
||||||
|
try {
|
||||||
|
const ixFinalize = [];
|
||||||
|
await withFinalizeVote(
|
||||||
|
ixFinalize,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
governance,
|
||||||
|
proposalPk,
|
||||||
|
proposerRecord,
|
||||||
|
mint
|
||||||
|
);
|
||||||
|
sigFinalize = await sendAndConfirmTransaction(connection, new Transaction().add(...ixFinalize), [proposer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Может быть уже tipped/succeeded без finalize.
|
||||||
|
}
|
||||||
|
|
||||||
|
let sigExecute = null;
|
||||||
|
let executeError = null;
|
||||||
|
let executeErrorKind = null;
|
||||||
|
try {
|
||||||
|
const ixExecute = [];
|
||||||
|
await withExecuteTransaction(
|
||||||
|
ixExecute,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
governance,
|
||||||
|
proposalPk,
|
||||||
|
proposalTxPk,
|
||||||
|
[revokeInstructionData]
|
||||||
|
);
|
||||||
|
sigExecute = await sendAndConfirmTransaction(connection, new Transaction().add(...ixExecute), [proposer], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
executeError = e?.message || String(e);
|
||||||
|
executeErrorKind = classifyExecuteError(executeError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cluster,
|
||||||
|
configPath,
|
||||||
|
governanceProgramId: governanceProgramId.toBase58(),
|
||||||
|
realm: realm.toBase58(),
|
||||||
|
governance: governance.toBase58(),
|
||||||
|
mint: mint.toBase58(),
|
||||||
|
targetOwner: targetOwner.toBase58(),
|
||||||
|
amount: amount.toString(),
|
||||||
|
proposer: proposer.publicKey.toBase58(),
|
||||||
|
proposerRecord: proposerRecord.toBase58(),
|
||||||
|
proposal: proposalPk.toBase58(),
|
||||||
|
proposalTransaction: proposalTxPk.toBase58(),
|
||||||
|
voteRecord: voteRecordPk.toBase58(),
|
||||||
|
txCreateProposal: sigCreateProposal,
|
||||||
|
txInsertTransaction: sigInsert,
|
||||||
|
txSignOff: sigSignOff,
|
||||||
|
txVote: sigVote,
|
||||||
|
txFinalize: sigFinalize,
|
||||||
|
txExecute: sigExecute,
|
||||||
|
executeError,
|
||||||
|
executeErrorKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportDir = path.resolve(__dirname, "runs");
|
||||||
|
fs.mkdirSync(reportDir, { recursive: true });
|
||||||
|
const reportBaseName = `${nowStamp()}_revoke_${targetOwner.toBase58().slice(0, 10)}`;
|
||||||
|
const reportJsonPath = path.join(reportDir, `${reportBaseName}.json`);
|
||||||
|
const reportTxtPath = path.join(reportDir, `${reportBaseName}.txt`);
|
||||||
|
fs.writeFileSync(reportJsonPath, JSON.stringify(report, null, 2));
|
||||||
|
fs.writeFileSync(
|
||||||
|
reportTxtPath,
|
||||||
|
[
|
||||||
|
`createdAt: ${report.createdAt}`,
|
||||||
|
`cluster: ${report.cluster}`,
|
||||||
|
`realm: ${report.realm}`,
|
||||||
|
`governance: ${report.governance}`,
|
||||||
|
`mint: ${report.mint}`,
|
||||||
|
`targetOwner: ${report.targetOwner}`,
|
||||||
|
`amount: ${report.amount}`,
|
||||||
|
`proposer: ${report.proposer}`,
|
||||||
|
`proposal: ${report.proposal}`,
|
||||||
|
`proposalTransaction: ${report.proposalTransaction}`,
|
||||||
|
`voteRecord: ${report.voteRecord}`,
|
||||||
|
`txCreateProposal: ${report.txCreateProposal}`,
|
||||||
|
`txInsertTransaction: ${report.txInsertTransaction}`,
|
||||||
|
`txSignOff: ${report.txSignOff}`,
|
||||||
|
`txVote: ${report.txVote}`,
|
||||||
|
`txFinalize: ${report.txFinalize || "-"}`,
|
||||||
|
`txExecute: ${report.txExecute || "-"}`,
|
||||||
|
`executeError: ${report.executeError || "-"}`,
|
||||||
|
`executeErrorKind: ${report.executeErrorKind || "-"}`,
|
||||||
|
].join("\n") + "\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("============================================================");
|
||||||
|
console.log("REVOKE ЧЕРЕЗ DAO ГОЛОСОВАНИЕ ВЫПОЛНЕН");
|
||||||
|
console.log("------------------------------------------------------------");
|
||||||
|
console.log("Proposal: ", proposalPk.toBase58());
|
||||||
|
console.log("Proposal Tx: ", proposalTxPk.toBase58());
|
||||||
|
console.log("Tx create proposal: ", sigCreateProposal);
|
||||||
|
console.log("Tx insert revoke instruction: ", sigInsert);
|
||||||
|
console.log("Tx sign off: ", sigSignOff);
|
||||||
|
console.log("Tx cast vote: ", sigVote);
|
||||||
|
if (sigFinalize) console.log("Tx finalize vote: ", sigFinalize);
|
||||||
|
if (sigExecute) {
|
||||||
|
console.log("Tx execute: ", sigExecute);
|
||||||
|
} else {
|
||||||
|
console.log("Execute сейчас не прошел (ожидание voting/hold-up):");
|
||||||
|
console.log("Ошибка execute: ", executeError);
|
||||||
|
if (executeErrorKind === "HOLD_UP_TIME") {
|
||||||
|
console.log("Причина: ", "слишком рано для execute (hold-up / окно голосования еще не завершено)");
|
||||||
|
} else if (executeErrorKind === "INVALID_MINT_AUTHORITY") {
|
||||||
|
console.log("Причина: ", "community mint authority не передан на governance PDA при создании DAO");
|
||||||
|
}
|
||||||
|
console.log("Повтор execute через время этой командой:");
|
||||||
|
console.log(
|
||||||
|
`node scripts/dao/execute_revoke_transaction_full_exec.js ${configPath} ${realm.toBase58()} ${governance.toBase58()} ${proposalPk.toBase58()} ${proposalTxPk.toBase58()} ${mint.toBase58()} ${targetOwner.toBase58()} ${amount.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("Отчёт JSON: ", reportJsonPath);
|
||||||
|
console.log("Отчёт TXT: ", reportTxtPath);
|
||||||
|
console.log("============================================================");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Ошибка proposal/vote/execute revoke:", e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
111
shine-solana/shine/scripts/dao/revoke_member_token_full_exec.js
Executable file
111
shine-solana/shine/scripts/dao/revoke_member_token_full_exec.js
Executable file
@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
const BN = require("bn.js");
|
||||||
|
const { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction, clusterApiUrl } = require("@solana/web3.js");
|
||||||
|
const { PROGRAM_VERSION_V3, withRevokeGoverningTokens } = require("@solana/spl-governance");
|
||||||
|
|
||||||
|
function parseEnvConfig(configPath) {
|
||||||
|
const raw = fs.readFileSync(configPath, "utf8");
|
||||||
|
const out = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let val = trimmed.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
val = val.replace(/\$HOME/g, process.env.HOME || "");
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeypair(filePath) {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
return Keypair.fromSecretKey(Uint8Array.from(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askYes() {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise((resolve) =>
|
||||||
|
rl.question("Введите YES для отзыва (burn/revoke) governance токенов: ", resolve)
|
||||||
|
);
|
||||||
|
rl.close();
|
||||||
|
return answer.trim() === "YES";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const configPath = process.argv[2]
|
||||||
|
? path.resolve(process.argv[2])
|
||||||
|
: path.resolve(__dirname, "dao.config.env");
|
||||||
|
const realmStr = process.argv[3];
|
||||||
|
const mintStr = process.argv[4];
|
||||||
|
const targetOwnerStr = process.argv[5];
|
||||||
|
const amountStr = process.argv[6] || "1";
|
||||||
|
if (!realmStr || !mintStr || !targetOwnerStr) {
|
||||||
|
throw new Error(
|
||||||
|
"Использование: node scripts/dao/revoke_member_token_full_exec.js <config.env> <realm> <mint> <target_owner_pubkey> [amount]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = parseEnvConfig(configPath);
|
||||||
|
const cluster = cfg.DAO_CLUSTER || "devnet";
|
||||||
|
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
|
||||||
|
const revokeKpPath = cfg.DAO_REVOKE_AUTHORITY_KEYPAIR || cfg.DAO_ISSUER_KEYPAIR;
|
||||||
|
if (!revokeKpPath) throw new Error("В конфиге нет DAO_REVOKE_AUTHORITY_KEYPAIR и DAO_ISSUER_KEYPAIR");
|
||||||
|
const revokeAuthority = loadKeypair(path.resolve(revokeKpPath));
|
||||||
|
|
||||||
|
const realm = new PublicKey(realmStr);
|
||||||
|
const mint = new PublicKey(mintStr);
|
||||||
|
const targetOwner = new PublicKey(targetOwnerStr);
|
||||||
|
const amount = new BN(amountStr);
|
||||||
|
if (amount.lten(0)) throw new Error("amount должен быть > 0");
|
||||||
|
|
||||||
|
console.log("============================================================");
|
||||||
|
console.log("REVOKE/BURN GOVERNANCE TOKENS");
|
||||||
|
console.log("------------------------------------------------------------");
|
||||||
|
console.log("Сеть: ", cluster);
|
||||||
|
console.log("Governance program: ", governanceProgramId.toBase58());
|
||||||
|
console.log("Realm: ", realm.toBase58());
|
||||||
|
console.log("Mint: ", mint.toBase58());
|
||||||
|
console.log("Target owner: ", targetOwner.toBase58());
|
||||||
|
console.log("Amount: ", amount.toString());
|
||||||
|
console.log("Revoke authority: ", revokeAuthority.publicKey.toBase58());
|
||||||
|
console.log("============================================================");
|
||||||
|
|
||||||
|
const ok = await askYes();
|
||||||
|
if (!ok) {
|
||||||
|
console.log("Отменено пользователем.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
|
||||||
|
const ix = [];
|
||||||
|
await withRevokeGoverningTokens(
|
||||||
|
ix,
|
||||||
|
governanceProgramId,
|
||||||
|
PROGRAM_VERSION_V3,
|
||||||
|
realm,
|
||||||
|
targetOwner,
|
||||||
|
mint,
|
||||||
|
revokeAuthority.publicKey,
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
|
||||||
|
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ix), [revokeAuthority], {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
console.log("Готово. Tx:", sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Ошибка revoke:", e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
1
shine-solana/shine/settings.gradle
Normal file
1
shine-solana/shine/settings.gradle
Normal file
@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "shine-tools"
|
||||||
328
shine-solana/shine/tests/shine.ts
Normal file
328
shine-solana/shine/tests/shine.ts
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
import * as anchor from "@coral-xyz/anchor";
|
||||||
|
import { Program } from "@coral-xyz/anchor";
|
||||||
|
import {
|
||||||
|
Ed25519Program,
|
||||||
|
PublicKey,
|
||||||
|
SYSVAR_INSTRUCTIONS_PUBKEY,
|
||||||
|
SystemProgram,
|
||||||
|
Transaction,
|
||||||
|
} from "@solana/web3.js";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import { Shine } from "../target/types/shine";
|
||||||
|
|
||||||
|
const MAGIC = Buffer.from("SHiNE", "utf8");
|
||||||
|
const FORMAT_MAJOR = 1;
|
||||||
|
const FORMAT_MINOR = 0;
|
||||||
|
const RESERVED = Buffer.from([0, 0, 0, 0, 0]);
|
||||||
|
const ZERO_HASH = Buffer.alloc(32, 0);
|
||||||
|
const KEY_STATUS_CREATED = 0;
|
||||||
|
|
||||||
|
const LIMIT_STEP = 10_000n;
|
||||||
|
const START_BONUS_LIMIT = 100_000n;
|
||||||
|
const USERS_ECONOMY_CONFIG_SEED = "shine_users_economy_config";
|
||||||
|
const SHINE_PAYMENTS_PROGRAM_ID = new PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||||
|
const SHINE_LOGIN_GUARD_PROGRAM_ID = new PublicKey("3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo");
|
||||||
|
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = "shine_payments_inflow_vault";
|
||||||
|
|
||||||
|
type MutableFields = {
|
||||||
|
blockchainKey: PublicKey;
|
||||||
|
deviceKey: PublicKey;
|
||||||
|
chainNumber: number;
|
||||||
|
isServer: boolean;
|
||||||
|
serverKey: PublicKey;
|
||||||
|
serverAddress: string;
|
||||||
|
syncServers: string[];
|
||||||
|
accessServers: string[];
|
||||||
|
trustedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnsignedRecord = {
|
||||||
|
createdAtMs: bigint;
|
||||||
|
updatedAtMs: bigint;
|
||||||
|
version: number;
|
||||||
|
prevHash: Buffer;
|
||||||
|
login: string;
|
||||||
|
rootKeyStatus: number;
|
||||||
|
rootKey: PublicKey;
|
||||||
|
blockchainKeyStatus: number;
|
||||||
|
blockchainKey: PublicKey;
|
||||||
|
deviceKeyStatus: number;
|
||||||
|
deviceKey: PublicKey;
|
||||||
|
chainNumber: number;
|
||||||
|
balance: bigint;
|
||||||
|
isServer: boolean;
|
||||||
|
serverKey: PublicKey;
|
||||||
|
serverAddress: string;
|
||||||
|
syncServers: string[];
|
||||||
|
accessServers: string[];
|
||||||
|
trustedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function u16le(v: number): Buffer {
|
||||||
|
const b = Buffer.alloc(2);
|
||||||
|
b.writeUInt16LE(v, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function u32le(v: number): Buffer {
|
||||||
|
const b = Buffer.alloc(4);
|
||||||
|
b.writeUInt32LE(v, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function u64le(v: bigint): Buffer {
|
||||||
|
const b = Buffer.alloc(8);
|
||||||
|
b.writeBigUInt64LE(v, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUnsignedRecord(r: UnsignedRecord): Buffer {
|
||||||
|
const loginBytes = Buffer.from(r.login, "utf8");
|
||||||
|
const serverAddressBytes = Buffer.from(r.serverAddress, "utf8");
|
||||||
|
|
||||||
|
const out: Buffer[] = [];
|
||||||
|
out.push(MAGIC);
|
||||||
|
out.push(Buffer.from([FORMAT_MAJOR]));
|
||||||
|
out.push(Buffer.from([FORMAT_MINOR]));
|
||||||
|
out.push(Buffer.alloc(2, 0)); // record_len placeholder
|
||||||
|
|
||||||
|
out.push(u64le(r.createdAtMs));
|
||||||
|
out.push(u64le(r.updatedAtMs));
|
||||||
|
out.push(u32le(r.version));
|
||||||
|
out.push(r.prevHash);
|
||||||
|
|
||||||
|
out.push(Buffer.from([loginBytes.length]));
|
||||||
|
out.push(loginBytes);
|
||||||
|
|
||||||
|
out.push(Buffer.from([r.rootKeyStatus]));
|
||||||
|
out.push(r.rootKey.toBuffer());
|
||||||
|
out.push(Buffer.from([r.blockchainKeyStatus]));
|
||||||
|
out.push(r.blockchainKey.toBuffer());
|
||||||
|
out.push(Buffer.from([r.deviceKeyStatus]));
|
||||||
|
out.push(r.deviceKey.toBuffer());
|
||||||
|
|
||||||
|
out.push(u16le(r.chainNumber));
|
||||||
|
out.push(u64le(r.balance));
|
||||||
|
|
||||||
|
out.push(Buffer.from([r.isServer ? 1 : 0]));
|
||||||
|
if (r.isServer) {
|
||||||
|
out.push(r.serverKey.toBuffer());
|
||||||
|
out.push(Buffer.from([serverAddressBytes.length]));
|
||||||
|
out.push(serverAddressBytes);
|
||||||
|
out.push(Buffer.from([r.syncServers.length]));
|
||||||
|
for (const s of r.syncServers) {
|
||||||
|
const sb = Buffer.from(s, "utf8");
|
||||||
|
out.push(Buffer.from([sb.length]));
|
||||||
|
out.push(sb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(Buffer.from([r.accessServers.length]));
|
||||||
|
for (const s of r.accessServers) {
|
||||||
|
const sb = Buffer.from(s, "utf8");
|
||||||
|
out.push(Buffer.from([sb.length]));
|
||||||
|
out.push(sb);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(Buffer.from([r.trustedCount]));
|
||||||
|
out.push(RESERVED);
|
||||||
|
|
||||||
|
const unsigned = Buffer.concat(out);
|
||||||
|
const recordLen = unsigned.length + 64;
|
||||||
|
unsigned.writeUInt16LE(recordLen, 7);
|
||||||
|
return unsigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buf: Buffer): Buffer {
|
||||||
|
return createHash("sha256").update(buf).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSigFromEdIx(ixData: Buffer): Buffer {
|
||||||
|
const signatureOffset = ixData.readUInt16LE(2);
|
||||||
|
return ixData.subarray(signatureOffset, signatureOffset + 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shine_users e2e", () => {
|
||||||
|
anchor.setProvider(anchor.AnchorProvider.env());
|
||||||
|
const provider = anchor.getProvider() as anchor.AnchorProvider;
|
||||||
|
const program = anchor.workspace.shine as Program<Shine>;
|
||||||
|
|
||||||
|
it("registers user and updates balance/server data", async () => {
|
||||||
|
const login = `u${Date.now().toString().slice(-10)}`;
|
||||||
|
const [userPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[Buffer.from("login="), Buffer.from(login, "utf8")],
|
||||||
|
program.programId
|
||||||
|
);
|
||||||
|
const [usersEconomyConfigPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[Buffer.from(USERS_ECONOMY_CONFIG_SEED, "utf8")],
|
||||||
|
program.programId
|
||||||
|
);
|
||||||
|
const [inflowVaultPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[Buffer.from(SHINE_PAYMENTS_INFLOW_VAULT_SEED, "utf8")],
|
||||||
|
SHINE_PAYMENTS_PROGRAM_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
const economyAi = await provider.connection.getAccountInfo(usersEconomyConfigPda);
|
||||||
|
if (!economyAi) {
|
||||||
|
await program.methods
|
||||||
|
.initUsersEconomyConfig()
|
||||||
|
.accounts({
|
||||||
|
signer: provider.wallet.publicKey,
|
||||||
|
usersEconomyConfigPda,
|
||||||
|
systemProgram: SystemProgram.programId,
|
||||||
|
})
|
||||||
|
.rpc();
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = anchor.web3.Keypair.generate();
|
||||||
|
const blockchainKey = anchor.web3.Keypair.generate().publicKey;
|
||||||
|
const deviceKey = anchor.web3.Keypair.generate().publicKey;
|
||||||
|
const serverKey1 = anchor.web3.Keypair.generate().publicKey;
|
||||||
|
const serverKey2 = anchor.web3.Keypair.generate().publicKey;
|
||||||
|
|
||||||
|
const createdAtMs = BigInt(Date.now());
|
||||||
|
const additionalLimitCreate = 20_000n;
|
||||||
|
expect(additionalLimitCreate % LIMIT_STEP).eq(0n);
|
||||||
|
|
||||||
|
const createRecord: UnsignedRecord = {
|
||||||
|
createdAtMs,
|
||||||
|
updatedAtMs: createdAtMs,
|
||||||
|
version: 0,
|
||||||
|
prevHash: ZERO_HASH,
|
||||||
|
login,
|
||||||
|
rootKeyStatus: KEY_STATUS_CREATED,
|
||||||
|
rootKey: root.publicKey,
|
||||||
|
blockchainKeyStatus: KEY_STATUS_CREATED,
|
||||||
|
blockchainKey,
|
||||||
|
deviceKeyStatus: KEY_STATUS_CREATED,
|
||||||
|
deviceKey,
|
||||||
|
chainNumber: 1,
|
||||||
|
balance: START_BONUS_LIMIT + additionalLimitCreate,
|
||||||
|
isServer: true,
|
||||||
|
serverKey: serverKey1,
|
||||||
|
serverAddress: "https://srv-1.local",
|
||||||
|
syncServers: ["sync_srv_1", "sync_srv_2"],
|
||||||
|
accessServers: ["access_srv_1"],
|
||||||
|
trustedCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUnsigned = serializeUnsignedRecord(createRecord);
|
||||||
|
const createHash = sha256(createUnsigned);
|
||||||
|
const createEdIx = Ed25519Program.createInstructionWithPrivateKey({
|
||||||
|
privateKey: root.secretKey,
|
||||||
|
message: createHash,
|
||||||
|
});
|
||||||
|
const createSig = extractSigFromEdIx(Buffer.from(createEdIx.data));
|
||||||
|
|
||||||
|
const createIx = await program.methods
|
||||||
|
.createUserPda({
|
||||||
|
login,
|
||||||
|
rootKey: root.publicKey,
|
||||||
|
createdAtMs: new anchor.BN(createdAtMs.toString()),
|
||||||
|
additionalLimit: new anchor.BN(additionalLimitCreate.toString()),
|
||||||
|
fields: {
|
||||||
|
blockchainKey,
|
||||||
|
deviceKey,
|
||||||
|
chainNumber: 1,
|
||||||
|
isServer: true,
|
||||||
|
serverKey: serverKey1,
|
||||||
|
serverAddress: "https://srv-1.local",
|
||||||
|
syncServers: ["sync_srv_1", "sync_srv_2"],
|
||||||
|
accessServers: ["access_srv_1"],
|
||||||
|
trustedCount: 0,
|
||||||
|
},
|
||||||
|
signature: createSig,
|
||||||
|
})
|
||||||
|
.accounts({
|
||||||
|
signer: provider.wallet.publicKey,
|
||||||
|
userPda,
|
||||||
|
systemProgram: SystemProgram.programId,
|
||||||
|
inflowVault: inflowVaultPda,
|
||||||
|
instructions: SYSVAR_INSTRUCTIONS_PUBKEY,
|
||||||
|
usersEconomyConfigPda,
|
||||||
|
loginGuardProgram: SHINE_LOGIN_GUARD_PROGRAM_ID,
|
||||||
|
})
|
||||||
|
.instruction();
|
||||||
|
|
||||||
|
await provider.sendAndConfirm(new Transaction().add(createEdIx, createIx), []);
|
||||||
|
|
||||||
|
const createAcc = await provider.connection.getAccountInfo(userPda);
|
||||||
|
expect(createAcc).not.eq(null);
|
||||||
|
expect(createAcc!.owner.toBase58()).eq(program.programId.toBase58());
|
||||||
|
|
||||||
|
const additionalLimitUpdate = 30_000n;
|
||||||
|
expect(additionalLimitUpdate % LIMIT_STEP).eq(0n);
|
||||||
|
|
||||||
|
const updateRecord: UnsignedRecord = {
|
||||||
|
createdAtMs,
|
||||||
|
updatedAtMs: createdAtMs + 1_000n,
|
||||||
|
version: 1,
|
||||||
|
prevHash: sha256(createUnsigned),
|
||||||
|
login,
|
||||||
|
rootKeyStatus: KEY_STATUS_CREATED,
|
||||||
|
rootKey: root.publicKey,
|
||||||
|
blockchainKeyStatus: KEY_STATUS_CREATED,
|
||||||
|
blockchainKey: anchor.web3.Keypair.generate().publicKey,
|
||||||
|
deviceKeyStatus: KEY_STATUS_CREATED,
|
||||||
|
deviceKey: anchor.web3.Keypair.generate().publicKey,
|
||||||
|
chainNumber: 1,
|
||||||
|
balance: START_BONUS_LIMIT + additionalLimitCreate + additionalLimitUpdate,
|
||||||
|
isServer: true,
|
||||||
|
serverKey: serverKey2,
|
||||||
|
serverAddress: "https://srv-2.local",
|
||||||
|
syncServers: ["sync_srv_3"],
|
||||||
|
accessServers: ["access_srv_2", "access_srv_3"],
|
||||||
|
trustedCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUnsigned = serializeUnsignedRecord(updateRecord);
|
||||||
|
const updateHash = sha256(updateUnsigned);
|
||||||
|
const updateEdIx = Ed25519Program.createInstructionWithPrivateKey({
|
||||||
|
privateKey: root.secretKey,
|
||||||
|
message: updateHash,
|
||||||
|
});
|
||||||
|
const updateSig = extractSigFromEdIx(Buffer.from(updateEdIx.data));
|
||||||
|
|
||||||
|
const updateIx = await program.methods
|
||||||
|
.updateUserPda({
|
||||||
|
login,
|
||||||
|
rootKey: root.publicKey,
|
||||||
|
createdAtMs: new anchor.BN(createdAtMs.toString()),
|
||||||
|
updatedAtMs: new anchor.BN((createdAtMs + 1_000n).toString()),
|
||||||
|
version: 1,
|
||||||
|
prevHash: sha256(createUnsigned),
|
||||||
|
additionalLimit: new anchor.BN(additionalLimitUpdate.toString()),
|
||||||
|
fields: {
|
||||||
|
blockchainKey: updateRecord.blockchainKey,
|
||||||
|
deviceKey: updateRecord.deviceKey,
|
||||||
|
chainNumber: 1,
|
||||||
|
isServer: true,
|
||||||
|
serverKey: serverKey2,
|
||||||
|
serverAddress: "https://srv-2.local",
|
||||||
|
syncServers: ["sync_srv_3"],
|
||||||
|
accessServers: ["access_srv_2", "access_srv_3"],
|
||||||
|
trustedCount: 0,
|
||||||
|
},
|
||||||
|
signature: updateSig,
|
||||||
|
})
|
||||||
|
.accounts({
|
||||||
|
signer: provider.wallet.publicKey,
|
||||||
|
userPda,
|
||||||
|
systemProgram: SystemProgram.programId,
|
||||||
|
inflowVault: inflowVaultPda,
|
||||||
|
instructions: SYSVAR_INSTRUCTIONS_PUBKEY,
|
||||||
|
usersEconomyConfigPda,
|
||||||
|
})
|
||||||
|
.instruction();
|
||||||
|
|
||||||
|
await provider.sendAndConfirm(new Transaction().add(updateEdIx, updateIx), []);
|
||||||
|
|
||||||
|
const updatedAcc = await provider.connection.getAccountInfo(userPda);
|
||||||
|
expect(updatedAcc).not.eq(null);
|
||||||
|
const data = updatedAcc!.data;
|
||||||
|
expect(data.subarray(0, 5).toString("utf8")).eq("SHiNE");
|
||||||
|
expect(data[5]).eq(FORMAT_MAJOR);
|
||||||
|
expect(data[6]).eq(FORMAT_MINOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
shine-solana/shine/tsconfig.json
Normal file
10
shine-solana/shine/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["mocha", "chai"],
|
||||||
|
"typeRoots": ["./node_modules/@types"],
|
||||||
|
"lib": ["es2015"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es6",
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
1606
shine-solana/shine/yarn.lock
Normal file
1606
shine-solana/shine/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user