Отключить репосты и добавить 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
|
||||
|
||||
# Локальные артефакты и секреты 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 для обработки запросов по проекту.
|
||||
- Подробные правила работы сервиса, его очередь, история, 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`.
|
||||
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
||||
@ -90,6 +97,15 @@
|
||||
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
||||
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
||||
|
||||
## Будущие фичи
|
||||
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
|
||||
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
||||
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
|
||||
- какие файлы и участки отключены;
|
||||
- что осталось в коде как заготовка;
|
||||
- какие документы нужно обновить при возврате;
|
||||
- с какого сценария продолжать разработку.
|
||||
|
||||
## Коммуникация по новым задачам (обязательно)
|
||||
- При получении нового задания сначала кратко пересказать задачу своими словами.
|
||||
- До начала реализации задать недостающие уточняющие вопросы (если они есть).
|
||||
|
||||
@ -81,11 +81,12 @@
|
||||
- `bad_signature`, `signature_verify_failed`
|
||||
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
||||
- `limit_exceeded`
|
||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||
- `internal_error`
|
||||
|
||||
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
||||
|
||||
Через `AddBlock` можно писать все поддержанные форматы:
|
||||
Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
|
||||
|
||||
1. **TECH (type=0)**
|
||||
- `HEADER_COMPAT (subType=0)`
|
||||
@ -96,7 +97,7 @@
|
||||
- `TEXT_EDIT_POST (11)`
|
||||
- `TEXT_REPLY (20)`
|
||||
- `TEXT_EDIT_REPLY (21)`
|
||||
- `TEXT_REPOST (30)`
|
||||
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||
|
||||
3. **REACTION (type=2)**
|
||||
- `REACTION_LIKE (1)`
|
||||
|
||||
@ -24,7 +24,8 @@ TEXT-тип хранит сообщения и редактирования.
|
||||
5. `subType=30` — `TEXT_REPOST`
|
||||
- репост сообщения в линию канала;
|
||||
- содержит line-поля + target на оригинальное сообщение + текст комментария;
|
||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются).
|
||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
||||
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
||||
|
||||
## Правило для 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
|
||||
- Базовый коммит-ориентир: `5344c42`.
|
||||
- Добавлен новый 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 как обычное текстовое сообщение.
|
||||
- Единственная рабочая реализация сервиса — 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`.
|
||||
- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
|
||||
- Сообщения других пользователей в разрешённом канале сохраняются в историю диалога как контекстные сообщения.
|
||||
- Сообщения других пользователей в разрешённом канале, группе или supergroup сохраняются в историю диалога как контекстные сообщения.
|
||||
- На сообщения других пользователей в группе или supergroup сервис должен коротко отвечать в тот же чат, что сообщение получено, но не ставить их в очередь как задачи.
|
||||
- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
|
||||
- В Telegram-канале `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в этот же канал.
|
||||
- В Telegram-канале/группе `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в тот же чат.
|
||||
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
|
||||
|
||||
## Очередь и состояние
|
||||
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||
- при рестарте восстанавливает незавершённые задачи;
|
||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||
- принимает сообщения из канала `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст.
|
||||
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст;
|
||||
- на сообщения других участников группы отвечает в тот же чат коротким подтверждением получения, не создавая задачу Codex;
|
||||
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
|
||||
|
||||
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
||||
|
||||
@ -27,7 +29,7 @@
|
||||
2. Заполнить секреты в `.env`.
|
||||
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
||||
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`.
|
||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
||||
3. Запуск:
|
||||
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import json
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import subprocess
|
||||
import tempfile
|
||||
@ -350,6 +351,56 @@ class ShinePyBotService:
|
||||
history_path = self._current_history_file()
|
||||
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:
|
||||
message = update.get("message")
|
||||
update_type = "message"
|
||||
@ -371,11 +422,17 @@ class ShinePyBotService:
|
||||
if not isinstance(chat_id, int) or not isinstance(message_id, int):
|
||||
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}"
|
||||
if self._mark_processed_update(update_key):
|
||||
return
|
||||
|
||||
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 = (
|
||||
not is_channel_post
|
||||
or not self.cfg.allowed_channel_username
|
||||
@ -387,10 +444,12 @@ class ShinePyBotService:
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
history_path = self._current_history_file()
|
||||
if author_username != self.cfg.allowed_username:
|
||||
if is_channel_post:
|
||||
self._append_history(history_path, "channel_context_message", {
|
||||
if is_channel_post or is_group_message:
|
||||
self._append_history(history_path, "chat_context_message", {
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"updateType": update_type,
|
||||
"chatType": chat_type,
|
||||
"chatUsername": chat_username,
|
||||
"chatTitle": chat_title,
|
||||
"username": username,
|
||||
@ -399,6 +458,8 @@ class ShinePyBotService:
|
||||
"hasVoice": bool(message.get("voice")),
|
||||
"hasAudio": bool(message.get("audio")),
|
||||
})
|
||||
if is_group_message:
|
||||
self._safe_send(chat_id, "Получил сообщение.", reply_to=message_id)
|
||||
return
|
||||
|
||||
if not text:
|
||||
@ -412,6 +473,7 @@ class ShinePyBotService:
|
||||
chat_username=chat_username,
|
||||
chat_title=chat_title,
|
||||
author_signature=author_signature,
|
||||
chat_type=chat_type,
|
||||
)
|
||||
return
|
||||
if message.get("audio"):
|
||||
@ -424,6 +486,7 @@ class ShinePyBotService:
|
||||
chat_username=chat_username,
|
||||
chat_title=chat_title,
|
||||
author_signature=author_signature,
|
||||
chat_type=chat_type,
|
||||
)
|
||||
return
|
||||
self._safe_send(chat_id, "Поддерживаются текст, voice и audio.", reply_to=message_id)
|
||||
@ -437,6 +500,7 @@ class ShinePyBotService:
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"updateType": update_type,
|
||||
"chatType": chat_type,
|
||||
"chatUsername": chat_username,
|
||||
"chatTitle": chat_title,
|
||||
"username": author_username,
|
||||
@ -447,6 +511,7 @@ class ShinePyBotService:
|
||||
job["type"] = "text"
|
||||
job["text"] = text
|
||||
job["update_type"] = update_type
|
||||
job["chat_type"] = chat_type
|
||||
job["chat_username"] = chat_username
|
||||
job["chat_title"] = chat_title
|
||||
job["author_signature"] = author_signature
|
||||
@ -466,6 +531,7 @@ class ShinePyBotService:
|
||||
chat_username: str = "",
|
||||
chat_title: str = "",
|
||||
author_signature: str = "",
|
||||
chat_type: str = "",
|
||||
) -> None:
|
||||
if not file_id:
|
||||
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
|
||||
@ -475,6 +541,7 @@ class ShinePyBotService:
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"updateType": update_type,
|
||||
"chatType": chat_type,
|
||||
"chatUsername": chat_username,
|
||||
"chatTitle": chat_title,
|
||||
"username": username,
|
||||
@ -485,6 +552,7 @@ class ShinePyBotService:
|
||||
job["type"] = "voice"
|
||||
job["telegram_file_id"] = file_id
|
||||
job["update_type"] = update_type
|
||||
job["chat_type"] = chat_type
|
||||
job["chat_username"] = chat_username
|
||||
job["chat_title"] = chat_title
|
||||
job["author_signature"] = author_signature
|
||||
@ -507,6 +575,7 @@ class ShinePyBotService:
|
||||
"message_id": message_id,
|
||||
"username": username,
|
||||
"update_type": "message",
|
||||
"chat_type": "",
|
||||
"chat_username": "",
|
||||
"chat_title": "",
|
||||
"author_signature": "",
|
||||
@ -717,6 +786,7 @@ class ShinePyBotService:
|
||||
"Пришло сообщение в Telegram.\n"
|
||||
f"Тип: {job.get('type')}\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"Username отправителя: @{job.get('username')}\n"
|
||||
f"Подпись автора в Telegram: {job.get('author_signature') or ''}\n"
|
||||
@ -892,9 +962,20 @@ class ShinePyBotService:
|
||||
return
|
||||
if len(text) > 3900:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
|
||||
def _schedule_self_restart(self) -> None:
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.87
|
||||
server.version=1.2.81
|
||||
client.version=1.2.88
|
||||
server.version=1.2.82
|
||||
|
||||
@ -709,24 +709,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
await handlers.onShare(target);
|
||||
});
|
||||
|
||||
const repostButton = document.createElement('button');
|
||||
repostButton.type = 'button';
|
||||
repostButton.className = 'channel-action-item thread-reply-btn';
|
||||
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);
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
|
||||
@ -1004,19 +1004,9 @@ function renderPostCard(post, {
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
const repostButton = document.createElement('button');
|
||||
repostButton.type = 'button';
|
||||
repostButton.className = 'channel-action-item channel-action-reply';
|
||||
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);
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
|
||||
@ -146,6 +146,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
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);
|
||||
}
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: 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;
|
||||
Chat200CreateSeed chat200CreateSeed = null;
|
||||
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